Skip to content

Commit 91e2cb5

Browse files
authored
feat: Otel Observabillity (#18)
* feat: Otel Observabillity * 🎨 * refactor * install semconv * fix * fix * fix * add middleware * cleanup * tweaks
1 parent 5e2a424 commit 91e2cb5

24 files changed

+1534
-17
lines changed

composer.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
"guzzlehttp/psr7": "^2.8",
2424
"illuminate/collections": "^12.49",
2525
"laravel/prompts": "^0.3.8",
26+
"open-telemetry/api": "^1.8",
27+
"open-telemetry/exporter-otlp": "^1.4",
28+
"open-telemetry/sdk": "^1.13",
29+
"open-telemetry/sem-conv": "^1.38",
2630
"openai-php/client": "^0.18",
2731
"php-mcp/client": "^1.0",
2832
"psr-discovery/cache-implementations": "^1.2",
@@ -104,7 +108,8 @@
104108
"allow-plugins": {
105109
"dealerdirect/phpcodesniffer-composer-installer": true,
106110
"pestphp/pest-plugin": true,
107-
"php-http/discovery": true
111+
"php-http/discovery": true,
112+
"tbachert/spi": true
108113
}
109114
},
110115
"extra": {

config/cortex.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Cortex\LLM\Enums\LLMDriver;
66
use Cortex\LLM\Enums\StreamingProtocol;
77
use Cortex\Agents\Prebuilt\WeatherAgent;
8+
use OpenTelemetry\Contrib\Otlp\Protocols;
89
use Cortex\ModelInfo\Providers\OllamaModelInfoProvider;
910
use Cortex\ModelInfo\Providers\LiteLLMModelInfoProvider;
1011
use Cortex\ModelInfo\Providers\LMStudioModelInfoProvider;
@@ -367,4 +368,45 @@
367368
|
368369
*/
369370
'default_streaming_protocol' => StreamingProtocol::Vercel,
371+
372+
/*
373+
|--------------------------------------------------------------------------
374+
| OpenTelemetry Tracing
375+
|--------------------------------------------------------------------------
376+
|
377+
| Configure OpenTelemetry tracing to export spans for agent runs, LLM calls,
378+
| tool executions, and agent steps to any OTLP-compatible backend
379+
| (e.g. Jaeger, Grafana Tempo, Honeycomb, Datadog).
380+
|
381+
| Set CORTEX_TRACING_ENABLED=true and point OTEL_EXPORTER_OTLP_ENDPOINT
382+
| at your collector or backend to get started.
383+
|
384+
*/
385+
'tracing' => [
386+
'enabled' => env('CORTEX_TRACING_ENABLED', false),
387+
388+
'exporter' => [
389+
/**
390+
* The full OTLP traces endpoint URL.
391+
* For HTTP/protobuf (default): http://localhost:4318/v1/traces
392+
* For gRPC: http://localhost:4317
393+
*/
394+
'endpoint' => env('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318/v1/traces'),
395+
396+
/**
397+
* Supported: "http/protobuf", "http/json"
398+
*/
399+
'protocol' => env('OTEL_EXPORTER_OTLP_PROTOCOL', Protocols::HTTP_PROTOBUF),
400+
401+
/**
402+
* The headers to send with the request. Comma separated list of key=value pairs.
403+
*/
404+
'headers' => env('OTEL_EXPORTER_OTLP_HEADERS', ''),
405+
],
406+
407+
/**
408+
* The service name reported to the tracing backend.
409+
*/
410+
'service_name' => env('OTEL_SERVICE_NAME', 'cortex'),
411+
],
370412
];

src/Agents/Middleware/AfterModelWrapper.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
namespace Cortex\Agents\Middleware;
66

77
use Closure;
8+
use Throwable;
9+
use Cortex\Events\MiddlewareEnd;
10+
use Cortex\Events\MiddlewareError;
11+
use Cortex\Events\MiddlewareStart;
812
use Cortex\Pipeline\RuntimeConfig;
913
use Cortex\Support\Traits\CanPipe;
14+
use Cortex\Support\Traits\DispatchesEvents;
15+
use Cortex\Events\Contracts\MiddlewareEvent;
1016
use Cortex\Agents\Contracts\AfterModelMiddleware;
1117

1218
/**
@@ -16,18 +22,36 @@
1622
class AfterModelWrapper implements AfterModelMiddleware
1723
{
1824
use CanPipe;
25+
use DispatchesEvents;
1926

2027
public function __construct(
2128
protected AfterModelMiddleware $middleware,
2229
) {}
2330

2431
public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed
2532
{
26-
return $this->middleware->afterModel($payload, $config, $next);
33+
$this->dispatchEvent(new MiddlewareStart($this->middleware, $config, 'afterModel'));
34+
35+
try {
36+
$result = $this->middleware->afterModel($payload, $config, $next);
37+
} catch (Throwable $e) {
38+
$this->dispatchEvent(new MiddlewareError($this->middleware, $config, 'afterModel', $e));
39+
40+
throw $e;
41+
}
42+
43+
$this->dispatchEvent(new MiddlewareEnd($this->middleware, $config, 'afterModel'));
44+
45+
return $result;
2746
}
2847

2948
public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed
3049
{
3150
return $this->handlePipeable($payload, $config, $next);
3251
}
52+
53+
protected function eventBelongsToThisInstance(object $event): bool
54+
{
55+
return $event instanceof MiddlewareEvent && $event->middleware === $this->middleware;
56+
}
3357
}

src/Agents/Middleware/BeforeModelWrapper.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
namespace Cortex\Agents\Middleware;
66

77
use Closure;
8+
use Throwable;
9+
use Cortex\Events\MiddlewareEnd;
10+
use Cortex\Events\MiddlewareError;
11+
use Cortex\Events\MiddlewareStart;
812
use Cortex\Pipeline\RuntimeConfig;
913
use Cortex\Support\Traits\CanPipe;
14+
use Cortex\Support\Traits\DispatchesEvents;
15+
use Cortex\Events\Contracts\MiddlewareEvent;
1016
use Cortex\Agents\Contracts\BeforeModelMiddleware;
1117

1218
/**
@@ -16,18 +22,36 @@
1622
class BeforeModelWrapper implements BeforeModelMiddleware
1723
{
1824
use CanPipe;
25+
use DispatchesEvents;
1926

2027
public function __construct(
2128
protected BeforeModelMiddleware $middleware,
2229
) {}
2330

2431
public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed
2532
{
26-
return $this->middleware->beforeModel($payload, $config, $next);
33+
$this->dispatchEvent(new MiddlewareStart($this->middleware, $config, 'beforeModel'));
34+
35+
try {
36+
$result = $this->middleware->beforeModel($payload, $config, $next);
37+
} catch (Throwable $e) {
38+
$this->dispatchEvent(new MiddlewareError($this->middleware, $config, 'beforeModel', $e));
39+
40+
throw $e;
41+
}
42+
43+
$this->dispatchEvent(new MiddlewareEnd($this->middleware, $config, 'beforeModel'));
44+
45+
return $result;
2746
}
2847

2948
public function beforeModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed
3049
{
3150
return $this->handlePipeable($payload, $config, $next);
3251
}
52+
53+
protected function eventBelongsToThisInstance(object $event): bool
54+
{
55+
return $event instanceof MiddlewareEvent && $event->middleware === $this->middleware;
56+
}
3357
}

src/Agents/Middleware/BeforePromptWrapper.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
namespace Cortex\Agents\Middleware;
66

77
use Closure;
8+
use Throwable;
9+
use Cortex\Events\MiddlewareEnd;
10+
use Cortex\Events\MiddlewareError;
11+
use Cortex\Events\MiddlewareStart;
812
use Cortex\Pipeline\RuntimeConfig;
913
use Cortex\Support\Traits\CanPipe;
14+
use Cortex\Support\Traits\DispatchesEvents;
15+
use Cortex\Events\Contracts\MiddlewareEvent;
1016
use Cortex\Agents\Contracts\BeforePromptMiddleware;
1117

1218
/**
@@ -16,18 +22,36 @@
1622
class BeforePromptWrapper implements BeforePromptMiddleware
1723
{
1824
use CanPipe;
25+
use DispatchesEvents;
1926

2027
public function __construct(
2128
protected BeforePromptMiddleware $middleware,
2229
) {}
2330

2431
public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed
2532
{
26-
return $this->middleware->beforePrompt($payload, $config, $next);
33+
$this->dispatchEvent(new MiddlewareStart($this->middleware, $config, 'beforePrompt'));
34+
35+
try {
36+
$result = $this->middleware->beforePrompt($payload, $config, $next);
37+
} catch (Throwable $e) {
38+
$this->dispatchEvent(new MiddlewareError($this->middleware, $config, 'beforePrompt', $e));
39+
40+
throw $e;
41+
}
42+
43+
$this->dispatchEvent(new MiddlewareEnd($this->middleware, $config, 'beforePrompt'));
44+
45+
return $result;
2746
}
2847

2948
public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $next): mixed
3049
{
3150
return $this->handlePipeable($payload, $config, $next);
3251
}
52+
53+
protected function eventBelongsToThisInstance(object $event): bool
54+
{
55+
return $event instanceof MiddlewareEvent && $event->middleware === $this->middleware;
56+
}
3357
}

src/Agents/Prebuilt/WeatherAgent.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string
5555

5656
public function llm(): LLM|string|null
5757
{
58-
return Cortex::llm('ollama', 'qwen3.5:9b')->ignoreFeatures();
58+
return Cortex::llm('lmstudio/openai/gpt-oss-20b')->ignoreFeatures();
59+
// return Cortex::llm('ollama', 'qwen3.5:9b')->ignoreFeatures();
5960
// return Cortex::llm('lmstudio/qwen3.5-9b-mlx')->ignoreFeatures();
6061
// return Cortex::llm('anthropic', 'claude-haiku-4-5')->ignoreFeatures();
6162
// return Cortex::llm('openai', 'gpt-5-mini')->ignoreFeatures();

src/Agents/Registry.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ public function register(Agent|string $agent, ?string $idOverride = null): void
2929
);
3030
}
3131

32-
// @phpstan-ignore function.alreadyNarrowedType
3332
if (! is_subclass_of($agent, AbstractAgentBuilder::class)) {
3433
throw new InvalidArgumentException(
3534
sprintf(

src/CortexServiceProvider.php

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,31 @@
55
namespace Cortex;
66

77
use Throwable;
8-
use Monolog\Logger;
98
use Cortex\LLM\LLMManager;
109
use Cortex\Agents\Registry;
1110
use Cortex\Console\AgentChat;
1211
use Cortex\LLM\Contracts\LLM;
1312
use Cortex\Mcp\McpServerManager;
14-
use Monolog\Handler\StreamHandler;
15-
use Monolog\Formatter\LineFormatter;
1613
use Illuminate\Support\Facades\Blade;
1714
use Cortex\ModelInfo\ModelInfoFactory;
1815
use Spatie\LaravelPackageTools\Package;
1916
use Cortex\Embeddings\EmbeddingsManager;
2017
use Cortex\Prompts\PromptFactoryManager;
18+
use OpenTelemetry\Contrib\Otlp\Protocols;
2119
use Cortex\Embeddings\Contracts\Embeddings;
2220
use Cortex\Prompts\Contracts\PromptFactory;
21+
use OpenTelemetry\Contrib\Otlp\SpanExporter;
22+
use OpenTelemetry\SDK\Resource\ResourceInfo;
2323
use Illuminate\Contracts\Container\Container;
2424
use Cortex\Support\Events\InternalEventDispatcher;
25+
use OpenTelemetry\SDK\Common\Attribute\Attributes;
26+
use OpenTelemetry\SDK\Common\Util\ShutdownHandler;
27+
use OpenTelemetry\SDK\Trace\TracerProviderBuilder;
28+
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
2529
use Spatie\LaravelPackageTools\PackageServiceProvider;
26-
use Cortex\Support\Events\Subscribers\LoggingSubscriber;
30+
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
31+
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
32+
use Cortex\Support\Events\Subscribers\Otel\OpenTelemetrySubscriber;
2733

2834
class CortexServiceProvider extends PackageServiceProvider
2935
{
@@ -55,6 +61,7 @@ public function packageBooted(): void
5561
$this->registerBladeDirectives();
5662

5763
$this->setupLogging();
64+
$this->setupTracing();
5865
}
5966

6067
protected function registerBladeDirectives(): void
@@ -163,12 +170,67 @@ protected function setupLogging(): void
163170
}
164171

165172
// TODO: This will be configurable.
166-
$logger = new Logger('cortex');
167-
$handler = new StreamHandler('php://stdout');
168-
$handler->setFormatter(new LineFormatter());
173+
// $logger = new Logger('cortex');
174+
// $handler = new StreamHandler('php://stdout');
175+
// $handler->setFormatter(new LineFormatter());
169176

170-
$logger->pushHandler($handler);
177+
// $logger->pushHandler($handler);
171178

172-
InternalEventDispatcher::instance()->subscribe(new LoggingSubscriber($logger));
179+
// InternalEventDispatcher::instance()->subscribe(new LoggingSubscriber($logger));
180+
}
181+
182+
protected function setupTracing(): void
183+
{
184+
if ($this->app->runningUnitTests()) {
185+
return;
186+
}
187+
188+
if (! config('cortex.tracing.enabled', false)) {
189+
return;
190+
}
191+
192+
$endpoint = (string) config('cortex.tracing.exporter.endpoint', 'http://localhost:4318/v1/traces');
193+
$protocol = (string) config('cortex.tracing.exporter.protocol', Protocols::HTTP_PROTOBUF);
194+
$serviceName = (string) config('cortex.tracing.service_name', 'cortex');
195+
$rawHeaders = config('cortex.tracing.exporter.headers', '');
196+
$headers = [];
197+
198+
foreach (explode(',', $rawHeaders) as $header) {
199+
$header = trim($header);
200+
201+
if ($header === '') {
202+
continue;
203+
}
204+
205+
$parts = explode('=', $header, 2);
206+
207+
if (count($parts) === 2) {
208+
[$key, $value] = $parts;
209+
$headers[trim($key)] = trim($value, " \t\n\r\0\x0B\"'");
210+
}
211+
}
212+
213+
$resource = ResourceInfoFactory::emptyResource()->merge(
214+
ResourceInfo::create(Attributes::create([
215+
'service.name' => $serviceName,
216+
])),
217+
);
218+
219+
$transport = new OtlpHttpTransportFactory()->create(
220+
$endpoint,
221+
Protocols::contentType($protocol),
222+
$headers,
223+
);
224+
225+
$exporter = new SpanExporter($transport);
226+
227+
$tracerProvider = new TracerProviderBuilder()
228+
->addSpanProcessor(new SimpleSpanProcessor($exporter))
229+
->setResource($resource)
230+
->build();
231+
232+
ShutdownHandler::register($tracerProvider->shutdown(...));
233+
234+
InternalEventDispatcher::instance()->subscribe(new OpenTelemetrySubscriber($tracerProvider));
173235
}
174236
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cortex\Events\Contracts;
6+
7+
use Cortex\Pipeline\RuntimeConfig;
8+
use Cortex\Agents\Contracts\Middleware;
9+
10+
interface MiddlewareEvent extends CortexEvent
11+
{
12+
public Middleware $middleware { get; }
13+
14+
public RuntimeConfig $config { get; }
15+
}

0 commit comments

Comments
 (0)