diff --git a/composer.json b/composer.json index 7c1b34bf4..3a74f2990 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,11 @@ "ext-fileinfo": "*", "laravel/framework": "^11.0|^12.0" }, + "suggest": { + "open-telemetry/sdk": "Required for OTLP telemetry drivers (Phoenix, Langfuse, etc.)", + "open-telemetry/exporter-otlp": "Required for OTLP telemetry drivers", + "google/protobuf": "Required for OTLP telemetry export (or ext-protobuf)" + }, "config": { "allow-plugins": { "php-http/discovery": true, @@ -45,7 +50,10 @@ "symplify/rule-doc-generator-contracts": "^11.2", "phpstan/phpdoc-parser": "^2.0", "spatie/laravel-ray": "^1.39", - "laravel/mcp": "^0.3.2" + "laravel/mcp": "^0.3.2", + "open-telemetry/sdk": "^1.10", + "open-telemetry/exporter-otlp": "^1.3", + "google/protobuf": "^4.29" }, "autoload-dev": { "psr-4": { diff --git a/config/prism.php b/config/prism.php index 58ff34d82..07dbfc166 100644 --- a/config/prism.php +++ b/config/prism.php @@ -62,4 +62,52 @@ ], ], ], + 'telemetry' => [ + 'enabled' => env('PRISM_TELEMETRY_ENABLED', false), + 'driver' => env('PRISM_TELEMETRY_DRIVER', 'null'), + + // Each named driver config specifies a 'driver' key for the actual driver type. + // This allows multiple configs using the same underlying driver (e.g., multiple OTLP endpoints). + 'drivers' => [ + 'null' => [ + 'driver' => 'null', + ], + + 'log' => [ + 'driver' => 'log', + 'channel' => env('PRISM_TELEMETRY_LOG_CHANNEL', 'prism-telemetry'), + ], + + // Phoenix Arize - OTLP with OpenInference semantic conventions + 'phoenix' => [ + 'driver' => 'otlp', + 'endpoint' => env('PHOENIX_ENDPOINT', 'https://app.phoenix.arize.com/v1/traces'), + 'api_key' => env('PHOENIX_API_KEY'), + 'service_name' => env('PHOENIX_SERVICE_NAME', 'prism'), + 'mapper' => \Prism\Prism\Telemetry\Semantics\OpenInferenceMapper::class, + 'timeout' => 30.0, + // Tags applied to all spans (useful for filtering) + 'tags' => [ + 'environment' => env('APP_ENV', 'production'), + 'app' => env('APP_NAME', 'laravel'), + ], + ], + + // Example: Langfuse OTLP backend + // 'langfuse' => [ + // 'driver' => 'otlp', + // 'endpoint' => env('LANGFUSE_ENDPOINT', 'https://cloud.langfuse.com/api/public/otel/v1/traces'), + // 'api_key' => env('LANGFUSE_API_KEY'), + // 'service_name' => env('LANGFUSE_SERVICE_NAME', 'prism'), + // 'mapper' => \Prism\Prism\Telemetry\Semantics\PassthroughMapper::class, + // ], + + // Example: Custom driver via factory class + // 'my-custom' => [ + // 'driver' => 'custom', + // 'via' => App\Telemetry\MyCustomDriverFactory::class, + // // Pass any additional config your factory needs... + // ], + ], + ], ]; diff --git a/src/Concerns/CallsTools.php b/src/Concerns/CallsTools.php index 078001e60..3ca562673 100644 --- a/src/Concerns/CallsTools.php +++ b/src/Concerns/CallsTools.php @@ -11,6 +11,8 @@ use Prism\Prism\Streaming\EventID; use Prism\Prism\Streaming\Events\ArtifactEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; +use Prism\Prism\Telemetry\Events\ToolCallCompleted; +use Prism\Prism\Telemetry\Events\ToolCallStarted; use Prism\Prism\Tool; use Prism\Prism\ValueObjects\ToolCall; use Prism\Prism\ValueObjects\ToolOutput; @@ -18,6 +20,8 @@ trait CallsTools { + use EmitsTelemetry; + /** * Execute tools and return results (for non-streaming handlers). * @@ -48,66 +52,85 @@ protected function callTools(array $tools, array $toolCalls): array protected function callToolsAndYieldEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults): Generator { foreach ($toolCalls as $toolCall) { - try { - $tool = $this->resolveTool($toolCall->name, $tools); - $output = call_user_func_array( - $tool->handle(...), - $toolCall->arguments() - ); - - if (is_string($output)) { - $output = new ToolOutput(result: $output); - } - - $toolResult = new ToolResult( - toolCallId: $toolCall->id, - toolName: $toolCall->name, - args: $toolCall->arguments(), - result: $output->result, - toolCallResultId: $toolCall->resultId, - artifacts: $output->artifacts, - ); - - $toolResults[] = $toolResult; - - yield new ToolResultEvent( - id: EventID::generate(), - timestamp: time(), - toolResult: $toolResult, - messageId: $messageId, - success: true - ); - - foreach ($toolResult->artifacts as $artifact) { - yield new ArtifactEvent( - id: EventID::generate(), - timestamp: time(), - artifact: $artifact, - toolCallId: $toolCall->id, - toolName: $toolCall->name, - messageId: $messageId, - ); - } - } catch (PrismException $e) { - $toolResult = new ToolResult( - toolCallId: $toolCall->id, - toolName: $toolCall->name, - args: $toolCall->arguments(), - result: $e->getMessage(), - toolCallResultId: $toolCall->resultId, - ); - - $toolResults[] = $toolResult; - - yield new ToolResultEvent( - id: EventID::generate(), - timestamp: time(), - toolResult: $toolResult, - messageId: $messageId, - success: false, - error: $e->getMessage() - ); - } + yield from $this->withStreamingTelemetry( + startEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId): ToolCallStarted => new ToolCallStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + toolCall: $toolCall, + ), + endEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId, ToolResultEvent $event): ToolCallCompleted => new ToolCallCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + toolCall: $toolCall, + toolResult: $event->toolResult, + ), + execute: function () use ($tools, $toolCall, $messageId, &$toolResults): Generator { + try { + $tool = $this->resolveTool($toolCall->name, $tools); + + $output = call_user_func_array( + $tool->handle(...), + $toolCall->arguments() + ); + + if (is_string($output)) { + $output = new ToolOutput(result: $output); + } + + $toolResult = new ToolResult( + toolCallId: $toolCall->id, + toolName: $toolCall->name, + args: $toolCall->arguments(), + result: $output->result, + toolCallResultId: $toolCall->resultId, + artifacts: $output->artifacts, + ); + + $toolResults[] = $toolResult; + + yield new ToolResultEvent( + id: EventID::generate(), + timestamp: time(), + toolResult: $toolResult, + messageId: $messageId, + success: true, + ); + + foreach ($toolResult->artifacts as $artifact) { + yield new ArtifactEvent( + id: EventID::generate(), + timestamp: time(), + artifact: $artifact, + toolCallId: $toolCall->id, + toolName: $toolCall->name, + messageId: $messageId, + ); + } + } catch (PrismException $e) { + $toolResult = new ToolResult( + toolCallId: $toolCall->id, + toolName: $toolCall->name, + args: $toolCall->arguments(), + result: $e->getMessage(), + toolCallResultId: $toolCall->resultId, + ); + + $toolResults[] = $toolResult; + + yield new ToolResultEvent( + id: EventID::generate(), + timestamp: time(), + toolResult: $toolResult, + messageId: $messageId, + success: false, + error: $e->getMessage() + ); + } + }, + endEventType: ToolResultEvent::class, + ); } } diff --git a/src/Concerns/EmitsTelemetry.php b/src/Concerns/EmitsTelemetry.php new file mode 100644 index 000000000..d1036bb9e --- /dev/null +++ b/src/Concerns/EmitsTelemetry.php @@ -0,0 +1,143 @@ + event + * @param callable(string, string, ?string, TResponse): object $endEventFactory Factory to create end event (spanId, traceId, parentSpanId, response) => event + * @param callable(): TResponse $execute The operation to execute + * @return TResponse + */ + protected function withTelemetry( + callable $startEventFactory, + callable $endEventFactory, + callable $execute, + ): mixed { + if (! config('prism.telemetry.enabled', false)) { + return $execute(); + } + + // Push telemetry context if HasTelemetryContext trait is used + // @phpstan-ignore function.alreadyNarrowedType + if (method_exists($this, 'pushTelemetryContext')) { + $this->pushTelemetryContext(); + } + + $spanId = bin2hex(random_bytes(8)); + $traceId = Context::getHidden('prism.telemetry.trace_id') ?? bin2hex(random_bytes(16)); + $parentSpanId = Context::getHidden('prism.telemetry.current_span_id'); + + // Use hidden context to avoid leaking telemetry IDs into logs + Context::addHidden('prism.telemetry.trace_id', $traceId); + Context::addHidden('prism.telemetry.current_span_id', $spanId); + + Event::dispatch($startEventFactory($spanId, $traceId, $parentSpanId)); + + try { + $response = $execute(); + + Event::dispatch($endEventFactory($spanId, $traceId, $parentSpanId, $response)); + + return $response; + } catch (\Throwable $e) { + Event::dispatch(new SpanException($spanId, $e)); + + throw $e; + } finally { + Context::addHidden('prism.telemetry.current_span_id', $parentSpanId); + + // Shutdown driver to flush any buffered spans + app(TelemetryDriver::class)->shutdown(); + } + } + + /** + * Execute a generator with telemetry tracking. + * + * If event types are provided, dispatches telemetry when those events are encountered. + * Otherwise, dispatches before/after the entire generator execution. + * + * @template TYield + * + * @param callable(string, string, ?string, mixed): object $startEventFactory Factory to create start event + * @param callable(string, string, ?string, mixed): object $endEventFactory Factory to create end event + * @param callable(): Generator $execute The generator to iterate + * @param class-string|null $startEventType Stream event type that triggers start telemetry (null = dispatch immediately) + * @param class-string|null $endEventType Stream event type that triggers end telemetry (null = dispatch after generator completes) + * @return Generator + */ + protected function withStreamingTelemetry( + callable $startEventFactory, + callable $endEventFactory, + callable $execute, + ?string $startEventType = null, + ?string $endEventType = null, + ): Generator { + if (! config('prism.telemetry.enabled', false)) { + yield from $execute(); + + return; + } + + // Push telemetry context if HasTelemetryContext trait is used + // @phpstan-ignore function.alreadyNarrowedType + if (method_exists($this, 'pushTelemetryContext')) { + $this->pushTelemetryContext(); + } + + $spanId = bin2hex(random_bytes(8)); + $traceId = Context::getHidden('prism.telemetry.trace_id') ?? bin2hex(random_bytes(16)); + $parentSpanId = Context::getHidden('prism.telemetry.current_span_id'); + + // Use hidden context to avoid leaking telemetry IDs into logs + Context::addHidden('prism.telemetry.trace_id', $traceId); + Context::addHidden('prism.telemetry.current_span_id', $spanId); + + // Dispatch start before generator if no event type specified + if ($startEventType === null) { + Event::dispatch($startEventFactory($spanId, $traceId, $parentSpanId, null)); + } + + try { + foreach ($execute() as $event) { + if ($startEventType !== null && $event instanceof $startEventType) { + Event::dispatch($startEventFactory($spanId, $traceId, $parentSpanId, $event)); + } + + if ($endEventType !== null && $event instanceof $endEventType) { + Event::dispatch($endEventFactory($spanId, $traceId, $parentSpanId, $event)); + } + + yield $event; + } + + // Dispatch end after generator if no event type specified + if ($endEventType === null) { + Event::dispatch($endEventFactory($spanId, $traceId, $parentSpanId, null)); + } + } catch (\Throwable $e) { + Event::dispatch(new SpanException($spanId, $e)); + + throw $e; + } finally { + Context::addHidden('prism.telemetry.current_span_id', $parentSpanId); + + // Shutdown driver to flush any buffered spans + app(TelemetryDriver::class)->shutdown(); + } + } +} diff --git a/src/Concerns/HasTelemetryContext.php b/src/Concerns/HasTelemetryContext.php new file mode 100644 index 000000000..3059cb61e --- /dev/null +++ b/src/Concerns/HasTelemetryContext.php @@ -0,0 +1,108 @@ + */ + protected array $telemetryContext = []; + + /** + * Add context metadata for telemetry tracking. + * + * This metadata is included in telemetry spans and can be used + * for filtering, grouping, and analysis in observability platforms. + * + * Common keys: user_id, user_email, session_id, environment, etc. + * + * @param array $context + */ + public function withTelemetryContext(array $context): static + { + $this->telemetryContext = array_merge($this->telemetryContext, $context); + + return $this; + } + + /** + * Set user context for telemetry tracking. + * + * @param string|int|null $userId User identifier + * @param string|null $email User email (optional) + */ + public function forUser(string|int|null $userId, ?string $email = null): static + { + return $this->withTelemetryContext(array_filter([ + 'user_id' => $userId !== null ? (string) $userId : null, + 'user_email' => $email, + ], fn (?string $v): bool => $v !== null)); + } + + /** + * Set conversation/chat session context for telemetry tracking. + * + * The session ID should represent the logical conversation thread, + * NOT the Laravel HTTP session. This allows Phoenix/Arize to group + * all messages in a conversation together. + * + * @param string|int $conversationId The conversation/chat thread ID + */ + public function forConversation(string|int $conversationId): static + { + return $this->withTelemetryContext([ + 'session_id' => (string) $conversationId, + ]); + } + + /** + * Set the agent/bot identifier for telemetry tracking. + * + * Use this when you have multiple AI agents in your application + * to differentiate and group telemetry by agent type. + * + * @param string $agent The agent identifier (e.g., "support-bot", "code-assistant") + */ + public function forAgent(string $agent): static + { + return $this->withTelemetryContext([ + 'agent' => $agent, + ]); + } + + /** + * Set tags for telemetry filtering. + * + * Tags are key-value pairs useful for filtering and grouping in observability platforms. + * + * @param array $tags + */ + public function withTelemetryTags(array $tags): static + { + return $this->withTelemetryContext(['tags' => $tags]); + } + + /** + * Push telemetry context to Laravel's Context facade. + * + * Uses hidden context so telemetry metadata doesn't leak into logs. + * This makes the context available to SpanCollector without + * needing to pass it through events. + */ + protected function pushTelemetryContext(): void + { + $context = array_merge( + Prism::getDefaultTelemetryContext(), + $this->telemetryContext + ); + + if ($context !== []) { + // Use hidden context to avoid leaking telemetry data into logs + Context::addHidden('prism.telemetry.metadata', $context); + } + } +} diff --git a/src/Concerns/InitializesClient.php b/src/Concerns/InitializesClient.php index 7237d2cab..5743bfb43 100644 --- a/src/Concerns/InitializesClient.php +++ b/src/Concerns/InitializesClient.php @@ -4,8 +4,13 @@ namespace Prism\Prism\Concerns; +use Closure; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Support\Facades\Context; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; +use Prism\Prism\Telemetry\Events\HttpCallCompleted; +use Prism\Prism\Telemetry\Events\HttpCallStarted; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -19,6 +24,62 @@ protected function baseClient(): PendingRequest ->withResponseMiddleware(fn (ResponseInterface $response): ResponseInterface => $response) ->timeout($timeout) ->connectTimeout($timeout) + ->when( + config('prism.telemetry.enabled', false), + fn (PendingRequest $client) => $client->withMiddleware($this->telemetryMiddleware()) + ) ->throw(); } + + protected function telemetryMiddleware(): Closure + { + return fn (callable $handler): callable => function ($request, array $options) use ($handler) { + $spanId = bin2hex(random_bytes(8)); + $traceId = Context::getHidden('prism.telemetry.trace_id') ?? bin2hex(random_bytes(16)); + $parentSpanId = Context::getHidden('prism.telemetry.current_span_id'); + + // Use hidden context to avoid leaking telemetry IDs into logs + Context::addHidden('prism.telemetry.trace_id', $traceId); + Context::addHidden('prism.telemetry.current_span_id', $spanId); + + // Extract details from the PSR-7 request + $method = $request->getMethod(); + $url = (string) $request->getUri(); + $headers = $request->getHeaders(); + + Event::dispatch(new HttpCallStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + method: $method, + url: $url, + headers: $headers, + )); + + $promise = $handler($request, $options); + + return $promise->then( + function ($response) use ($spanId, $traceId, $parentSpanId, $method, $url) { + Event::dispatch(new HttpCallCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + method: $method, + url: $url, + statusCode: $response->getStatusCode(), + )); + + Context::addHidden('prism.telemetry.current_span_id', $parentSpanId); + + return $response; + }, + function (\Throwable $reason) use ($parentSpanId): void { + // Reset context even on HTTP errors + Context::addHidden('prism.telemetry.current_span_id', $parentSpanId); + + throw $reason; + } + ); + }; + } } diff --git a/src/Contracts/TelemetryDriver.php b/src/Contracts/TelemetryDriver.php new file mode 100644 index 000000000..3955dc0eb --- /dev/null +++ b/src/Contracts/TelemetryDriver.php @@ -0,0 +1,26 @@ + */ protected array $inputs = []; @@ -101,7 +107,22 @@ public function asEmbeddings(): Response $request = $this->toRequest(); try { - return $this->provider->embeddings($request); + return $this->withTelemetry( + startEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId): \Prism\Prism\Telemetry\Events\EmbeddingGenerationStarted => new EmbeddingGenerationStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + ), + endEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId, Response $response): \Prism\Prism\Telemetry\Events\EmbeddingGenerationCompleted => new EmbeddingGenerationCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + response: $response, + ), + execute: fn (): \Prism\Prism\Embeddings\Response => $this->provider->embeddings($request), + ); } catch (RequestException $e) { $this->provider->handleRequestException($request->model(), $e); } diff --git a/src/Prism.php b/src/Prism.php index 7ed3a4953..8cf392cbd 100644 --- a/src/Prism.php +++ b/src/Prism.php @@ -4,6 +4,7 @@ namespace Prism\Prism; +use Closure; use Illuminate\Support\Traits\Macroable; use Prism\Prism\Audio\PendingRequest as PendingAudioRequest; use Prism\Prism\Embeddings\PendingRequest as PendingEmbeddingRequest; @@ -16,6 +17,71 @@ class Prism { use Macroable; + /** + * The callback to resolve default telemetry context. + * + * @var (Closure(): array)|null + */ + protected static ?Closure $defaultTelemetryContextResolver = null; + + /** + * Set a callback to resolve default telemetry context. + * + * This context will be automatically added to all Prism requests. + * Useful for adding user_id, session_id, etc. globally. + * + * The callback is invoked lazily per-request, so it's safe to reference + * request-scoped services like auth() and session() inside the closure. + * + * @param (Closure(): array)|null $callback + * + * @example + * ```php + * // In AppServiceProvider::boot(): + * Prism::defaultTelemetryContext(fn () => [ + * 'user_id' => auth()->id(), // Resolved fresh each request + * 'session_id' => session()->getId(), + * 'environment' => app()->environment(), + * ]); + * ``` + * + * @warning Do NOT capture request-scoped values in the closure: + * ```php + * // ❌ BAD - $user is captured, will leak between Octane requests + * $user = auth()->user(); + * Prism::defaultTelemetryContext(fn () => ['user_id' => $user->id]); + * + * // ✅ GOOD - auth() is called fresh each request + * Prism::defaultTelemetryContext(fn () => ['user_id' => auth()->id()]); + * ``` + */ + public static function defaultTelemetryContext(?Closure $callback): void + { + static::$defaultTelemetryContextResolver = $callback; + } + + /** + * Get the default telemetry context. + * + * @return array + */ + public static function getDefaultTelemetryContext(): array + { + if (! static::$defaultTelemetryContextResolver instanceof \Closure) { + return []; + } + + return (static::$defaultTelemetryContextResolver)(); + } + + /** + * Clear the default telemetry context resolver. + */ + public static function flushDefaultTelemetryContext(): void + { + static::$defaultTelemetryContextResolver = null; + } + public function text(): PendingTextRequest { return new PendingTextRequest; diff --git a/src/PrismServiceProvider.php b/src/PrismServiceProvider.php index 5294d1ce1..de3197fbf 100644 --- a/src/PrismServiceProvider.php +++ b/src/PrismServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Prism\Prism\Telemetry\TelemetryServiceProvider; class PrismServiceProvider extends ServiceProvider { @@ -48,5 +49,7 @@ public function register(): void 'prism-server', fn (): PrismServer => new PrismServer ); + + $this->app->register(TelemetryServiceProvider::class); } } diff --git a/src/Providers/Anthropic/Handlers/Stream.php b/src/Providers/Anthropic/Handlers/Stream.php index 0dd07e8ef..c0c3fc261 100644 --- a/src/Providers/Anthropic/Handlers/Stream.php +++ b/src/Providers/Anthropic/Handlers/Stream.php @@ -10,6 +10,7 @@ use Illuminate\Support\Arr; use Prism\Prism\Concerns\CallsTools; use Prism\Prism\Enums\FinishReason; +use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Exceptions\PrismStreamDecodeException; use Prism\Prism\Providers\Anthropic\Maps\CitationsMapper; use Prism\Prism\Providers\Anthropic\ValueObjects\AnthropicStreamState; @@ -466,6 +467,8 @@ protected function handleToolUseComplete(): ?ToolCallEvent /** * @return Generator + * + * @throws PrismException */ protected function handleToolCalls(Request $request, int $depth): Generator { diff --git a/src/Structured/PendingRequest.php b/src/Structured/PendingRequest.php index 063a85def..2e2907f9b 100644 --- a/src/Structured/PendingRequest.php +++ b/src/Structured/PendingRequest.php @@ -11,14 +11,18 @@ use Prism\Prism\Concerns\ConfiguresProviders; use Prism\Prism\Concerns\ConfiguresStructuredOutput; use Prism\Prism\Concerns\ConfiguresTools; +use Prism\Prism\Concerns\EmitsTelemetry; use Prism\Prism\Concerns\HasMessages; use Prism\Prism\Concerns\HasPrompts; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasProviderTools; use Prism\Prism\Concerns\HasSchema; +use Prism\Prism\Concerns\HasTelemetryContext; use Prism\Prism\Concerns\HasTools; use Prism\Prism\Contracts\Schema; use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Telemetry\Events\StructuredOutputCompleted; +use Prism\Prism\Telemetry\Events\StructuredOutputStarted; use Prism\Prism\ValueObjects\Messages\UserMessage; class PendingRequest @@ -29,11 +33,13 @@ class PendingRequest use ConfiguresProviders; use ConfiguresStructuredOutput; use ConfiguresTools; + use EmitsTelemetry; use HasMessages; use HasPrompts; use HasProviderOptions; use HasProviderTools; use HasSchema; + use HasTelemetryContext; use HasTools; /** @@ -49,7 +55,22 @@ public function asStructured(): Response $request = $this->toRequest(); try { - return $this->provider->structured($request); + return $this->withTelemetry( + startEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId): \Prism\Prism\Telemetry\Events\StructuredOutputStarted => new StructuredOutputStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + ), + endEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId, Response $response): \Prism\Prism\Telemetry\Events\StructuredOutputCompleted => new StructuredOutputCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + response: $response, + ), + execute: fn (): \Prism\Prism\Structured\Response => $this->provider->structured($request), + ); } catch (RequestException $e) { $this->provider->handleRequestException($request->model(), $e); } diff --git a/src/Telemetry/Contracts/SemanticMapperInterface.php b/src/Telemetry/Contracts/SemanticMapperInterface.php new file mode 100644 index 000000000..b4a15ac2d --- /dev/null +++ b/src/Telemetry/Contracts/SemanticMapperInterface.php @@ -0,0 +1,31 @@ + $attributes Generic Prism attributes + * @return array Mapped attributes in semantic convention format + */ + public function map(string $operation, array $attributes): array; + + /** + * Map events to semantic convention format. + * + * @param array}> $events + * @return array}> + */ + public function mapEvents(array $events): array; +} diff --git a/src/Telemetry/Drivers/LogDriver.php b/src/Telemetry/Drivers/LogDriver.php new file mode 100644 index 000000000..ea7c8cd82 --- /dev/null +++ b/src/Telemetry/Drivers/LogDriver.php @@ -0,0 +1,60 @@ +channel)->info('Span recorded', [ + 'span_id' => $span->spanId, + 'trace_id' => $span->traceId, + 'parent_span_id' => $span->parentSpanId, + 'operation' => $span->operation, + 'start_time_nano' => $span->startTimeNano, + 'end_time_nano' => $span->endTimeNano, + 'duration_ms' => $span->durationMs(), + 'attributes' => $span->attributes, + 'events' => $span->events, + 'has_error' => $span->hasError(), + 'exception' => $span->exception instanceof Throwable ? [ + 'class' => $span->exception::class, + 'message' => $span->exception->getMessage(), + 'file' => $span->exception->getFile(), + 'line' => $span->exception->getLine(), + ] : null, + 'timestamp' => now()->toISOString(), + ]); + } + + /** + * Shutdown (no-op for log driver - logs immediately). + */ + public function shutdown(): void + { + // Log driver writes immediately, nothing to flush + } +} diff --git a/src/Telemetry/Drivers/NullDriver.php b/src/Telemetry/Drivers/NullDriver.php new file mode 100644 index 000000000..b0c6c2001 --- /dev/null +++ b/src/Telemetry/Drivers/NullDriver.php @@ -0,0 +1,32 @@ + */ + protected array $config; + + public function __construct(protected string $driver = 'otlp') + { + $this->config = config("prism.telemetry.drivers.{$this->driver}", []); + + /** @var class-string $mapperClass */ + $mapperClass = $this->config['mapper'] ?? PassthroughMapper::class; + $this->mapper = new $mapperClass; + } + + public function recordSpan(SpanData $spanData): void + { + $this->idGenerator()->primeSpanId($spanData->spanId); + + if (! $spanData->parentSpanId) { + $this->idGenerator()->primeTraceId($spanData->traceId); + } + + $spanBuilder = $this->provider()->getTracer('prism', '1.0.0') + ->spanBuilder($spanData->operation ?: 'unknown') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setStartTimestamp($spanData->startTimeNano); + + if ($spanData->parentSpanId) { + $spanBuilder->setParent(Context::getCurrent()->withContextValue( + Span::wrap(SpanContext::create($spanData->traceId, $spanData->parentSpanId, TraceFlags::SAMPLED)) + )); + } + + $span = $spanBuilder->startSpan(); + + $attributes = $spanData->attributes; + + if ($tags = $this->config['tags'] ?? null) { + data_set($attributes, 'metadata.tags', $tags); + } + + foreach ($this->mapper->map($spanData->operation, $attributes) as $key => $value) { + if ($key !== '') { + $span->setAttribute($key, is_array($value) ? json_encode($value) : $value); + } + } + + foreach ($this->mapper->mapEvents($spanData->events) as $event) { + $span->addEvent($event['name'], Attributes::create($event['attributes']), $event['timeNanos']); + } + + if ($spanData->hasError()) { + $span->setStatus(StatusCode::STATUS_ERROR, $spanData->exception?->getMessage() ?? 'Error'); + } + + $span->end($spanData->endTimeNano); + } + + public function shutdown(): void + { + $this->provider?->shutdown(); + $this->provider = null; + $this->idGenerator = null; + } + + public function getDriver(): string + { + return $this->driver; + } + + protected function provider(): TracerProvider + { + if ($this->provider instanceof \OpenTelemetry\SDK\Trace\TracerProvider) { + return $this->provider; + } + + $transport = (new OtlpHttpTransportFactory)->create( + endpoint: $this->config['endpoint'] ?? 'http://localhost:4318/v1/traces', + contentType: ContentTypes::JSON, + headers: ($key = $this->config['api_key'] ?? null) ? ['Authorization' => "Bearer {$key}"] : [], + timeout: (float) ($this->config['timeout'] ?? 30.0), + ); + + return $this->provider = new TracerProvider( + spanProcessors: [new BatchSpanProcessor(new SpanExporter($transport), Clock::getDefault())], + sampler: new AlwaysOnSampler, + resource: ResourceInfo::create(Attributes::create(['service.name' => $this->config['service_name'] ?? 'prism'])), + idGenerator: $this->idGenerator(), + ); + } + + protected function idGenerator(): PrimedIdGenerator + { + return $this->idGenerator ??= new PrimedIdGenerator; + } +} diff --git a/src/Telemetry/Events/EmbeddingGenerationCompleted.php b/src/Telemetry/Events/EmbeddingGenerationCompleted.php new file mode 100644 index 000000000..84f5b00fc --- /dev/null +++ b/src/Telemetry/Events/EmbeddingGenerationCompleted.php @@ -0,0 +1,37 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/EmbeddingGenerationStarted.php b/src/Telemetry/Events/EmbeddingGenerationStarted.php new file mode 100644 index 000000000..b249364ac --- /dev/null +++ b/src/Telemetry/Events/EmbeddingGenerationStarted.php @@ -0,0 +1,34 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/HttpCallCompleted.php b/src/Telemetry/Events/HttpCallCompleted.php new file mode 100644 index 000000000..3c80eea2b --- /dev/null +++ b/src/Telemetry/Events/HttpCallCompleted.php @@ -0,0 +1,37 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/HttpCallStarted.php b/src/Telemetry/Events/HttpCallStarted.php new file mode 100644 index 000000000..b41087022 --- /dev/null +++ b/src/Telemetry/Events/HttpCallStarted.php @@ -0,0 +1,40 @@ + $headers Request headers + * @property-read int $timeNanos Unix epoch timestamp in nanoseconds + */ +class HttpCallStarted +{ + use Dispatchable; + + public readonly int $timeNanos; + + /** + * @param array $headers Request headers + */ + public function __construct( + public readonly string $spanId, + public readonly string $traceId, + public readonly ?string $parentSpanId, + public readonly string $method, + public readonly string $url, + public readonly array $headers = [], + ?int $timeNanos = null, + ) { + $this->timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/SpanException.php b/src/Telemetry/Events/SpanException.php new file mode 100644 index 000000000..33723c522 --- /dev/null +++ b/src/Telemetry/Events/SpanException.php @@ -0,0 +1,33 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/StreamingCompleted.php b/src/Telemetry/Events/StreamingCompleted.php new file mode 100644 index 000000000..34b21fbe2 --- /dev/null +++ b/src/Telemetry/Events/StreamingCompleted.php @@ -0,0 +1,37 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/StreamingStarted.php b/src/Telemetry/Events/StreamingStarted.php new file mode 100644 index 000000000..3dbc23337 --- /dev/null +++ b/src/Telemetry/Events/StreamingStarted.php @@ -0,0 +1,37 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/StructuredOutputCompleted.php b/src/Telemetry/Events/StructuredOutputCompleted.php new file mode 100644 index 000000000..b2636ed7d --- /dev/null +++ b/src/Telemetry/Events/StructuredOutputCompleted.php @@ -0,0 +1,37 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/StructuredOutputStarted.php b/src/Telemetry/Events/StructuredOutputStarted.php new file mode 100644 index 000000000..7d4d86dfd --- /dev/null +++ b/src/Telemetry/Events/StructuredOutputStarted.php @@ -0,0 +1,34 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/TextGenerationCompleted.php b/src/Telemetry/Events/TextGenerationCompleted.php new file mode 100644 index 000000000..0a6698841 --- /dev/null +++ b/src/Telemetry/Events/TextGenerationCompleted.php @@ -0,0 +1,37 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/TextGenerationStarted.php b/src/Telemetry/Events/TextGenerationStarted.php new file mode 100644 index 000000000..eb990c67c --- /dev/null +++ b/src/Telemetry/Events/TextGenerationStarted.php @@ -0,0 +1,34 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/ToolCallCompleted.php b/src/Telemetry/Events/ToolCallCompleted.php new file mode 100644 index 000000000..6d8136c3c --- /dev/null +++ b/src/Telemetry/Events/ToolCallCompleted.php @@ -0,0 +1,37 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Events/ToolCallStarted.php b/src/Telemetry/Events/ToolCallStarted.php new file mode 100644 index 000000000..a41f5f614 --- /dev/null +++ b/src/Telemetry/Events/ToolCallStarted.php @@ -0,0 +1,34 @@ +timeNanos = $timeNanos ?? now_nanos(); + } +} diff --git a/src/Telemetry/Listeners/TelemetryEventListener.php b/src/Telemetry/Listeners/TelemetryEventListener.php new file mode 100644 index 000000000..81b817f81 --- /dev/null +++ b/src/Telemetry/Listeners/TelemetryEventListener.php @@ -0,0 +1,92 @@ +collector->startSpan($event); + } + + public function handleTextGenerationCompleted(TextGenerationCompleted $event): void + { + $this->collector->endSpan($event); + } + + public function handleStructuredOutputStarted(StructuredOutputStarted $event): void + { + $this->collector->startSpan($event); + } + + public function handleStructuredOutputCompleted(StructuredOutputCompleted $event): void + { + $this->collector->endSpan($event); + } + + public function handleEmbeddingGenerationStarted(EmbeddingGenerationStarted $event): void + { + $this->collector->startSpan($event); + } + + public function handleEmbeddingGenerationCompleted(EmbeddingGenerationCompleted $event): void + { + $this->collector->endSpan($event); + } + + public function handleStreamingStarted(StreamingStarted $event): void + { + $this->collector->startSpan($event); + } + + public function handleStreamingCompleted(StreamingCompleted $event): void + { + $this->collector->endSpan($event); + } + + public function handleHttpCallStarted(HttpCallStarted $event): void + { + $this->collector->startSpan($event); + } + + public function handleHttpCallCompleted(HttpCallCompleted $event): void + { + $this->collector->endSpan($event); + } + + public function handleToolCallStarted(ToolCallStarted $event): void + { + $this->collector->startSpan($event); + } + + public function handleToolCallCompleted(ToolCallCompleted $event): void + { + $this->collector->endSpan($event); + } + + public function handleSpanException(SpanException $event): void + { + $this->collector->recordException($event); + } +} diff --git a/src/Telemetry/Otel/PrimedIdGenerator.php b/src/Telemetry/Otel/PrimedIdGenerator.php new file mode 100644 index 000000000..4bc3f4d39 --- /dev/null +++ b/src/Telemetry/Otel/PrimedIdGenerator.php @@ -0,0 +1,45 @@ + */ + private array $spanIds = []; + + /** @var list */ + private array $traceIds = []; + + public function primeSpanId(string $spanId): void + { + $this->spanIds[] = $spanId; + } + + public function primeTraceId(string $traceId): void + { + $this->traceIds[] = $traceId; + } + + public function generateSpanId(): string + { + return array_shift($this->spanIds) + ?? throw new RuntimeException('PrimedIdGenerator: spanId not primed. Call primeSpanId() before creating a span.'); + } + + public function generateTraceId(): string + { + return array_shift($this->traceIds) + ?? throw new RuntimeException('PrimedIdGenerator: traceId not primed. Call primeTraceId() before creating a root span.'); + } +} diff --git a/src/Telemetry/Semantics/OpenInferenceMapper.php b/src/Telemetry/Semantics/OpenInferenceMapper.php new file mode 100644 index 000000000..9cc50ae0a --- /dev/null +++ b/src/Telemetry/Semantics/OpenInferenceMapper.php @@ -0,0 +1,379 @@ + $attributes + * @return array + */ + public function map(string $operation, array $attributes): array + { + $attrs = match ($operation) { + 'text_generation', 'streaming' => $this->buildLlmSpan($attributes), + 'tool_call' => $this->buildToolSpan($attributes), + 'embedding_generation' => $this->buildEmbeddingSpan($attributes), + 'structured_output' => $this->buildChainSpan($attributes), + 'http_call' => $this->buildHttpSpan($attributes), + default => $this->buildChainSpan($attributes), + }; + + return $this->filterNulls($attrs); + } + + /** + * Convert events to OpenInference format. + * + * @param array}> $events + * @return array}> + */ + public function mapEvents(array $events): array + { + return array_map(fn (array $e): array => [ + 'name' => $e['name'], + 'timeNanos' => $e['timeNanos'], + 'attributes' => $e['name'] === 'exception' ? [ + 'exception.type' => $e['attributes']['type'] ?? 'Unknown', + 'exception.message' => $e['attributes']['message'] ?? '', + 'exception.stacktrace' => $e['attributes']['stacktrace'] ?? '', + 'exception.escaped' => true, + ] : $e['attributes'], + ], $events); + } + + /** + * @param array $a + * @return array + */ + protected function buildLlmSpan(array $a): array + { + $attrs = [ + 'openinference.span.kind' => 'LLM', + 'llm.model_name' => $a['model'] ?? null, + 'llm.response.model' => $a['response_model'] ?? null, + 'llm.provider' => $a['provider'] ?? null, + 'llm.service_tier' => $a['service_tier'] ?? null, + 'llm.invocation_parameters' => $this->buildInvocationParams($a), + 'llm.response.id' => $a['response_id'] ?? null, + 'llm.tool_choice' => isset($a['tool_choice']) ? json_encode($a['tool_choice']) : null, + // Prompt template support (if provided) + 'llm.prompt_template.template' => $a['prompt_template'] ?? null, + 'llm.prompt_template.variables' => isset($a['prompt_variables']) ? json_encode($a['prompt_variables']) : null, + ]; + + $this->addSystemPrompt($attrs, $a['messages'] ?? []); + $this->addInputOutput($attrs, $a); + $this->addTokenUsage($attrs, $a['usage'] ?? null); + $this->addInputMessages($attrs, $a['messages'] ?? []); + $this->addOutputMessages($attrs, $a['output'] ?? '', $a['tool_calls'] ?? [], $a['finish_reason'] ?? null); + $this->addToolDefinitions($attrs, $a['tools'] ?? []); + $this->addMetadata($attrs, $a['metadata'] ?? []); + + return $attrs; + } + + /** + * @param array $a + * @return array + */ + protected function buildToolSpan(array $a): array + { + $tool = $a['tool'] ?? []; + $output = $a['output'] ?? null; + + $attrs = [ + 'openinference.span.kind' => 'TOOL', + 'tool.name' => $tool['name'] ?? null, + 'tool.call.id' => $tool['call_id'] ?? null, + 'tool.call.function.name' => $tool['name'] ?? null, + 'tool.call.function.arguments' => isset($tool['arguments']) ? json_encode($tool['arguments']) : null, + 'tool.description' => $tool['description'] ?? null, + 'tool.parameters' => isset($tool['parameters']) ? json_encode($tool['parameters']) : null, + 'tool.output' => is_string($output) ? $output : (isset($output) ? json_encode($output) : null), + 'input.value' => isset($a['input']) ? json_encode($a['input']) : null, + 'input.mime_type' => isset($a['input']) ? 'application/json' : null, + 'output.value' => is_string($output) ? $output : (isset($output) ? json_encode($output) : null), + 'output.mime_type' => isset($output) ? (is_string($output) ? 'text/plain' : 'application/json') : null, + ]; + + $this->addMetadata($attrs, $a['metadata'] ?? []); + + return $attrs; + } + + /** + * @param array $a + * @return array + */ + protected function buildEmbeddingSpan(array $a): array + { + $attrs = [ + 'openinference.span.kind' => 'EMBEDDING', + 'embedding.model_name' => $a['model'] ?? null, + 'embedding.provider' => $a['provider'] ?? null, + ]; + + // Per OpenInference spec: embedding.embeddings.{i}.embedding.text + foreach ($a['inputs'] ?? [] as $i => $text) { + $attrs["embedding.embeddings.{$i}.embedding.text"] = $text; + } + + // Also set input.value as per spec for span-level input + if (! empty($a['inputs'])) { + $attrs['input.value'] = count($a['inputs']) === 1 + ? $a['inputs'][0] + : json_encode($a['inputs']); + $attrs['input.mime_type'] = count($a['inputs']) === 1 + ? 'text/plain' + : 'application/json'; + } + + $this->addTokenUsage($attrs, $a['usage'] ?? null); + $this->addMetadata($attrs, $a['metadata'] ?? []); + + return $attrs; + } + + /** + * @param array $a + * @return array + */ + protected function buildChainSpan(array $a): array + { + $attrs = [ + 'openinference.span.kind' => 'CHAIN', + 'llm.model_name' => $a['model'] ?? null, + 'llm.response.model' => $a['response_model'] ?? null, + 'llm.provider' => $a['provider'] ?? null, + 'llm.service_tier' => $a['service_tier'] ?? null, + 'llm.response.id' => $a['response_id'] ?? null, + 'output.schema.name' => $a['schema']['name'] ?? null, + 'output.schema' => isset($a['schema']['definition']) ? json_encode($a['schema']['definition']) : null, + ]; + + $this->addInputOutput($attrs, $a); + $this->addTokenUsage($attrs, $a['usage'] ?? null); + $this->addInputMessages($attrs, $a['messages'] ?? []); + $this->addToolDefinitions($attrs, $a['tools'] ?? []); + $this->addMetadata($attrs, $a['metadata'] ?? []); + + return $attrs; + } + + /** + * @param array $a + * @return array + */ + protected function buildHttpSpan(array $a): array + { + $http = $a['http'] ?? []; + + $attrs = [ + 'openinference.span.kind' => 'CHAIN', + 'http.method' => $http['method'] ?? null, + 'http.url' => $http['url'] ?? null, + 'http.status_code' => $http['status_code'] ?? null, + ]; + + $this->addMetadata($attrs, $a['metadata'] ?? []); + + return $attrs; + } + + /** + * @param array $a + */ + protected function buildInvocationParams(array $a): ?string + { + $params = $this->filterNulls([ + 'temperature' => $a['temperature'] ?? null, + 'max_tokens' => $a['max_tokens'] ?? null, + 'top_p' => $a['top_p'] ?? null, + ]); + + return $params !== [] ? (json_encode($params) ?: null) : null; + } + + /** + * @param array $attrs + * @param array> $messages + */ + protected function addSystemPrompt(array &$attrs, array $messages): void + { + $systemContent = collect($messages) + ->filter(fn ($m): bool => ($m['role'] ?? '') === 'system') + ->pluck('content') + ->implode("\n"); + + if ($systemContent) { + $attrs['llm.system'] = $systemContent; + } + } + + /** + * @param array $attrs + * @param array $a + */ + protected function addInputOutput(array &$attrs, array $a): void + { + $input = $a['input'] ?? null; + $output = $a['output'] ?? null; + + $attrs['input.value'] = is_string($input) ? $input : (isset($input) ? json_encode($input) : null); + $attrs['input.mime_type'] = isset($input) ? (is_string($input) ? 'text/plain' : 'application/json') : null; + $attrs['output.value'] = is_string($output) ? $output : (isset($output) ? json_encode($output) : null); + $attrs['output.mime_type'] = isset($output) ? (is_string($output) ? 'text/plain' : 'application/json') : null; + } + + /** + * @param array $attrs + * @param array|null $usage + */ + protected function addTokenUsage(array &$attrs, ?array $usage): void + { + if (! $usage) { + return; + } + + $prompt = $usage['prompt_tokens'] ?? $usage['tokens'] ?? null; + $completion = $usage['completion_tokens'] ?? null; + + $attrs['llm.token_count.prompt'] = $prompt; + $attrs['llm.token_count.completion'] = $completion; + $attrs['llm.token_count.total'] = ($prompt !== null && $completion !== null) ? $prompt + $completion : null; + } + + /** + * @param array $attrs + * @param array> $messages + */ + protected function addInputMessages(array &$attrs, array $messages): void + { + foreach ($messages as $i => $msg) { + $attrs["llm.input_messages.{$i}.message.role"] = $msg['role']; + $attrs["llm.input_messages.{$i}.message.content"] = $msg['content']; + + foreach ($msg['tool_calls'] ?? [] as $j => $tc) { + $prefix = "llm.input_messages.{$i}.message.tool_calls.{$j}.tool_call"; + $attrs["{$prefix}.id"] = $tc['id'] ?? null; + $attrs["{$prefix}.function.name"] = $tc['name']; + $attrs["{$prefix}.function.arguments"] = json_encode($tc['arguments']); + } + } + } + + /** + * @param array $attrs + * @param array> $toolCalls + */ + protected function addOutputMessages(array &$attrs, string $text, array $toolCalls, ?string $finishReason = null): void + { + if (! $text && ! $toolCalls) { + return; + } + + $attrs['llm.output_messages.0.message.role'] = 'assistant'; + $attrs['llm.output_messages.0.message.content'] = $text; + + // Per OpenInference spec, finish_reason goes on the output message + if ($finishReason !== null) { + $attrs['llm.output_messages.0.message.finish_reason'] = $finishReason; + } + + foreach ($toolCalls as $i => $tc) { + $prefix = "llm.output_messages.0.message.tool_calls.{$i}.tool_call"; + $attrs["{$prefix}.id"] = $tc['id']; + $attrs["{$prefix}.function.name"] = $tc['name']; + $attrs["{$prefix}.function.arguments"] = json_encode($tc['arguments']); + } + } + + /** + * @param array $attrs + * @param array> $tools + */ + protected function addToolDefinitions(array &$attrs, array $tools): void + { + foreach ($tools as $i => $tool) { + $attrs["llm.tools.{$i}.tool.json_schema"] = json_encode([ + 'type' => 'function', + 'function' => [ + 'name' => $tool['name'], + 'description' => $tool['description'], + 'parameters' => $tool['parameters'], + ], + ]); + } + } + + /** + * Add metadata to attributes, handling special OpenInference user/session attributes. + * + * @see https://github.com/Arize-ai/openinference/blob/main/spec/semantic_conventions.md#user-attributes + * + * @param array $attrs + * @param array $metadata + */ + protected function addMetadata(array &$attrs, array $metadata): void + { + // Handle special OpenInference user attributes + if (isset($metadata['user_id'])) { + $attrs['user.id'] = (string) $metadata['user_id']; + unset($metadata['user_id']); + } + + if (isset($metadata['session_id'])) { + $attrs['session.id'] = (string) $metadata['session_id']; + unset($metadata['session_id']); + } + + // Handle agent as a reserved OpenInference attribute + // @see https://arize-ai.github.io/openinference/spec/semantic_conventions.html + if (isset($metadata['agent'])) { + $attrs['agent.name'] = (string) $metadata['agent']; + unset($metadata['agent']); + } + + // Handle tags per OpenInference spec - tag.tags is a list of strings + // @see https://arize-ai.github.io/openinference/spec/semantic_conventions.html + if (isset($metadata['tags']) && is_array($metadata['tags'])) { + $tagList = []; + foreach ($metadata['tags'] as $tagKey => $tagValue) { + // Convert key-value pairs to "key:value" format + $tagList[] = is_int($tagKey) + ? (string) $tagValue + : "{$tagKey}:{$tagValue}"; + } + $attrs['tag.tags'] = json_encode($tagList); + unset($metadata['tags']); + } + + // Remaining metadata goes under metadata.* prefix + foreach ($metadata as $key => $value) { + $attrs["metadata.{$key}"] = is_string($value) ? $value : json_encode($value); + } + } + + /** + * @param array $array + * @return array + */ + protected function filterNulls(array $array): array + { + return array_filter($array, fn ($v): bool => $v !== null); + } +} diff --git a/src/Telemetry/Semantics/PassthroughMapper.php b/src/Telemetry/Semantics/PassthroughMapper.php new file mode 100644 index 000000000..db0a93ed9 --- /dev/null +++ b/src/Telemetry/Semantics/PassthroughMapper.php @@ -0,0 +1,33 @@ + $attributes + * @return array + */ + public function map(string $operation, array $attributes): array + { + return $attributes; + } + + /** + * @param array}> $events + * @return array}> + */ + public function mapEvents(array $events): array + { + return $events; + } +} diff --git a/src/Telemetry/SpanCollector.php b/src/Telemetry/SpanCollector.php new file mode 100644 index 000000000..401c32721 --- /dev/null +++ b/src/Telemetry/SpanCollector.php @@ -0,0 +1,451 @@ +> */ + protected array $pendingSpans = []; + + public function __construct( + protected TelemetryDriver $driver + ) {} + + public function startSpan(TextGenerationStarted|StructuredOutputStarted|EmbeddingGenerationStarted|StreamingStarted|HttpCallStarted|ToolCallStarted $event): string + { + $spanId = $event->spanId; + + $this->pendingSpans[$spanId] = [ + 'spanId' => $spanId, + 'traceId' => $event->traceId, + 'parentSpanId' => $event->parentSpanId, + 'operation' => $this->getOperation($event), + 'startTimeNano' => $event->timeNanos, + 'attributes' => $this->extractStartAttributes($event), + 'events' => [], + 'exception' => null, + ]; + + return $spanId; + } + + public function endSpan(TextGenerationCompleted|StructuredOutputCompleted|EmbeddingGenerationCompleted|StreamingCompleted|HttpCallCompleted|ToolCallCompleted $event): void + { + if (! isset($this->pendingSpans[$event->spanId])) { + return; + } + + $pending = &$this->pendingSpans[$event->spanId]; + $endAttrs = $this->extractEndAttributes($event); + + // Deep merge metadata, shallow merge everything else + $startMetadata = $pending['attributes']['metadata'] ?? []; + $endMetadata = $endAttrs['metadata'] ?? []; + unset($pending['attributes']['metadata'], $endAttrs['metadata']); + + $pending['attributes'] = array_merge($pending['attributes'], $endAttrs); + + if ($startMetadata || $endMetadata) { + $pending['attributes']['metadata'] = array_merge($startMetadata, $endMetadata); + } + + $this->driver->recordSpan(new SpanData( + spanId: $pending['spanId'], + traceId: $pending['traceId'], + parentSpanId: $pending['parentSpanId'], + operation: $pending['operation'], + startTimeNano: $pending['startTimeNano'], + endTimeNano: $event->timeNanos, + attributes: $this->filterNulls($pending['attributes']), + events: $pending['events'], + exception: $pending['exception'], + )); + + unset($this->pendingSpans[$event->spanId]); + } + + /** + * Shutdown the telemetry driver, flushing any buffered spans. + * + * Should be called when a Prism operation completes. + */ + public function shutdown(): void + { + $this->driver->shutdown(); + } + + /** + * @param array $attributes + */ + public function addEvent(string $spanId, string $name, int $timeNanos, array $attributes = []): void + { + if (isset($this->pendingSpans[$spanId])) { + $this->pendingSpans[$spanId]['events'][] = [ + 'name' => $name, + 'timeNanos' => $timeNanos, + 'attributes' => $attributes, + ]; + } + } + + public function recordException(SpanException $event): void + { + if (isset($this->pendingSpans[$event->spanId])) { + $this->pendingSpans[$event->spanId]['exception'] = $event->exception; + $this->pendingSpans[$event->spanId]['events'][] = [ + 'name' => 'exception', + 'timeNanos' => $event->timeNanos, + 'attributes' => [ + 'type' => $event->exception::class, + 'message' => $event->exception->getMessage(), + 'stacktrace' => $event->exception->getTraceAsString(), + ], + ]; + } + } + + // ======================================================================== + // Operation & Attribute Extraction + // ======================================================================== + + protected function getOperation(TextGenerationStarted|StructuredOutputStarted|EmbeddingGenerationStarted|StreamingStarted|HttpCallStarted|ToolCallStarted $event): string + { + return match (true) { + $event instanceof TextGenerationStarted => 'text_generation', + $event instanceof StructuredOutputStarted => 'structured_output', + $event instanceof EmbeddingGenerationStarted => 'embedding_generation', + $event instanceof StreamingStarted => 'streaming', + $event instanceof HttpCallStarted => 'http_call', + $event instanceof ToolCallStarted => 'tool_call', + }; + } + + /** @return array */ + protected function extractStartAttributes(TextGenerationStarted|StructuredOutputStarted|EmbeddingGenerationStarted|StreamingStarted|HttpCallStarted|ToolCallStarted $event): array + { + $attrs = match (true) { + $event instanceof TextGenerationStarted => $this->extractTextGenerationStart($event), + $event instanceof StructuredOutputStarted => $this->extractStructuredOutputStart($event), + $event instanceof EmbeddingGenerationStarted => $this->extractEmbeddingStart($event), + $event instanceof StreamingStarted => $this->extractStreamingStart($event), + $event instanceof HttpCallStarted => $this->extractHttpStart($event), + $event instanceof ToolCallStarted => $this->extractToolCallStart($event), + }; + + // Add user metadata from Laravel hidden Context (set by HasTelemetryContext trait) + $metadata = Context::getHidden('prism.telemetry.metadata'); + if (! empty($metadata)) { + $attrs['metadata'] = $metadata; + } + + return $attrs; + } + + /** @return array */ + protected function extractEndAttributes(TextGenerationCompleted|StructuredOutputCompleted|EmbeddingGenerationCompleted|StreamingCompleted|HttpCallCompleted|ToolCallCompleted $event): array + { + return match (true) { + $event instanceof TextGenerationCompleted => $this->extractTextGenerationEnd($event), + $event instanceof StructuredOutputCompleted => $this->extractStructuredOutputEnd($event), + $event instanceof EmbeddingGenerationCompleted => $this->extractEmbeddingEnd($event), + $event instanceof StreamingCompleted => $this->extractStreamingEnd($event), + $event instanceof HttpCallCompleted => ['http' => ['status_code' => $event->statusCode]], + $event instanceof ToolCallCompleted => $this->extractToolCallEnd($event), + }; + } + + // ======================================================================== + // Start Event Extractors + // ======================================================================== + + /** @return array */ + protected function extractTextGenerationStart(TextGenerationStarted $event): array + { + $r = $event->request; + + return array_merge($this->extractLlmRequestAttributes($r), [ + 'tools' => $r->tools() ? $this->buildToolDefinitions($r->tools()) : null, + 'tool_choice' => $r->toolChoice() ? $this->formatToolChoice($r->toolChoice()) : null, + ]); + } + + /** @return array */ + protected function extractStreamingStart(StreamingStarted $event): array + { + $attrs = $this->extractLlmRequestAttributes($event->request); + + // Merge stream start data if available, capturing response model separately + if ($event->streamStart !== null) { + $startData = $event->streamStart->toArray(); + + // Capture response model separately (the actual model used may differ from requested) + if (isset($startData['model'])) { + $attrs['response_model'] = $startData['model']; + unset($startData['model']); + } + + $attrs = array_merge($attrs, $startData); + } + + return $attrs; + } + + /** @return array */ + protected function extractStructuredOutputStart(StructuredOutputStarted $event): array + { + $r = $event->request; + + return array_merge($this->extractLlmRequestAttributes($r), [ + 'schema' => [ + 'name' => $r->schema()->name(), + 'definition' => $r->schema()->toArray(), + ], + 'tools' => $r->tools() ? $this->buildToolDefinitions($r->tools()) : null, + 'tool_choice' => $r->toolChoice() ? $this->formatToolChoice($r->toolChoice()) : null, + ]); + } + + /** @return array */ + protected function extractEmbeddingStart(EmbeddingGenerationStarted $event): array + { + $r = $event->request; + + return [ + 'model' => $r->model(), + 'provider' => $r->provider(), + 'inputs' => $r->inputs(), + ]; + } + + /** @return array */ + protected function extractToolCallStart(ToolCallStarted $event): array + { + $tc = $event->toolCall; + + return [ + 'tool' => [ + 'name' => $tc->name, + 'call_id' => $tc->id, + 'arguments' => $tc->arguments(), + ], + 'input' => $tc->arguments(), + ]; + } + + /** @return array */ + protected function extractHttpStart(HttpCallStarted $event): array + { + return [ + 'http' => [ + 'method' => $event->method, + 'url' => $event->url, + ], + ]; + } + + // ======================================================================== + // End Event Extractors + // ======================================================================== + + /** @return array */ + protected function extractTextGenerationEnd(TextGenerationCompleted $event): array + { + $r = $event->response; + + return [ + 'output' => $r->text, + 'response_model' => $r->meta->model, + 'service_tier' => $r->meta->serviceTier, + 'usage' => [ + 'prompt_tokens' => $r->usage->promptTokens, + 'completion_tokens' => $r->usage->completionTokens, + ], + 'response_id' => $r->meta->id, + 'finish_reason' => $r->finishReason->name, + 'tool_calls' => $r->toolCalls ? array_map(fn (\Prism\Prism\ValueObjects\ToolCall $tc): array => [ + 'id' => $tc->id, + 'name' => $tc->name, + 'arguments' => $tc->arguments(), + ], $r->toolCalls) : null, + ]; + } + + /** @return array */ + protected function extractStructuredOutputEnd(StructuredOutputCompleted $event): array + { + $r = $event->response; + + return [ + 'output' => $r->structured, + 'response_model' => $r->meta->model, + 'service_tier' => $r->meta->serviceTier, + 'usage' => [ + 'prompt_tokens' => $r->usage->promptTokens, + 'completion_tokens' => $r->usage->completionTokens, + ], + 'response_id' => $r->meta->id, + 'finish_reason' => $r->finishReason->name, + ]; + } + + /** @return array */ + protected function extractEmbeddingEnd(EmbeddingGenerationCompleted $event): array + { + return [ + 'usage' => ['tokens' => $event->response->usage->tokens], + 'embedding_count' => count($event->response->embeddings), + ]; + } + + /** @return array */ + protected function extractStreamingEnd(StreamingCompleted $event): array + { + if ($event->streamEnd === null) { + return []; + } + + return $event->streamEnd->toArray(); + } + + /** @return array */ + protected function extractToolCallEnd(ToolCallCompleted $event): array + { + return [ + 'output' => $event->toolResult->result, + ]; + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /** + * Extract common attributes from LLM request objects. + * + * @return array + */ + protected function extractLlmRequestAttributes(TextRequest|StructuredRequest $request): array + { + return [ + 'model' => $request->model(), + 'provider' => $request->provider(), + 'temperature' => $request->temperature(), + 'max_tokens' => $request->maxTokens(), + 'top_p' => $request->topP(), + 'input' => $request->prompt(), + 'messages' => $this->buildMessages($request->systemPrompts(), $request->messages()), + ]; + } + + /** + * @param SystemMessage[] $systemPrompts + * @param Message[] $messages + * @return array}>}> + */ + protected function buildMessages(array $systemPrompts, array $messages): array + { + $result = []; + + foreach ($systemPrompts as $prompt) { + $result[] = ['role' => 'system', 'content' => $prompt->content]; + } + + foreach ($messages as $message) { + if ($formatted = $this->formatMessage($message)) { + $result[] = $formatted; + } + } + + return $result; + } + + /** @return array{role: string, content: string, tool_calls?: array}>}|null */ + protected function formatMessage(Message $message): ?array + { + return match (true) { + $message instanceof UserMessage => ['role' => 'user', 'content' => $message->text()], + $message instanceof AssistantMessage => array_filter([ + 'role' => 'assistant', + 'content' => $message->content, + 'tool_calls' => $message->toolCalls ? array_map(fn (\Prism\Prism\ValueObjects\ToolCall $tc): array => [ + 'id' => $tc->id, + 'name' => $tc->name, + 'arguments' => $tc->arguments(), + ], $message->toolCalls) : null, + ], fn ($v): bool => $v !== null), + $message instanceof ToolResultMessage => [ + 'role' => 'tool', + 'content' => implode("\n", array_map( + fn (\Prism\Prism\ValueObjects\ToolResult $tr): string|false => is_string($tr->result) ? $tr->result : json_encode($tr->result), + $message->toolResults + )), + ], + $message instanceof SystemMessage => ['role' => 'system', 'content' => $message->content], + default => null, + }; + } + + /** + * @param Tool[] $tools + * @return array, required: array}}> + */ + protected function buildToolDefinitions(array $tools): array + { + return array_map(fn (Tool $tool): array => [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => $tool->parametersAsArray(), + 'required' => $tool->requiredParameters(), + ], + ], $tools); + } + + /** @return array{type: string, tool_name?: string} */ + protected function formatToolChoice(string|ToolChoice $toolChoice): array + { + return is_string($toolChoice) + ? ['type' => 'tool', 'tool_name' => $toolChoice] + : ['type' => strtolower($toolChoice->name)]; + } + + /** + * @param array $array + * @return array + */ + protected function filterNulls(array $array): array + { + return array_filter($array, fn ($v): bool => $v !== null); + } +} diff --git a/src/Telemetry/SpanData.php b/src/Telemetry/SpanData.php new file mode 100644 index 000000000..386019369 --- /dev/null +++ b/src/Telemetry/SpanData.php @@ -0,0 +1,61 @@ + $attributes Driver-agnostic span attributes + * @param array}> $events Mid-span events (exceptions, annotations) + * @param \Throwable|null $exception Exception if the span failed + */ + public function __construct( + public string $spanId, + public string $traceId, + public ?string $parentSpanId, + public string $operation, + public int $startTimeNano, + public int $endTimeNano, + public array $attributes, + public array $events = [], + public ?\Throwable $exception = null, + ) {} + + /** + * Check if the span has an error. + */ + public function hasError(): bool + { + return $this->exception instanceof \Throwable; + } + + /** + * Get the duration in nanoseconds. + */ + public function durationNano(): int + { + return $this->endTimeNano - $this->startTimeNano; + } + + /** + * Get the duration in milliseconds. + */ + public function durationMs(): float + { + return $this->durationNano() / 1_000_000; + } +} diff --git a/src/Telemetry/TelemetryManager.php b/src/Telemetry/TelemetryManager.php new file mode 100644 index 000000000..65ff1b871 --- /dev/null +++ b/src/Telemetry/TelemetryManager.php @@ -0,0 +1,119 @@ + $driverConfig Optional config overrides + * + * @throws InvalidArgumentException + */ + public function resolve(string $name, array $driverConfig = []): TelemetryDriver + { + $config = array_merge($this->getConfig($name), $driverConfig); + + // Get the actual driver type from config (e.g., 'otlp', 'log', 'null') + $driverType = $config['driver'] ?? $name; + + $factory = sprintf('create%sDriver', ucfirst($driverType)); + + if (method_exists($this, $factory)) { + return $this->{$factory}($name, $config); + } + + throw new InvalidArgumentException("Telemetry driver [{$driverType}] is not supported."); + } + + /** + * @param array $config + */ + protected function createNullDriver(string $name, array $config): NullDriver + { + return new NullDriver; + } + + /** + * @param array $config + */ + protected function createLogDriver(string $name, array $config): LogDriver + { + return new LogDriver( + channel: $config['channel'] ?? 'default' + ); + } + + /** + * Generic OTLP driver - works for any OTLP-compatible backend. + * + * @param array $config + * + * @throws RuntimeException + */ + protected function createOtlpDriver(string $name, array $config): OtlpDriver + { + if (! class_exists(SpanExporter::class) || ! class_exists(TracerProvider::class)) { + throw new RuntimeException( + 'OpenTelemetry SDK required for OTLP telemetry. '. + 'Run: composer require open-telemetry/sdk open-telemetry/exporter-otlp' + ); + } + + return new OtlpDriver(driver: $name); + } + + /** + * Custom driver - allows external packages/apps to provide their own driver. + * + * Config should include a 'via' key with a class that implements __invoke($app, $config). + * + * @param array $config + * + * @throws InvalidArgumentException + */ + protected function createCustomDriver(string $name, array $config): TelemetryDriver + { + if (! isset($config['via'])) { + throw new InvalidArgumentException( + "Custom telemetry driver [{$name}] requires a 'via' configuration option." + ); + } + + $factory = $config['via']; + + if (is_string($factory)) { + $factory = $this->app->make($factory); + } + + return $factory($this->app, $config); + } + + /** + * @return array + */ + protected function getConfig(string $name): array + { + return config("prism.telemetry.drivers.{$name}", []); + } +} diff --git a/src/Telemetry/TelemetryServiceProvider.php b/src/Telemetry/TelemetryServiceProvider.php new file mode 100644 index 000000000..7100f1dcf --- /dev/null +++ b/src/Telemetry/TelemetryServiceProvider.php @@ -0,0 +1,72 @@ +app->singleton(TelemetryManager::class); + + $this->app->scoped(TelemetryDriver::class, function () { + $manager = $this->app->make(TelemetryManager::class); + $driver = config('prism.telemetry.driver', 'null'); + + return $manager->resolve($driver); + }); + + $this->app->scoped(SpanCollector::class, fn (): SpanCollector => new SpanCollector( + $this->app->make(TelemetryDriver::class) + )); + + $this->app->scoped(TelemetryEventListener::class, fn (): TelemetryEventListener => new TelemetryEventListener( + $this->app->make(SpanCollector::class) + )); + } + + public function boot(): void + { + if (config('prism.telemetry.enabled', false)) { + $this->registerEventListeners(); + } + } + + protected function registerEventListeners(): void + { + $listener = $this->app->make(TelemetryEventListener::class); + + Event::listen(TextGenerationStarted::class, $listener->handleTextGenerationStarted(...)); + Event::listen(TextGenerationCompleted::class, $listener->handleTextGenerationCompleted(...)); + Event::listen(StructuredOutputStarted::class, $listener->handleStructuredOutputStarted(...)); + Event::listen(StructuredOutputCompleted::class, $listener->handleStructuredOutputCompleted(...)); + Event::listen(EmbeddingGenerationStarted::class, $listener->handleEmbeddingGenerationStarted(...)); + Event::listen(EmbeddingGenerationCompleted::class, $listener->handleEmbeddingGenerationCompleted(...)); + Event::listen(StreamingStarted::class, $listener->handleStreamingStarted(...)); + Event::listen(StreamingCompleted::class, $listener->handleStreamingCompleted(...)); + Event::listen(HttpCallStarted::class, $listener->handleHttpCallStarted(...)); + Event::listen(HttpCallCompleted::class, $listener->handleHttpCallCompleted(...)); + Event::listen(ToolCallStarted::class, $listener->handleToolCallStarted(...)); + Event::listen(ToolCallCompleted::class, $listener->handleToolCallCompleted(...)); + Event::listen(SpanException::class, $listener->handleSpanException(...)); + } +} diff --git a/src/Text/PendingRequest.php b/src/Text/PendingRequest.php index c9807ee32..f83d0fb17 100644 --- a/src/Text/PendingRequest.php +++ b/src/Text/PendingRequest.php @@ -13,16 +13,24 @@ use Prism\Prism\Concerns\ConfiguresModels; use Prism\Prism\Concerns\ConfiguresProviders; use Prism\Prism\Concerns\ConfiguresTools; +use Prism\Prism\Concerns\EmitsTelemetry; use Prism\Prism\Concerns\HasMessages; use Prism\Prism\Concerns\HasPrompts; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasProviderTools; +use Prism\Prism\Concerns\HasTelemetryContext; use Prism\Prism\Concerns\HasTools; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Streaming\Adapters\BroadcastAdapter; use Prism\Prism\Streaming\Adapters\DataProtocolAdapter; use Prism\Prism\Streaming\Adapters\SSEAdapter; +use Prism\Prism\Streaming\Events\StreamEndEvent; use Prism\Prism\Streaming\Events\StreamEvent; +use Prism\Prism\Streaming\Events\StreamStartEvent; +use Prism\Prism\Telemetry\Events\StreamingCompleted; +use Prism\Prism\Telemetry\Events\StreamingStarted; +use Prism\Prism\Telemetry\Events\TextGenerationCompleted; +use Prism\Prism\Telemetry\Events\TextGenerationStarted; use Prism\Prism\Tool; use Prism\Prism\ValueObjects\Messages\UserMessage; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -34,10 +42,12 @@ class PendingRequest use ConfiguresModels; use ConfiguresProviders; use ConfiguresTools; + use EmitsTelemetry; use HasMessages; use HasPrompts; use HasProviderOptions; use HasProviderTools; + use HasTelemetryContext; use HasTools; /** @@ -58,13 +68,30 @@ public function asText(?callable $callback = null): Response $request = $this->toRequest(); try { - $response = $this->provider->text($request); - - if ($callback !== null) { - $callback($this, $response); - } - - return $response; + return $this->withTelemetry( + startEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId): TextGenerationStarted => new TextGenerationStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + ), + endEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId, Response $response): TextGenerationCompleted => new TextGenerationCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + response: $response, + ), + execute: function () use ($request, $callback): Response { + $response = $this->provider->text($request); + + if ($callback !== null) { + $callback($this, $response); + } + + return $response; + }, + ); } catch (RequestException $e) { $this->provider->handleRequestException($request->model(), $e); } @@ -78,7 +105,25 @@ public function asStream(): Generator $request = $this->toRequest(); try { - yield from $this->provider->stream($request); + yield from $this->withStreamingTelemetry( + startEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId, ?StreamStartEvent $streamStart): StreamingStarted => new StreamingStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + streamStart: $streamStart, + ), + endEventFactory: fn (string $spanId, string $traceId, ?string $parentSpanId, ?StreamEndEvent $streamEnd): StreamingCompleted => new StreamingCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + streamEnd: $streamEnd, + ), + execute: fn (): Generator => $this->provider->stream($request), + startEventType: StreamStartEvent::class, + endEventType: StreamEndEvent::class, + ); } catch (RequestException $e) { $this->provider->handleRequestException($request->model(), $e); } diff --git a/src/helpers.php b/src/helpers.php index 1a1b06bd2..b6cf45ba5 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -16,3 +16,14 @@ function prism(): Prism return App::make(Prism::class); } } + +if (! function_exists('now_nanos')) { + + /** + * Get the current Unix timestamp in nanoseconds. + */ + function now_nanos(): int + { + return (int) (microtime(true) * 1_000_000_000); + } +} diff --git a/tests/Telemetry/Drivers/LogDriverTest.php b/tests/Telemetry/Drivers/LogDriverTest.php new file mode 100644 index 000000000..4de077935 --- /dev/null +++ b/tests/Telemetry/Drivers/LogDriverTest.php @@ -0,0 +1,110 @@ +byDefault()->andReturnSelf(); + Log::shouldReceive('warning')->byDefault(); +}); + +it('logs span data when recordSpan is called', function (): void { + Log::shouldReceive('channel') + ->with('test-channel') + ->once() + ->andReturnSelf(); + + Log::shouldReceive('info') + ->once() + ->with('Span recorded', Mockery::on(fn ($data): bool => $data['span_id'] === 'test-span-id' + && $data['operation'] === 'text_generation' + && isset($data['duration_ms']) + && $data['has_error'] === false + && isset($data['attributes']))); + + $driver = new LogDriver('test-channel'); + $spanData = createLogSpanData(); + + $driver->recordSpan($spanData); +}); + +it('logs error when span has exception', function (): void { + Log::shouldReceive('channel') + ->with('test-channel') + ->once() + ->andReturnSelf(); + + Log::shouldReceive('info') + ->once() + ->with('Span recorded', Mockery::on(fn ($data): bool => $data['span_id'] === 'test-span-id' + && $data['has_error'] === true + && isset($data['exception']) + && $data['exception']['class'] === Exception::class + && $data['exception']['message'] === 'Test exception')); + + $driver = new LogDriver('test-channel'); + $exception = new Exception('Test exception'); + $spanData = createLogSpanData(exception: $exception); + + $driver->recordSpan($spanData); +}); + +it('handles different channel configurations', function (): void { + Log::shouldReceive('channel') + ->with('default') + ->once() + ->andReturnSelf(); + + Log::shouldReceive('info') + ->once(); + + $defaultDriver = new LogDriver; + $defaultDriver->recordSpan(createLogSpanData()); +}); + +it('logs generic attributes directly from span data', function (): void { + Log::shouldReceive('channel') + ->with('test-channel') + ->once() + ->andReturnSelf(); + + Log::shouldReceive('info') + ->once() + ->with('Span recorded', Mockery::on( + fn ($data): bool => $data['attributes']['model'] === 'gpt-4' + && $data['attributes']['provider'] === 'openai' + && $data['attributes']['usage']['prompt_tokens'] === 10)); + + $driver = new LogDriver('test-channel'); + $spanData = createLogSpanData(); + + $driver->recordSpan($spanData); +}); + +function createLogSpanData(?\Throwable $exception = null): SpanData +{ + return new SpanData( + spanId: 'test-span-id', + traceId: bin2hex(random_bytes(16)), + parentSpanId: null, + operation: 'text_generation', + startTimeNano: (int) (microtime(true) * 1_000_000_000), + endTimeNano: (int) (microtime(true) * 1_000_000_000) + 100_000_000, + attributes: [ + 'model' => 'gpt-4', + 'provider' => 'openai', + 'temperature' => 0.7, + 'output' => 'Hello there!', + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 5, + ], + ], + events: [], + exception: $exception, + ); +} diff --git a/tests/Telemetry/Drivers/NullDriverTest.php b/tests/Telemetry/Drivers/NullDriverTest.php new file mode 100644 index 000000000..1d0625167 --- /dev/null +++ b/tests/Telemetry/Drivers/NullDriverTest.php @@ -0,0 +1,67 @@ +recordSpan($spanData); + + expect(true)->toBeTrue(); +}); + +it('handles spans with exceptions gracefully', function (): void { + $driver = new NullDriver; + $spanData = createNullDriverSpanData(new Exception('Test exception')); + + $driver->recordSpan($spanData); + + expect(true)->toBeTrue(); +}); + +it('handles spans with events gracefully', function (): void { + $driver = new NullDriver; + + $spanData = new SpanData( + spanId: 'test-span-id', + traceId: bin2hex(random_bytes(16)), + parentSpanId: null, + operation: 'text_generation', + startTimeNano: (int) (microtime(true) * 1_000_000_000), + endTimeNano: (int) (microtime(true) * 1_000_000_000) + 100_000_000, + attributes: ['model' => 'gpt-4'], + events: [ + ['name' => 'event1', 'timeNanos' => 1000, 'attributes' => []], + ['name' => 'event2', 'timeNanos' => 2000, 'attributes' => ['key' => 'value']], + ], + ); + + $driver->recordSpan($spanData); + + expect(true)->toBeTrue(); +}); + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function createNullDriverSpanData(?\Throwable $exception = null): SpanData +{ + return new SpanData( + spanId: 'test-span-id', + traceId: bin2hex(random_bytes(16)), + parentSpanId: null, + operation: 'text_generation', + startTimeNano: (int) (microtime(true) * 1_000_000_000), + endTimeNano: (int) (microtime(true) * 1_000_000_000) + 100_000_000, + attributes: [ + 'model' => 'gpt-4', + ], + events: [], + exception: $exception, + ); +} diff --git a/tests/Telemetry/Drivers/OtlpDriverTest.php b/tests/Telemetry/Drivers/OtlpDriverTest.php new file mode 100644 index 000000000..c4e1d5252 --- /dev/null +++ b/tests/Telemetry/Drivers/OtlpDriverTest.php @@ -0,0 +1,226 @@ +recordSpan(new SpanData( + spanId: $spanId, + traceId: $traceId, + parentSpanId: null, + operation: 'test_operation', + startTimeNano: hrtime(true), + endTimeNano: hrtime(true) + 1_000_000, + attributes: [], + events: [], + )); + + $driver->shutdown(); + + expect($exportedSpans)->toHaveCount(1) + ->and($exportedSpans[0]->getSpanId())->toBe($spanId) + ->and($exportedSpans[0]->getTraceId())->toBe($traceId); + }); + + it('preserves parent span ID in context', function (): void { + $spanId = bin2hex(random_bytes(8)); + $traceId = bin2hex(random_bytes(16)); + $parentSpanId = bin2hex(random_bytes(8)); + + $exportedSpans = []; + $driver = createTestableOtlpDriver(function ($spans) use (&$exportedSpans): void { + $exportedSpans = array_merge($exportedSpans, iterator_to_array($spans)); + }); + + $driver->recordSpan(new SpanData( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + operation: 'child_operation', + startTimeNano: hrtime(true), + endTimeNano: hrtime(true) + 1_000_000, + attributes: [], + events: [], + )); + + $driver->shutdown(); + + expect($exportedSpans[0]->getParentSpanId())->toBe($parentSpanId); + }); +}); + +describe('SDK Batching', function (): void { + it('batches multiple spans via SDK BatchSpanProcessor', function (): void { + $exportCount = 0; + $totalSpans = 0; + + $driver = createTestableOtlpDriver(function ($spans) use (&$exportCount, &$totalSpans): void { + $exportCount++; + $totalSpans += count(iterator_to_array($spans)); + }); + + $driver->recordSpan(createOtlpTestSpanData()); + $driver->recordSpan(createOtlpTestSpanData()); + $driver->recordSpan(createOtlpTestSpanData()); + + $driver->shutdown(); + + // SDK batches spans - typically one export call + expect($totalSpans)->toBe(3); + }); + + it('flushes all spans on shutdown', function (): void { + $exportCount = 0; + + $driver = createTestableOtlpDriver(function () use (&$exportCount): void { + $exportCount++; + }); + + $driver->recordSpan(createOtlpTestSpanData()); + $driver->recordSpan(createOtlpTestSpanData()); + + $driver->shutdown(); + + // All spans should be exported + expect($exportCount)->toBe(2); + + // Second shutdown should not export anything + $driver->shutdown(); + expect($exportCount)->toBe(2); + }); +}); + +describe('Error Handling', function (): void { + it('sets error status for failed spans', function (): void { + $exportedSpans = []; + $driver = createTestableOtlpDriver(function ($spans) use (&$exportedSpans): void { + $exportedSpans = array_merge($exportedSpans, iterator_to_array($spans)); + }); + + $driver->recordSpan(new SpanData( + spanId: bin2hex(random_bytes(8)), + traceId: bin2hex(random_bytes(16)), + parentSpanId: null, + operation: 'failed_operation', + startTimeNano: hrtime(true), + endTimeNano: hrtime(true) + 1_000_000, + attributes: [], + events: [], + exception: new RuntimeException('Something failed'), + )); + + $driver->shutdown(); + + expect($exportedSpans[0]->getStatus()->getCode())->toBe('Error') + ->and($exportedSpans[0]->getStatus()->getDescription())->toBe('Something failed'); + }); +}); + +describe('Driver Identity', function (): void { + it('returns configured driver name', function (): void { + expect((new OtlpDriver('phoenix'))->getDriver())->toBe('phoenix'); + }); + + it('defaults to otlp', function (): void { + expect((new OtlpDriver)->getDriver())->toBe('otlp'); + }); +}); + +describe('Configuration', function (): void { + it('applies tags from config', function (): void { + config(['prism.telemetry.drivers.tagged' => [ + 'tags' => ['env' => 'testing'], + 'mapper' => \Prism\Prism\Telemetry\Semantics\OpenInferenceMapper::class, + ]]); + + $exportedSpans = []; + $driver = createTestableOtlpDriver(function ($spans) use (&$exportedSpans): void { + $exportedSpans = array_merge($exportedSpans, iterator_to_array($spans)); + }, 'tagged'); + + $driver->recordSpan(createOtlpTestSpanData()); + $driver->shutdown(); + + $tagsTags = $exportedSpans[0]->getAttributes()->get('tag.tags'); + expect($tagsTags)->toContain('env:testing'); + }); +}); + +describe('PrimedIdGenerator Integration', function (): void { + it('uses PrimedIdGenerator for ID generation', function (): void { + $driver = new OtlpDriver('otlp'); + + $ref = new ReflectionMethod($driver, 'idGenerator'); + $generator = $ref->invoke($driver); + + expect($generator)->toBeInstanceOf(PrimedIdGenerator::class); + }); +}); + +function createTestableOtlpDriver(callable $onExport, string $driverName = 'otlp'): OtlpDriver +{ + $driver = new OtlpDriver($driverName); + + // Create mock exporter + $mockExporter = Mockery::mock(SpanExporterInterface::class); + $mockExporter->shouldReceive('export') + ->andReturnUsing(function ($spans) use ($onExport) { + $onExport($spans); + + $future = Mockery::mock(\OpenTelemetry\SDK\Common\Future\FutureInterface::class); + $future->shouldReceive('await')->andReturn(true); + + return $future; + }); + $mockExporter->shouldReceive('shutdown')->andReturn(true); + + // Create TracerProvider with mock exporter but real PrimedIdGenerator + $idGenerator = new PrimedIdGenerator; + $provider = new \OpenTelemetry\SDK\Trace\TracerProvider( + spanProcessors: [new \OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor($mockExporter)], + sampler: new \OpenTelemetry\SDK\Trace\Sampler\AlwaysOnSampler, + resource: \OpenTelemetry\SDK\Resource\ResourceInfo::create( + \OpenTelemetry\SDK\Common\Attribute\Attributes::create(['service.name' => 'test']) + ), + idGenerator: $idGenerator, + ); + + // Inject via reflection + $providerRef = new ReflectionProperty($driver, 'provider'); + $providerRef->setValue($driver, $provider); + + $idGenRef = new ReflectionProperty($driver, 'idGenerator'); + $idGenRef->setValue($driver, $idGenerator); + + return $driver; +} + +function createOtlpTestSpanData(): SpanData +{ + return new SpanData( + spanId: bin2hex(random_bytes(8)), + traceId: bin2hex(random_bytes(16)), + parentSpanId: null, + operation: 'text_generation', + startTimeNano: hrtime(true), + endTimeNano: hrtime(true) + 1_000_000, + attributes: [ + 'model' => 'gpt-4', + 'provider' => 'openai', + ], + events: [], + ); +} diff --git a/tests/Telemetry/EmbeddingTelemetryTest.php b/tests/Telemetry/EmbeddingTelemetryTest.php new file mode 100644 index 000000000..5e1809c39 --- /dev/null +++ b/tests/Telemetry/EmbeddingTelemetryTest.php @@ -0,0 +1,89 @@ + true]); + Event::fake(); + + $mockResponse = new Response( + embeddings: [ + new Embedding([1.0, 2.0, 3.0]), + ], + usage: new \Prism\Prism\ValueObjects\EmbeddingsUsage(10), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model') + ); + + Prism::fake([$mockResponse]); + + $response = Prism::embeddings() + ->using('openai', 'text-embedding-ada-002') + ->fromInput('Test input') + ->asEmbeddings(); + + Event::assertDispatched(EmbeddingGenerationStarted::class); + Event::assertDispatched(EmbeddingGenerationCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('does not emit telemetry events when disabled', function (): void { + config(['prism.telemetry.enabled' => false]); + Event::fake(); + + $mockResponse = new Response( + embeddings: [ + new Embedding([1.0, 2.0, 3.0]), + ], + usage: new \Prism\Prism\ValueObjects\EmbeddingsUsage(10), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model') + ); + + Prism::fake([$mockResponse]); + + $response = Prism::embeddings() + ->using('openai', 'text-embedding-ada-002') + ->fromInput('Test input') + ->asEmbeddings(); + + Event::assertNotDispatched(EmbeddingGenerationStarted::class); + Event::assertNotDispatched(EmbeddingGenerationCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('includes traceId and parentSpanId in embedding telemetry events', function (): void { + config(['prism.telemetry.enabled' => true]); + Event::fake(); + + $mockResponse = new Response( + embeddings: [ + new Embedding([1.0, 2.0, 3.0]), + ], + usage: new \Prism\Prism\ValueObjects\EmbeddingsUsage(10), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model') + ); + + Prism::fake([$mockResponse]); + + Prism::embeddings() + ->using('openai', 'text-embedding-ada-002') + ->fromInput('Test input') + ->asEmbeddings(); + + Event::assertDispatched(EmbeddingGenerationStarted::class, fn ($event): bool => ! empty($event->spanId) + && ! empty($event->traceId) + && $event->request !== null); + + Event::assertDispatched(EmbeddingGenerationCompleted::class, fn ($event): bool => ! empty($event->spanId) + && ! empty($event->traceId) + && $event->request !== null + && $event->response !== null); +}); diff --git a/tests/Telemetry/HttpCallTelemetryTest.php b/tests/Telemetry/HttpCallTelemetryTest.php new file mode 100644 index 000000000..bf346058e --- /dev/null +++ b/tests/Telemetry/HttpCallTelemetryTest.php @@ -0,0 +1,112 @@ +telemetryMiddleware(); + } +} + +beforeEach(function (): void { + config(['prism.telemetry.enabled' => true]); + Event::fake(); + Context::forgetHidden('prism.telemetry.trace_id'); + Context::forgetHidden('prism.telemetry.current_span_id'); +}); + +describe('successful requests', function (): void { + it('dispatches HttpCallStarted and HttpCallCompleted events', function (): void { + $promise = executeMiddleware(new FulfilledPromise(new Response(200, [], 'OK'))); + $promise->wait(); + + Event::assertDispatched(HttpCallStarted::class, fn ($e): bool => $e->method === 'POST' + && $e->url === 'https://api.example.com/v1/test' + && ! empty($e->spanId) + && ! empty($e->traceId)); + + Event::assertDispatched(HttpCallCompleted::class, fn ($e): bool => $e->method === 'POST' + && $e->url === 'https://api.example.com/v1/test' + && $e->statusCode === 200); + }); + + it('resets context span id to parent after completion', function (): void { + $parentSpanId = 'parent-span-123'; + Context::addHidden('prism.telemetry.trace_id', 'trace-123'); + Context::addHidden('prism.telemetry.current_span_id', $parentSpanId); + + $promise = executeMiddleware(new FulfilledPromise(new Response(200))); + $promise->wait(); + + expect(Context::getHidden('prism.telemetry.current_span_id'))->toBe($parentSpanId); + }); +}); + +describe('failed requests', function (): void { + it('dispatches HttpCallStarted but not HttpCallCompleted on failure', function (): void { + $promise = executeMiddleware(new RejectedPromise(new Exception('Connection timeout'))); + + try { + $promise->wait(); + } catch (Exception) { + // Expected + } + + Event::assertDispatched(HttpCallStarted::class); + Event::assertNotDispatched(HttpCallCompleted::class); + }); + + it('resets context span id to parent even on failure', function (): void { + $parentSpanId = 'parent-span-456'; + Context::addHidden('prism.telemetry.trace_id', 'trace-456'); + Context::addHidden('prism.telemetry.current_span_id', $parentSpanId); + + $promise = executeMiddleware(new RejectedPromise(new Exception('Connection failed'))); + + try { + $promise->wait(); + } catch (Exception) { + // Expected + } + + expect(Context::getHidden('prism.telemetry.current_span_id'))->toBe($parentSpanId); + }); +}); + +describe('trace context', function (): void { + it('preserves existing trace id across http calls', function (): void { + $existingTraceId = 'existing-trace-id-789'; + Context::addHidden('prism.telemetry.trace_id', $existingTraceId); + + $promise = executeMiddleware(new FulfilledPromise(new Response(200))); + $promise->wait(); + + Event::assertDispatched(HttpCallStarted::class, fn ($e): bool => $e->traceId === $existingTraceId); + Event::assertDispatched(HttpCallCompleted::class, fn ($e): bool => $e->traceId === $existingTraceId); + }); +}); + +function executeMiddleware($handlerResult, string $method = 'POST', string $url = 'https://api.example.com/v1/test') +{ + $testClass = new TelemetryMiddlewareTestClass; + $middleware = $testClass->getMiddleware(); + + $mockHandler = fn () => $handlerResult; + $wrappedHandler = $middleware($mockHandler); + + return $wrappedHandler(new Request($method, $url), []); +} diff --git a/tests/Telemetry/Otel/PrimedIdGeneratorTest.php b/tests/Telemetry/Otel/PrimedIdGeneratorTest.php new file mode 100644 index 000000000..391f047d4 --- /dev/null +++ b/tests/Telemetry/Otel/PrimedIdGeneratorTest.php @@ -0,0 +1,65 @@ +primeSpanId($spanId); + + expect($generator->generateSpanId())->toBe($spanId); + }); + + it('returns primed trace ID', function (): void { + $generator = new PrimedIdGenerator; + $traceId = bin2hex(random_bytes(16)); + + $generator->primeTraceId($traceId); + + expect($generator->generateTraceId())->toBe($traceId); + }); + + it('returns multiple primed IDs in FIFO order', function (): void { + $generator = new PrimedIdGenerator; + $spanId1 = bin2hex(random_bytes(8)); + $spanId2 = bin2hex(random_bytes(8)); + $spanId3 = bin2hex(random_bytes(8)); + + $generator->primeSpanId($spanId1); + $generator->primeSpanId($spanId2); + $generator->primeSpanId($spanId3); + + expect($generator->generateSpanId())->toBe($spanId1) + ->and($generator->generateSpanId())->toBe($spanId2) + ->and($generator->generateSpanId())->toBe($spanId3); + }); + + it('throws when span ID not primed', function (): void { + $generator = new PrimedIdGenerator; + + expect(fn (): string => $generator->generateSpanId()) + ->toThrow(RuntimeException::class, 'spanId not primed'); + }); + + it('throws when trace ID not primed', function (): void { + $generator = new PrimedIdGenerator; + + expect(fn (): string => $generator->generateTraceId()) + ->toThrow(RuntimeException::class, 'traceId not primed'); + }); + + it('throws after primed IDs exhausted', function (): void { + $generator = new PrimedIdGenerator; + $spanId = bin2hex(random_bytes(8)); + + $generator->primeSpanId($spanId); + $generator->generateSpanId(); // Consumes the primed ID + + expect(fn (): string => $generator->generateSpanId()) + ->toThrow(RuntimeException::class); + }); +}); diff --git a/tests/Telemetry/Semantics/OpenInferenceMapperTest.php b/tests/Telemetry/Semantics/OpenInferenceMapperTest.php new file mode 100644 index 000000000..5a3e8285b --- /dev/null +++ b/tests/Telemetry/Semantics/OpenInferenceMapperTest.php @@ -0,0 +1,303 @@ +map('text_generation', [ + 'model' => 'gpt-4', + 'provider' => 'openai', + ]); + + expect($attrs['openinference.span.kind'])->toBe('LLM'); + expect($attrs['llm.model_name'])->toBe('gpt-4'); + expect($attrs['llm.provider'])->toBe('openai'); +}); + +it('maps streaming span to LLM kind', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('streaming', [ + 'model' => 'gpt-4-turbo', + ]); + + expect($attrs['openinference.span.kind'])->toBe('LLM'); + expect($attrs['llm.model_name'])->toBe('gpt-4-turbo'); +}); + +it('maps tool_call span to TOOL kind', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('tool_call', [ + 'tool' => [ + 'name' => 'search', + 'call_id' => 'call_123', + 'arguments' => ['query' => 'test'], + 'description' => 'Search the web', + 'parameters' => ['type' => 'object'], + ], + 'output' => 'Search results', + ]); + + expect($attrs['openinference.span.kind'])->toBe('TOOL'); + expect($attrs['tool.name'])->toBe('search'); + expect($attrs['tool.call.id'])->toBe('call_123'); + expect($attrs['tool.output'])->toBe('Search results'); + expect($attrs['tool.description'])->toBe('Search the web'); +}); + +it('maps embedding_generation span to EMBEDDING kind', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('embedding_generation', [ + 'model' => 'text-embedding-ada-002', + 'inputs' => ['Hello world', 'Test input'], + 'usage' => ['tokens' => 10], + ]); + + expect($attrs['openinference.span.kind'])->toBe('EMBEDDING'); + expect($attrs['embedding.model_name'])->toBe('text-embedding-ada-002'); + expect($attrs['embedding.embeddings.0.embedding.text'])->toBe('Hello world'); + expect($attrs['embedding.embeddings.1.embedding.text'])->toBe('Test input'); +}); + +it('maps structured_output span to CHAIN kind', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('structured_output', [ + 'model' => 'gpt-4', + 'schema' => [ + 'name' => 'UserProfile', + 'definition' => ['type' => 'object'], + ], + ]); + + expect($attrs['openinference.span.kind'])->toBe('CHAIN'); + expect($attrs['output.schema.name'])->toBe('UserProfile'); +}); + +it('maps http_call span correctly', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('http_call', [ + 'http' => [ + 'method' => 'POST', + 'url' => 'https://api.openai.com/v1/chat/completions', + 'status_code' => 200, + ], + ]); + + expect($attrs['openinference.span.kind'])->toBe('CHAIN'); + expect($attrs['http.method'])->toBe('POST'); + expect($attrs['http.url'])->toBe('https://api.openai.com/v1/chat/completions'); + expect($attrs['http.status_code'])->toBe(200); +}); + +it('converts token usage correctly', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 50, + ], + ]); + + expect($attrs['llm.token_count.prompt'])->toBe(100); + expect($attrs['llm.token_count.completion'])->toBe(50); + expect($attrs['llm.token_count.total'])->toBe(150); +}); + +it('converts invocation parameters to JSON', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'temperature' => 0.7, + 'max_tokens' => 100, + 'top_p' => 0.9, + ]); + + expect($attrs)->toHaveKey('llm.invocation_parameters'); + + $params = json_decode((string) $attrs['llm.invocation_parameters'], true); + expect($params['temperature'])->toBe(0.7); + expect($params['max_tokens'])->toBe(100); + expect($params['top_p'])->toBe(0.9); +}); + +it('extracts system prompt from messages', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'messages' => [ + ['role' => 'system', 'content' => 'You are a helpful assistant.'], + ['role' => 'user', 'content' => 'Hello'], + ], + ]); + + expect($attrs['llm.system'])->toBe('You are a helpful assistant.'); +}); + +it('formats input messages correctly', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'messages' => [ + ['role' => 'user', 'content' => 'Hello'], + ['role' => 'assistant', 'content' => 'Hi there!'], + ], + ]); + + expect($attrs['llm.input_messages.0.message.role'])->toBe('user'); + expect($attrs['llm.input_messages.0.message.content'])->toBe('Hello'); + expect($attrs['llm.input_messages.1.message.role'])->toBe('assistant'); + expect($attrs['llm.input_messages.1.message.content'])->toBe('Hi there!'); +}); + +it('formats output messages correctly', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'output' => 'Hello there!', + 'tool_calls' => [ + ['id' => 'call_1', 'name' => 'search', 'arguments' => ['q' => 'test']], + ], + ]); + + expect($attrs['llm.output_messages.0.message.role'])->toBe('assistant'); + expect($attrs['llm.output_messages.0.message.content'])->toBe('Hello there!'); + expect($attrs['llm.output_messages.0.message.tool_calls.0.tool_call.id'])->toBe('call_1'); +}); + +it('maps user and session attributes to OpenInference format', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'metadata' => [ + 'user_id' => 'user_123', + 'session_id' => 'session_456', + ], + ]); + + // OpenInference uses user.id and session.id for user tracking + expect($attrs['user.id'])->toBe('user_123'); + expect($attrs['session.id'])->toBe('session_456'); +}); + +it('maps agent to agent.name per OpenInference spec', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'metadata' => [ + 'agent' => 'support-bot', + ], + ]); + + // Agent is a reserved OpenInference attribute + // @see https://arize-ai.github.io/openinference/spec/semantic_conventions.html + expect($attrs['agent.name'])->toBe('support-bot'); + expect($attrs)->not->toHaveKey('tag.agent'); // Not a spec attribute +}); + +it('formats general metadata with prefix', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'metadata' => [ + 'custom_key' => 'custom_value', + 'user_email' => 'test@example.com', + ], + ]); + + // General metadata goes under metadata.* prefix + expect($attrs['metadata.custom_key'])->toBe('custom_value'); + expect($attrs['metadata.user_email'])->toBe('test@example.com'); +}); + +it('maps tags to OpenInference tag.tags list format', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'metadata' => [ + 'tags' => [ + 'environment' => 'production', + 'app' => 'my-app', + ], + ], + ]); + + // Per OpenInference spec, tag.tags is a list of strings + // Key-value pairs are converted to "key:value" format + // @see https://arize-ai.github.io/openinference/spec/semantic_conventions.html + expect($attrs['tag.tags'])->toBe('["environment:production","app:my-app"]'); +}); + +it('supports simple string tags in tag.tags list', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + 'metadata' => [ + 'tags' => ['shopping', 'travel'], + ], + ]); + + // Simple string tags (integer keys) are passed through + expect($attrs['tag.tags'])->toBe('["shopping","travel"]'); +}); + +it('maps exception events correctly', function (): void { + $mapper = new OpenInferenceMapper; + + $events = [ + [ + 'name' => 'exception', + 'timeNanos' => 1000000000, + 'attributes' => [ + 'type' => 'RuntimeException', + 'message' => 'Test error', + ], + ], + ]; + + $mappedEvents = $mapper->mapEvents($events); + + expect($mappedEvents[0]['attributes']['exception.type'])->toBe('RuntimeException'); + expect($mappedEvents[0]['attributes']['exception.message'])->toBe('Test error'); + expect($mappedEvents[0]['attributes']['exception.escaped'])->toBeTrue(); +}); + +it('filters null values from attributes', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('text_generation', [ + 'model' => 'gpt-4', + // No usage provided - should not appear in output + ]); + + expect($attrs)->not->toHaveKey('llm.token_count.prompt'); + expect($attrs)->not->toHaveKey('llm.token_count.completion'); + expect($attrs)->not->toHaveKey('llm.token_count.total'); +}); + +it('maps unknown operation to CHAIN kind', function (): void { + $mapper = new OpenInferenceMapper; + + $attrs = $mapper->map('unknown_operation', [ + 'model' => 'gpt-4', + ]); + + expect($attrs['openinference.span.kind'])->toBe('CHAIN'); +}); diff --git a/tests/Telemetry/Semantics/PassthroughMapperTest.php b/tests/Telemetry/Semantics/PassthroughMapperTest.php new file mode 100644 index 000000000..7951da46a --- /dev/null +++ b/tests/Telemetry/Semantics/PassthroughMapperTest.php @@ -0,0 +1,59 @@ +toBeInstanceOf(SemanticMapperInterface::class); +}); + +it('returns attributes unchanged', function (): void { + $mapper = new PassthroughMapper; + + $attributes = [ + 'model' => 'gpt-4', + 'provider' => 'openai', + 'temperature' => 0.7, + 'custom_field' => 'custom_value', + ]; + + $result = $mapper->map('text_generation', $attributes); + + expect($result)->toBe($attributes); +}); + +it('returns events unchanged', function (): void { + $mapper = new PassthroughMapper; + + $events = [ + [ + 'name' => 'test_event', + 'timeNanos' => 1000000000, + 'attributes' => ['key' => 'value'], + ], + ]; + + $result = $mapper->mapEvents($events); + + expect($result)->toBe($events); +}); + +it('handles empty attributes', function (): void { + $mapper = new PassthroughMapper; + + $result = $mapper->map('text_generation', []); + + expect($result)->toBe([]); +}); + +it('handles empty events', function (): void { + $mapper = new PassthroughMapper; + + $result = $mapper->mapEvents([]); + + expect($result)->toBe([]); +}); diff --git a/tests/Telemetry/SpanCollectorTest.php b/tests/Telemetry/SpanCollectorTest.php new file mode 100644 index 000000000..e4381b420 --- /dev/null +++ b/tests/Telemetry/SpanCollectorTest.php @@ -0,0 +1,293 @@ +shouldReceive('recordSpan')->never(); + + $collector = new SpanCollector($driver); + $spanId = $collector->startSpan(createCollectorMockStartEvent('my-span-123')); + + expect($spanId)->toBe('my-span-123'); + }); + + it('sends span data to driver when span ends', function (): void { + $capturedSpan = captureSpan(); + + expect($capturedSpan)->toBeInstanceOf(SpanData::class) + ->and($capturedSpan->spanId)->toBe('test-span-id') + ->and($capturedSpan->operation)->toBe('text_generation') + ->and($capturedSpan->attributes)->toBeArray() + ->and($capturedSpan->hasError())->toBeFalse(); + }); + + it('calculates span duration correctly', function (): void { + $capturedSpan = null; + $driver = mockDriver($capturedSpan); + $collector = new SpanCollector($driver); + + $collector->startSpan(createCollectorMockStartEvent('test-span-id')); + usleep(1000); // 1ms delay + $collector->endSpan(createCollectorMockEndEvent('test-span-id')); + + expect($capturedSpan->durationNano())->toBeGreaterThan(0) + ->and($capturedSpan->startTimeNano)->toBeLessThan($capturedSpan->endTimeNano); + }); +}); + +describe('attribute extraction', function (): void { + it('extracts model attributes from start event', function (): void { + $capturedSpan = captureSpan(); + + expect($capturedSpan->attributes['model'])->toBe('gpt-4') + ->and($capturedSpan->attributes['provider'])->toBe('openai') + ->and($capturedSpan->attributes['temperature'])->toBe(0.7) + ->and($capturedSpan->attributes['max_tokens'])->toBe(100); + }); + + it('extracts token usage from end event', function (): void { + $capturedSpan = captureSpan(); + + expect($capturedSpan->attributes['usage'])->toBeArray() + ->and($capturedSpan->attributes['usage']['prompt_tokens'])->toBe(10) + ->and($capturedSpan->attributes['usage']['completion_tokens'])->toBe(5); + }); + + it('extracts output and messages from end event', function (): void { + $capturedSpan = captureSpan(); + + expect($capturedSpan->attributes['output'])->toBe('Hello there!') + ->and($capturedSpan->attributes['messages'])->toBeArray(); + }); + + it('includes operation in span data', function (): void { + $capturedSpan = captureSpan(); + + expect($capturedSpan->operation)->toBe('text_generation'); + }); +}); + +describe('trace context', function (): void { + it('uses trace id from event', function (): void { + $capturedSpan = captureSpan('test-span-id', 'custom-trace-id-from-event'); + + expect($capturedSpan->traceId)->toBe('custom-trace-id-from-event'); + }); + + it('uses parent span id from event', function (): void { + $capturedSpan = captureSpan('child-span', 'trace-123', 'parent-span-123'); + + expect($capturedSpan->parentSpanId)->toBe('parent-span-123'); + }); + + it('adds context metadata from Laravel hidden Context', function (): void { + Context::addHidden('prism.telemetry.metadata', ['user_id' => '123', 'session' => 'abc']); + + $capturedSpan = captureSpan('test-span-id', 'trace-id'); + + expect($capturedSpan->attributes['metadata']['user_id'])->toBe('123') + ->and($capturedSpan->attributes['metadata']['session'])->toBe('abc'); + + Context::forgetHidden('prism.telemetry.metadata'); + }); +}); + +describe('span events', function (): void { + it('adds events to pending spans', function (): void { + $capturedSpan = null; + $driver = mockDriver($capturedSpan); + $collector = new SpanCollector($driver); + + $collector->startSpan(createCollectorMockStartEvent('test-span-id')); + $collector->addEvent('test-span-id', 'token_generated', now_nanos(), ['token_count' => 10]); + $collector->addEvent('test-span-id', 'chunk_received', now_nanos()); + $collector->endSpan(createCollectorMockEndEvent('test-span-id')); + + expect($capturedSpan->events)->toHaveCount(2) + ->and($capturedSpan->events[0]['name'])->toBe('token_generated') + ->and($capturedSpan->events[0]['attributes'])->toBe(['token_count' => 10]) + ->and($capturedSpan->events[1]['name'])->toBe('chunk_received'); + }); + + it('ignores events for unknown span id', function (): void { + $driver = Mockery::mock(TelemetryDriver::class)->shouldIgnoreMissing(); + $collector = new SpanCollector($driver); + + $collector->addEvent('unknown-span-id', 'event', now_nanos(), ['key' => 'value']); + + expect(true)->toBeTrue(); + }); +}); + +describe('exception handling', function (): void { + it('records exceptions on spans', function (): void { + $capturedSpan = null; + $driver = mockDriver($capturedSpan); + $collector = new SpanCollector($driver); + $exception = new Exception('Test error'); + + $collector->startSpan(createCollectorMockStartEvent('test-span-id')); + $collector->recordException(new SpanException('test-span-id', $exception)); + $collector->endSpan(createCollectorMockEndEvent('test-span-id')); + + expect($capturedSpan->hasError())->toBeTrue() + ->and($capturedSpan->exception)->toBe($exception) + ->and($capturedSpan->events)->toHaveCount(1) + ->and($capturedSpan->events[0]['name'])->toBe('exception') + ->and($capturedSpan->events[0]['attributes']['type'])->toBe(Exception::class); + }); + + it('ignores exceptions for unknown span id', function (): void { + $driver = Mockery::mock(TelemetryDriver::class)->shouldIgnoreMissing(); + $collector = new SpanCollector($driver); + + $collector->recordException(new SpanException('unknown-span-id', new Exception('Test error'))); + + expect(true)->toBeTrue(); + }); +}); + +describe('edge cases', function (): void { + it('does not dispatch for unknown span id', function (): void { + $driver = Mockery::mock(TelemetryDriver::class); + $driver->shouldReceive('recordSpan')->never(); + + $collector = new SpanCollector($driver); + $collector->endSpan(createCollectorMockEndEvent('unknown-span-id')); + + expect(true)->toBeTrue(); + }); + + it('tracks multiple spans with different ids', function (): void { + $capturedSpans = []; + $driver = Mockery::mock(TelemetryDriver::class); + $driver->shouldReceive('recordSpan') + ->twice() + ->with(Mockery::on(function ($span) use (&$capturedSpans): bool { + $capturedSpans[] = $span; + + return $span instanceof SpanData; + })); + + $collector = new SpanCollector($driver); + $traceId = 'shared-trace-id'; + + $collector->startSpan(createCollectorMockStartEvent('span-1', $traceId)); + $collector->startSpan(createCollectorMockStartEvent('span-2', $traceId)); + $collector->endSpan(createCollectorMockEndEvent('span-1', $traceId)); + $collector->endSpan(createCollectorMockEndEvent('span-2', $traceId)); + + expect($capturedSpans)->toHaveCount(2) + ->and($capturedSpans[0]->spanId)->toBe('span-1') + ->and($capturedSpans[1]->spanId)->toBe('span-2'); + }); +}); + +function mockDriver(?SpanData &$capturedSpan): TelemetryDriver +{ + $driver = Mockery::mock(TelemetryDriver::class); + $driver->shouldReceive('recordSpan') + ->once() + ->with(Mockery::on(function ($span) use (&$capturedSpan): bool { + $capturedSpan = $span; + + return $span instanceof SpanData; + })); + + return $driver; +} + +function captureSpan( + string $spanId = 'test-span-id', + string $traceId = 'test-trace-id', + ?string $parentSpanId = null +): SpanData { + $capturedSpan = null; + $driver = mockDriver($capturedSpan); + $collector = new SpanCollector($driver); + + $collector->startSpan(createCollectorMockStartEvent($spanId, $traceId, $parentSpanId)); + $collector->endSpan(createCollectorMockEndEvent($spanId, $traceId, $parentSpanId)); + + return $capturedSpan; +} + +function createCollectorMockStartEvent( + string $spanId = 'test-span-id', + string $traceId = 'test-trace-id', + ?string $parentSpanId = null +): TextGenerationStarted { + return new TextGenerationStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: new Request( + model: 'gpt-4', + providerKey: 'openai', + systemPrompts: [], + prompt: 'Hello', + messages: [], + maxSteps: 1, + maxTokens: 100, + temperature: 0.7, + topP: 1.0, + tools: [], + clientOptions: [], + clientRetry: [3], + toolChoice: null, + ), + ); +} + +function createCollectorMockEndEvent( + string $spanId = 'test-span-id', + string $traceId = 'test-trace-id', + ?string $parentSpanId = null +): TextGenerationCompleted { + return new TextGenerationCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: new Request( + model: 'gpt-4', + providerKey: 'openai', + systemPrompts: [], + prompt: 'Hello', + messages: [], + maxSteps: 1, + maxTokens: 100, + temperature: 0.7, + topP: 1.0, + tools: [], + clientOptions: [], + clientRetry: [3], + toolChoice: null, + ), + response: new Response( + steps: new Collection, + text: 'Hello there!', + finishReason: FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new Usage(10, 5), + meta: new Meta(id: 'resp-123', model: 'gpt-4'), + messages: new Collection, + ), + ); +} diff --git a/tests/Telemetry/SpanDataTest.php b/tests/Telemetry/SpanDataTest.php new file mode 100644 index 000000000..cc8dc28a7 --- /dev/null +++ b/tests/Telemetry/SpanDataTest.php @@ -0,0 +1,185 @@ + 'gpt-4'], + events: [['name' => 'event1', 'timeNanos' => 1500000000, 'attributes' => []]], + ); + + expect($spanData->spanId)->toBe('span-123') + ->and($spanData->traceId)->toBe('trace-456') + ->and($spanData->parentSpanId)->toBe('parent-789') + ->and($spanData->operation)->toBe('text_generation') + ->and($spanData->startTimeNano)->toBe(1000000000) + ->and($spanData->endTimeNano)->toBe(2000000000) + ->and($spanData->attributes)->toBe(['model' => 'gpt-4']) + ->and($spanData->events)->toHaveCount(1) + ->and($spanData->exception)->toBeNull(); +}); + +it('uses sensible defaults for optional parameters', function (): void { + $spanData = new SpanData( + spanId: 'span-123', + traceId: 'trace-456', + parentSpanId: null, + operation: 'text_generation', + startTimeNano: 1000000000, + endTimeNano: 2000000000, + attributes: [], + ); + + expect($spanData->parentSpanId)->toBeNull() + ->and($spanData->events)->toBe([]) + ->and($spanData->exception)->toBeNull(); +}); + +describe('hasError', function (): void { + it('returns false when exception is null', function (): void { + $spanData = createSpanDataWithException(null); + + expect($spanData->hasError())->toBeFalse(); + }); + + it('returns true when exception is present', function (): void { + $spanData = createSpanDataWithException(new RuntimeException('Test error')); + + expect($spanData->hasError())->toBeTrue(); + }); + + it('returns true for any Throwable type', function (): void { + $spanData = createSpanDataWithException(new Error('Fatal error')); + + expect($spanData->hasError())->toBeTrue(); + }); +}); + +describe('durationNano', function (): void { + it('calculates duration in nanoseconds', function (): void { + $spanData = createSpanDataWithTimes(1000000000, 2500000000); + + expect($spanData->durationNano())->toBe(1500000000); + }); + + it('returns zero for same start and end time', function (): void { + $spanData = createSpanDataWithTimes(1000000000, 1000000000); + + expect($spanData->durationNano())->toBe(0); + }); + + it('handles very small durations (1 nanosecond)', function (): void { + $spanData = createSpanDataWithTimes(1000000000, 1000000001); + + expect($spanData->durationNano())->toBe(1); + }); +}); + +describe('durationMs', function (): void { + it('calculates duration in milliseconds', function (): void { + $spanData = createSpanDataWithTimes(1000000000, 2500000000); + + expect($spanData->durationMs())->toBe(1500.0); + }); + + it('returns float for sub-millisecond precision', function (): void { + $spanData = createSpanDataWithTimes(1000000000, 1000500000); + + expect($spanData->durationMs())->toBe(0.5); + }); + + it('returns zero for same start and end time', function (): void { + $spanData = createSpanDataWithTimes(1000000000, 1000000000); + + expect($spanData->durationMs())->toBe(0.0); + }); +}); + +describe('attributes', function (): void { + it('supports nested arrays and complex structures', function (): void { + $spanData = new SpanData( + spanId: 'span-123', + traceId: 'trace-456', + parentSpanId: null, + operation: 'text_generation', + startTimeNano: 1000000000, + endTimeNano: 2000000000, + attributes: [ + 'model' => 'gpt-4', + 'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 20], + 'messages' => [ + ['role' => 'user', 'content' => 'Hello'], + ['role' => 'assistant', 'content' => 'Hi there!'], + ], + ], + ); + + expect($spanData->attributes['usage']['prompt_tokens'])->toBe(10) + ->and($spanData->attributes['messages'])->toHaveCount(2); + }); + + it('supports empty attributes', function (): void { + $spanData = createSpanDataWithTimes(1000000000, 2000000000); + + expect($spanData->attributes)->toBe([]); + }); +}); + +describe('events', function (): void { + it('supports multiple events and preserves order', function (): void { + $spanData = new SpanData( + spanId: 'span-123', + traceId: 'trace-456', + parentSpanId: null, + operation: 'text_generation', + startTimeNano: 1000000000, + endTimeNano: 2000000000, + attributes: [], + events: [ + ['name' => 'first', 'timeNanos' => 1100000000, 'attributes' => ['size' => 10]], + ['name' => 'second', 'timeNanos' => 1200000000, 'attributes' => ['size' => 15]], + ['name' => 'third', 'timeNanos' => 1300000000, 'attributes' => []], + ], + ); + + expect($spanData->events)->toHaveCount(3) + ->and($spanData->events[0]['name'])->toBe('first') + ->and($spanData->events[1]['name'])->toBe('second') + ->and($spanData->events[2]['name'])->toBe('third'); + }); +}); + +function createSpanDataWithException(?Throwable $exception): SpanData +{ + return new SpanData( + spanId: 'span-123', + traceId: 'trace-456', + parentSpanId: null, + operation: 'text_generation', + startTimeNano: 1000000000, + endTimeNano: 2000000000, + attributes: [], + exception: $exception, + ); +} + +function createSpanDataWithTimes(int $startTimeNano, int $endTimeNano): SpanData +{ + return new SpanData( + spanId: 'span-123', + traceId: 'trace-456', + parentSpanId: null, + operation: 'text_generation', + startTimeNano: $startTimeNano, + endTimeNano: $endTimeNano, + attributes: [], + ); +} diff --git a/tests/Telemetry/StreamingTelemetryTest.php b/tests/Telemetry/StreamingTelemetryTest.php new file mode 100644 index 000000000..a4fa8fc00 --- /dev/null +++ b/tests/Telemetry/StreamingTelemetryTest.php @@ -0,0 +1,177 @@ + true]); + Event::fake(); + Context::forgetHidden('prism.telemetry.trace_id'); + Context::forgetHidden('prism.telemetry.current_span_id'); + Context::forgetHidden('prism.telemetry.metadata'); +}); + +describe('event dispatch', function (): void { + it('creates StreamingStarted event with correct properties', function (): void { + $request = createStreamingRequest(); + $event = new StreamingStarted( + spanId: 'stream-span-123', + traceId: 'stream-trace-123', + parentSpanId: null, + request: $request, + ); + + expect($event->spanId)->toBe('stream-span-123') + ->and($event->traceId)->toBe('stream-trace-123') + ->and($event->request)->toBe($request) + ->and($event->streamStart)->toBeNull(); + }); + + it('creates StreamingCompleted event with correct properties', function (): void { + $request = createStreamingRequest(); + $event = new StreamingCompleted( + spanId: 'stream-span-123', + traceId: 'stream-trace-123', + parentSpanId: null, + request: $request, + ); + + expect($event->spanId)->toBe('stream-span-123') + ->and($event->request)->toBe($request) + ->and($event->streamEnd)->toBeNull(); + }); +}); + +describe('SpanCollector', function (): void { + it('extracts streaming attributes and sets correct operation', function (): void { + $capturedSpan = captureSpanFromCollector(); + + expect($capturedSpan)->toBeInstanceOf(SpanData::class) + ->and($capturedSpan->operation)->toBe('streaming') + ->and($capturedSpan->attributes)->toHaveKey('model') + ->and($capturedSpan->attributes['model'])->toBe('gpt-4'); + }); + + it('maintains trace context from events', function (): void { + $capturedSpan = captureSpanFromCollector( + spanId: 'child-stream-span', + traceId: 'parent-trace-id', + parentSpanId: 'parent-span-id' + ); + + expect($capturedSpan->traceId)->toBe('parent-trace-id') + ->and($capturedSpan->parentSpanId)->toBe('parent-span-id'); + }); + + it('preserves telemetry context metadata', function (): void { + Context::addHidden('prism.telemetry.metadata', [ + 'user_id' => 'stream-user-123', + 'session_id' => 'stream-session-456', + ]); + + $capturedSpan = captureSpanFromCollector(); + + expect($capturedSpan->attributes)->toHaveKey('metadata') + ->and($capturedSpan->attributes['metadata']['user_id'])->toBe('stream-user-123') + ->and($capturedSpan->attributes['metadata']['session_id'])->toBe('stream-session-456'); + + Context::forgetHidden('prism.telemetry.metadata'); + }); +}); + +describe('TelemetryEventListener', function (): void { + it('routes streaming events through collector', function (): void { + $capturedSpan = null; + $driver = createStreamingMockDriver($capturedSpan); + $collector = new SpanCollector($driver); + $listener = new TelemetryEventListener($collector); + + $request = createStreamingRequest(); + + $listener->handleStreamingStarted(new StreamingStarted( + spanId: 'stream-span-123', + traceId: 'stream-trace-123', + parentSpanId: null, + request: $request, + )); + + $listener->handleStreamingCompleted(new StreamingCompleted( + spanId: 'stream-span-123', + traceId: 'stream-trace-123', + parentSpanId: null, + request: $request, + )); + + expect($capturedSpan)->toBeInstanceOf(SpanData::class) + ->and($capturedSpan->operation)->toBe('streaming'); + }); +}); + +function createStreamingRequest(): Request +{ + return new Request( + model: 'gpt-4', + providerKey: 'openai', + systemPrompts: [], + prompt: 'Stream this response', + messages: [], + maxSteps: 1, + maxTokens: 200, + temperature: 0.5, + topP: 1.0, + tools: [], + clientOptions: [], + clientRetry: [3], + toolChoice: null, + ); +} + +function createStreamingMockDriver(?SpanData &$capturedSpan): TelemetryDriver +{ + $driver = Mockery::mock(TelemetryDriver::class); + $driver->shouldReceive('recordSpan') + ->once() + ->with(Mockery::on(function ($span) use (&$capturedSpan): bool { + $capturedSpan = $span; + + return $span instanceof SpanData; + })); + + return $driver; +} + +function captureSpanFromCollector( + string $spanId = 'stream-span-123', + string $traceId = 'stream-trace-123', + ?string $parentSpanId = null +): SpanData { + $capturedSpan = null; + $driver = createStreamingMockDriver($capturedSpan); + $collector = new SpanCollector($driver); + $request = createStreamingRequest(); + + $collector->startSpan(new StreamingStarted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + )); + + $collector->endSpan(new StreamingCompleted( + spanId: $spanId, + traceId: $traceId, + parentSpanId: $parentSpanId, + request: $request, + )); + + return $capturedSpan; +} diff --git a/tests/Telemetry/StructuredOutputTelemetryTest.php b/tests/Telemetry/StructuredOutputTelemetryTest.php new file mode 100644 index 000000000..ea3632dab --- /dev/null +++ b/tests/Telemetry/StructuredOutputTelemetryTest.php @@ -0,0 +1,95 @@ + true]); + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Structured response', + structured: ['test'], + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model') + ); + + Prism::fake([$mockResponse]); + + $response = Prism::structured() + ->using('openai', 'gpt-4') + ->withPrompt('Generate structured data') + ->withSchema(new StringSchema('test', 'Test schema')) + ->asStructured(); + + Event::assertDispatched(StructuredOutputStarted::class); + Event::assertDispatched(StructuredOutputCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('does not emit telemetry events when disabled', function (): void { + config(['prism.telemetry.enabled' => false]); + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Structured response', + structured: ['test'], + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model') + ); + + Prism::fake([$mockResponse]); + + $response = Prism::structured() + ->using('openai', 'gpt-4') + ->withPrompt('Generate structured data') + ->withSchema(new StringSchema('test', 'Test schema')) + ->asStructured(); + + Event::assertNotDispatched(StructuredOutputStarted::class); + Event::assertNotDispatched(StructuredOutputCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('includes traceId and parentSpanId in structured output telemetry events', function (): void { + config(['prism.telemetry.enabled' => true]); + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Structured response', + structured: ['test'], + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model') + ); + + Prism::fake([$mockResponse]); + + Prism::structured() + ->using('openai', 'gpt-4') + ->withPrompt('Generate structured data') + ->withSchema(new StringSchema('test', 'Test schema')) + ->asStructured(); + + Event::assertDispatched(StructuredOutputStarted::class, fn ($event): bool => ! empty($event->spanId) + && ! empty($event->traceId) + && $event->request !== null); + + Event::assertDispatched(StructuredOutputCompleted::class, fn ($event): bool => ! empty($event->spanId) + && ! empty($event->traceId) + && $event->request !== null + && $event->response !== null); +}); diff --git a/tests/Telemetry/TelemetryContextTest.php b/tests/Telemetry/TelemetryContextTest.php new file mode 100644 index 000000000..165a2f3c5 --- /dev/null +++ b/tests/Telemetry/TelemetryContextTest.php @@ -0,0 +1,277 @@ +telemetryContext; + } + + public function testPushContext(): void + { + $this->pushTelemetryContext(); + } +} + +class TestExceptionClass +{ + use EmitsTelemetry, HasTelemetryContext; + + public function executeWithException(): void + { + $this->withTelemetry( + startEventFactory: fn ($spanId, $traceId, $parentSpanId): object => new class($spanId) + { + public function __construct(public string $spanId) {} + }, + endEventFactory: fn ($spanId, $traceId, $parentSpanId, $response): object => new class($spanId) + { + public function __construct(public string $spanId) {} + }, + execute: fn () => throw new RuntimeException('Test exception from execute'), + ); + } +} + +beforeEach(function (): void { + Context::forgetHidden('prism.telemetry.metadata'); + Context::forgetHidden('prism.telemetry.trace_id'); + Context::forgetHidden('prism.telemetry.current_span_id'); + PrismClass::flushDefaultTelemetryContext(); +}); + +describe('HasTelemetryContext fluent methods', function (): void { + it('sets user context with forUser method', function (): void { + $instance = new TestContextClass; + + $result = $instance->forUser('user-123', 'test@example.com'); + + expect($result)->toBe($instance) + ->and($instance->getTelemetryContext())->toBe([ + 'user_id' => 'user-123', + 'user_email' => 'test@example.com', + ]); + }); + + it('converts integer user id to string', function (): void { + $instance = (new TestContextClass)->forUser(123); + + expect($instance->getTelemetryContext()['user_id'])->toBe('123'); + }); + + it('sets conversation context with forConversation method', function (): void { + $instance = (new TestContextClass)->forConversation('conv-456'); + + expect($instance->getTelemetryContext())->toBe(['session_id' => 'conv-456']); + }); + + it('sets agent context with forAgent method', function (): void { + $instance = (new TestContextClass)->forAgent('support-bot'); + + expect($instance->getTelemetryContext())->toBe(['agent' => 'support-bot']); + }); + + it('sets arbitrary context with withTelemetryContext method', function (): void { + $instance = (new TestContextClass)->withTelemetryContext([ + 'custom_key' => 'custom_value', + 'environment' => 'production', + ]); + + expect($instance->getTelemetryContext())->toBe([ + 'custom_key' => 'custom_value', + 'environment' => 'production', + ]); + }); + + it('sets tags with withTelemetryTags method', function (): void { + $instance = (new TestContextClass)->withTelemetryTags([ + 'priority' => 'high', + 'department' => 'sales', + ]); + + expect($instance->getTelemetryContext()['tags'])->toBe([ + 'priority' => 'high', + 'department' => 'sales', + ]); + }); + + it('chains multiple context methods together', function (): void { + $instance = (new TestContextClass) + ->forUser('user-123') + ->forConversation('conv-456') + ->forAgent('code-assistant') + ->withTelemetryTags(['env' => 'test']); + + expect($instance->getTelemetryContext())->toBe([ + 'user_id' => 'user-123', + 'session_id' => 'conv-456', + 'agent' => 'code-assistant', + 'tags' => ['env' => 'test'], + ]); + }); +}); + +describe('Laravel Context integration', function (): void { + it('pushes context to Laravel hidden context', function (): void { + $instance = (new TestContextClass)->forUser('user-789')->forAgent('test-agent'); + $instance->testPushContext(); + + expect(Context::getHidden('prism.telemetry.metadata'))->toBe([ + 'user_id' => 'user-789', + 'agent' => 'test-agent', + ]); + }); +}); + +describe('global default context', function (): void { + it('sets and retrieves global default telemetry context', function (): void { + PrismClass::defaultTelemetryContext(fn (): array => [ + 'environment' => 'testing', + 'app_version' => '1.0.0', + ]); + + expect(PrismClass::getDefaultTelemetryContext())->toBe([ + 'environment' => 'testing', + 'app_version' => '1.0.0', + ]); + }); + + it('flushes global default telemetry context', function (): void { + PrismClass::defaultTelemetryContext(fn (): array => ['key' => 'value']); + PrismClass::flushDefaultTelemetryContext(); + + expect(PrismClass::getDefaultTelemetryContext())->toBe([]); + }); + + it('evaluates resolver lazily on each call', function (): void { + $callCount = 0; + PrismClass::defaultTelemetryContext(function () use (&$callCount): array { + $callCount++; + + return ['call_count' => $callCount]; + }); + + PrismClass::getDefaultTelemetryContext(); + PrismClass::getDefaultTelemetryContext(); + + expect($callCount)->toBe(2); + }); +}); + +describe('context merging', function (): void { + it('merges global default context with instance context', function (): void { + PrismClass::defaultTelemetryContext(fn (): array => [ + 'environment' => 'testing', + 'default_key' => 'default_value', + ]); + + $instance = (new TestContextClass)->forUser('user-123'); + $instance->testPushContext(); + + expect(Context::getHidden('prism.telemetry.metadata'))->toBe([ + 'environment' => 'testing', + 'default_key' => 'default_value', + 'user_id' => 'user-123', + ]); + }); + + it('instance context overrides global default context', function (): void { + PrismClass::defaultTelemetryContext(fn (): array => [ + 'user_id' => 'global-user', + 'environment' => 'production', + ]); + + $instance = (new TestContextClass)->forUser('specific-user'); + $instance->testPushContext(); + + $metadata = Context::getHidden('prism.telemetry.metadata'); + expect($metadata['user_id'])->toBe('specific-user') + ->and($metadata['environment'])->toBe('production'); + }); +}); + +describe('context flows to telemetry', function (): void { + it('context metadata flows through to telemetry events', function (): void { + config(['prism.telemetry.enabled' => true]); + Event::fake(); + + PrismClass::defaultTelemetryContext(fn (): array => ['environment' => 'test']); + + $mockResponse = new Response( + steps: collect(), + text: 'Test response', + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model'), + messages: collect() + ); + + Prism::fake([$mockResponse]); + + Prism::text() + ->using('openai', 'gpt-4') + ->forUser('user-integration-test') + ->forAgent('integration-agent') + ->withPrompt('Test prompt') + ->asText(); + + Event::assertDispatched(TextGenerationStarted::class); + Event::assertDispatched(TextGenerationCompleted::class); + }); +}); + +describe('exception handling', function (): void { + it('dispatches SpanException when operation throws', function (): void { + config(['prism.telemetry.enabled' => true]); + Event::fake(); + + $testClass = new TestExceptionClass; + + try { + $testClass->executeWithException(); + $this->fail('Expected RuntimeException to be thrown'); + } catch (RuntimeException $e) { + expect($e->getMessage())->toBe('Test exception from execute'); + } + + Event::assertDispatched(SpanException::class, fn ($e): bool => $e->exception instanceof RuntimeException + && $e->exception->getMessage() === 'Test exception from execute' + && ! empty($e->spanId)); + }); + + it('resets span context even when exception is thrown', function (): void { + config(['prism.telemetry.enabled' => true]); + Event::fake(); + + $parentSpanId = 'parent-span-exception-test'; + Context::addHidden('prism.telemetry.trace_id', 'trace-exception'); + Context::addHidden('prism.telemetry.current_span_id', $parentSpanId); + + $testClass = new TestExceptionClass; + + try { + $testClass->executeWithException(); + } catch (RuntimeException) { + // Expected + } + + expect(Context::getHidden('prism.telemetry.current_span_id'))->toBe($parentSpanId); + }); +}); diff --git a/tests/Telemetry/TelemetryEventListenerTest.php b/tests/Telemetry/TelemetryEventListenerTest.php new file mode 100644 index 000000000..bf1ce338b --- /dev/null +++ b/tests/Telemetry/TelemetryEventListenerTest.php @@ -0,0 +1,185 @@ + true, + 'prism.telemetry.driver' => 'null', + ]); + + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Test response', + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model'), + messages: collect() + ); + + Prism::fake([$mockResponse]); + + $response = Prism::text() + ->using('anthropic', 'claude-3-sonnet') + ->withPrompt('Test prompt') + ->asText(); + + // Verify telemetry events were dispatched + Event::assertDispatched(TextGenerationStarted::class); + Event::assertDispatched(TextGenerationCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('does not dispatch events when telemetry is disabled', function (): void { + config([ + 'prism.telemetry.enabled' => false, + ]); + + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Test response', + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model'), + messages: collect() + ); + + Prism::fake([$mockResponse]); + + $response = Prism::text() + ->using('anthropic', 'claude-3-sonnet') + ->withPrompt('Test prompt') + ->asText(); + + // Verify events were not dispatched when telemetry is disabled + Event::assertNotDispatched(TextGenerationStarted::class); + Event::assertNotDispatched(TextGenerationCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('can handle telemetry events with event listener', function (): void { + $driver = new NullDriver; + $collector = new SpanCollector($driver); + $listener = new TelemetryEventListener($collector); + + $textRequest = new \Prism\Prism\Text\Request( + model: 'claude-3-sonnet', + providerKey: 'anthropic:claude-3-sonnet', + systemPrompts: [], + prompt: 'test', + messages: [], + maxSteps: 1, + maxTokens: null, + temperature: null, + topP: null, + tools: [], + clientOptions: [], + clientRetry: [], + toolChoice: null, + providerOptions: [], + providerTools: [] + ); + + $startEvent = new TextGenerationStarted( + spanId: 'span-123', + traceId: 'trace-123', + parentSpanId: null, + request: $textRequest, + ); + + // This should not throw an exception + $listener->handleTextGenerationStarted($startEvent); + + expect(true)->toBeTrue(); +}); + +it('routes events through span collector and extracts attributes', function (): void { + $capturedSpan = null; + + $driver = Mockery::mock(TelemetryDriver::class); + $driver->shouldReceive('recordSpan') + ->once() + ->with(Mockery::on(function ($span) use (&$capturedSpan): bool { + $capturedSpan = $span; + + return $span instanceof SpanData; + })); + + $collector = new SpanCollector($driver); + $listener = new TelemetryEventListener($collector); + + $textRequest = new \Prism\Prism\Text\Request( + model: 'claude-3-sonnet', + providerKey: 'anthropic:claude-3-sonnet', + systemPrompts: [], + prompt: 'test', + messages: [], + maxSteps: 1, + maxTokens: null, + temperature: null, + topP: null, + tools: [], + clientOptions: [], + clientRetry: [], + toolChoice: null, + providerOptions: [], + providerTools: [] + ); + + $textResponse = new Response( + steps: collect(), + text: 'Test response', + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model'), + messages: collect() + ); + + $startEvent = new TextGenerationStarted( + spanId: 'span-123', + traceId: 'trace-123', + parentSpanId: null, + request: $textRequest, + ); + $endEvent = new TextGenerationCompleted( + spanId: 'span-123', + traceId: 'trace-123', + parentSpanId: null, + request: $textRequest, + response: $textResponse, + ); + + $listener->handleTextGenerationStarted($startEvent); + $listener->handleTextGenerationCompleted($endEvent); + + // Verify span was created with generic Prism attributes (not OpenInference) + expect($capturedSpan)->toBeInstanceOf(SpanData::class); + expect($capturedSpan->operation)->toBe('text_generation'); + expect($capturedSpan->attributes)->toHaveKey('model'); + expect($capturedSpan->attributes['model'])->toBe('claude-3-sonnet'); + expect($capturedSpan->attributes)->toHaveKey('provider'); + expect($capturedSpan->attributes['provider'])->toBe('anthropic:claude-3-sonnet'); +}); diff --git a/tests/Telemetry/TelemetryManagerTest.php b/tests/Telemetry/TelemetryManagerTest.php new file mode 100644 index 000000000..6c9c726d1 --- /dev/null +++ b/tests/Telemetry/TelemetryManagerTest.php @@ -0,0 +1,169 @@ +resolve('null'); + + expect($driver)->toBeInstanceOf(NullDriver::class); +}); + +it('resolves log driver with configuration', function (): void { + $manager = new TelemetryManager(app()); + + $driver = $manager->resolve('log'); + + expect($driver)->toBeInstanceOf(LogDriver::class); +}); + +it('resolves phoenix as otlp driver with openinference mapper', function (): void { + $manager = new TelemetryManager(app()); + + $driver = $manager->resolve('phoenix'); + + expect($driver)->toBeInstanceOf(OtlpDriver::class); + expect($driver->getDriver())->toBe('phoenix'); +}); + +it('resolves custom otlp driver from config', function (): void { + config([ + 'prism.telemetry.drivers.langfuse' => [ + 'driver' => 'otlp', + 'endpoint' => 'https://cloud.langfuse.com', + 'api_key' => 'test-key', + ], + ]); + + $manager = new TelemetryManager(app()); + + $driver = $manager->resolve('langfuse'); + + expect($driver)->toBeInstanceOf(OtlpDriver::class); + expect($driver->getDriver())->toBe('langfuse'); +}); + +it('throws exception for unsupported driver type', function (): void { + config([ + 'prism.telemetry.drivers.custom' => [ + 'driver' => 'unsupported', + ], + ]); + + $manager = new TelemetryManager(app()); + + $manager->resolve('custom'); +})->throws(InvalidArgumentException::class, 'Telemetry driver [unsupported] is not supported.'); + +it('uses configuration from config file for log driver', function (): void { + config([ + 'prism.telemetry.drivers.log' => [ + 'driver' => 'log', + 'channel' => 'custom-channel', + ], + ]); + + $manager = new TelemetryManager(app()); + $driver = $manager->resolve('log'); + + expect($driver)->toBeInstanceOf(LogDriver::class); +}); + +it('resolves custom driver via class', function (): void { + $customDriver = new class implements TelemetryDriver + { + public function recordSpan(SpanData $span): void {} + + public function shutdown(): void {} + }; + + $factoryClass = new class($customDriver) + { + public function __construct(private readonly TelemetryDriver $driver) {} + + public function __invoke($app, $config): TelemetryDriver + { + return $this->driver; + } + }; + + app()->instance('custom-factory', $factoryClass); + + config([ + 'prism.telemetry.drivers.my-custom' => [ + 'driver' => 'custom', + 'via' => 'custom-factory', + ], + ]); + + $manager = new TelemetryManager(app()); + $driver = $manager->resolve('my-custom'); + + expect($driver)->toBe($customDriver); +}); + +it('resolves custom driver via closure', function (): void { + $customDriver = new class implements TelemetryDriver + { + public function recordSpan(SpanData $span): void {} + + public function shutdown(): void {} + }; + + config([ + 'prism.telemetry.drivers.closure-custom' => [ + 'driver' => 'custom', + 'via' => fn ($app, $config): object => $customDriver, + ], + ]); + + $manager = new TelemetryManager(app()); + $driver = $manager->resolve('closure-custom'); + + expect($driver)->toBe($customDriver); +}); + +it('throws exception when custom driver missing via key', function (): void { + config([ + 'prism.telemetry.drivers.bad-custom' => [ + 'driver' => 'custom', + ], + ]); + + $manager = new TelemetryManager(app()); + $manager->resolve('bad-custom'); +})->throws(InvalidArgumentException::class, "Custom telemetry driver [bad-custom] requires a 'via' configuration option."); + +it('passes config to custom driver factory', function (): void { + $receivedConfig = null; + + config([ + 'prism.telemetry.drivers.config-test' => [ + 'driver' => 'custom', + 'via' => function ($app, $config) use (&$receivedConfig): \Prism\Prism\Contracts\TelemetryDriver { + $receivedConfig = $config; + + return new class implements TelemetryDriver + { + public function recordSpan(SpanData $span): void {} + + public function shutdown(): void {} + }; + }, + 'custom_option' => 'test-value', + ], + ]); + + $manager = new TelemetryManager(app()); + $manager->resolve('config-test'); + + expect($receivedConfig)->toHaveKey('custom_option', 'test-value'); +}); diff --git a/tests/Telemetry/TextGenerationTelemetryTest.php b/tests/Telemetry/TextGenerationTelemetryTest.php new file mode 100644 index 000000000..9b1f6b90e --- /dev/null +++ b/tests/Telemetry/TextGenerationTelemetryTest.php @@ -0,0 +1,97 @@ + true]); + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Test response', + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model'), + messages: collect() + ); + + Prism::fake([$mockResponse]); + + $response = Prism::text() + ->using('openai', 'gpt-4') + ->withPrompt('Test prompt') + ->asText(); + + Event::assertDispatched(TextGenerationStarted::class); + Event::assertDispatched(TextGenerationCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('does not emit telemetry events when disabled', function (): void { + config(['prism.telemetry.enabled' => false]); + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Test response', + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model'), + messages: collect() + ); + + Prism::fake([$mockResponse]); + + $response = Prism::text() + ->using('openai', 'gpt-4') + ->withPrompt('Test prompt') + ->asText(); + + Event::assertNotDispatched(TextGenerationStarted::class); + Event::assertNotDispatched(TextGenerationCompleted::class); + + expect($response)->toBeInstanceOf(Response::class); +}); + +it('includes traceId and parentSpanId in telemetry events', function (): void { + config(['prism.telemetry.enabled' => true]); + Event::fake(); + + $mockResponse = new Response( + steps: collect(), + text: 'Test response', + finishReason: \Prism\Prism\Enums\FinishReason::Stop, + toolCalls: [], + toolResults: [], + usage: new \Prism\Prism\ValueObjects\Usage(10, 20), + meta: new \Prism\Prism\ValueObjects\Meta('test-id', 'test-model'), + messages: collect() + ); + + Prism::fake([$mockResponse]); + + Prism::text() + ->using('openai', 'gpt-4') + ->withPrompt('Test prompt') + ->asText(); + + Event::assertDispatched(TextGenerationStarted::class, fn ($event): bool => ! empty($event->spanId) + && ! empty($event->traceId) + && $event->request !== null); + + Event::assertDispatched(TextGenerationCompleted::class, fn ($event): bool => ! empty($event->spanId) + && ! empty($event->traceId) + && $event->request !== null + && $event->response !== null); +}); diff --git a/tests/Telemetry/ToolCallTelemetryTest.php b/tests/Telemetry/ToolCallTelemetryTest.php new file mode 100644 index 000000000..7c07a6c33 --- /dev/null +++ b/tests/Telemetry/ToolCallTelemetryTest.php @@ -0,0 +1,148 @@ + true, + 'prism.telemetry.driver' => 'null', + ]); + + Event::fake(); + + // Create a test class that uses the CallsTools trait + $testHandler = new class + { + use CallsTools; + + public function testCallTools(array $tools, array $toolCalls): array + { + return $this->callTools($tools, $toolCalls); + } + }; + + // Create a mock tool + $tool = (new Tool) + ->as('test_tool') + ->for('Testing tool calls') + ->withStringParameter('input', 'Test input') + ->using(fn (string $input): string => "Processed: {$input}"); + + // Create a tool call + $toolCall = new ToolCall( + id: 'tool-123', + name: 'test_tool', + arguments: ['input' => 'test value'], + resultId: 'result-123' + ); + + // Execute the tool call + $results = $testHandler->testCallTools([$tool], [$toolCall]); + + // Verify tool call telemetry events were dispatched + Event::assertDispatched(ToolCallStarted::class); + Event::assertDispatched(ToolCallCompleted::class); + + expect($results)->toHaveCount(1); +}); + +it('does not emit tool call events when telemetry is disabled', function (): void { + config([ + 'prism.telemetry.enabled' => false, + ]); + + Event::fake(); + + // Create a test class that uses the CallsTools trait + $testHandler = new class + { + use CallsTools; + + public function testCallTools(array $tools, array $toolCalls): array + { + return $this->callTools($tools, $toolCalls); + } + }; + + // Create a mock tool + $tool = (new Tool) + ->as('test_tool') + ->for('Testing tool calls') + ->withStringParameter('input', 'Test input') + ->using(fn (string $input): string => "Processed: {$input}"); + + // Create a tool call + $toolCall = new ToolCall( + id: 'tool-123', + name: 'test_tool', + arguments: ['input' => 'test value'], + resultId: 'result-123' + ); + + // Execute the tool call + $results = $testHandler->testCallTools([$tool], [$toolCall]); + + // Verify tool call events were not dispatched when telemetry is disabled + Event::assertNotDispatched(ToolCallStarted::class); + Event::assertNotDispatched(ToolCallCompleted::class); + + expect($results)->toHaveCount(1); +}); + +it('includes traceId and parentSpanId in tool call telemetry events', function (): void { + config([ + 'prism.telemetry.enabled' => true, + 'prism.telemetry.driver' => 'null', + ]); + + Event::fake(); + + // Create a test class that uses the CallsTools trait + $testHandler = new class + { + use CallsTools; + + public function testCallTools(array $tools, array $toolCalls): array + { + return $this->callTools($tools, $toolCalls); + } + }; + + // Create a mock tool + $tool = (new Tool) + ->as('test_tool') + ->for('Testing tool calls') + ->withStringParameter('input', 'Test input') + ->using(fn (string $input): string => "Processed: {$input}"); + + // Create a tool call + $toolCall = new ToolCall( + id: 'tool-123', + name: 'test_tool', + arguments: ['input' => 'test value'], + resultId: 'result-123' + ); + + // Execute the tool call + $results = $testHandler->testCallTools([$tool], [$toolCall]); + + // Verify tool call events contain traceId and parentSpanId as properties + Event::assertDispatched(ToolCallStarted::class, function (ToolCallStarted $event): bool { + return ! empty($event->spanId) + && ! empty($event->traceId) + && $event->parentSpanId === null; // No parent when called directly + }); + + Event::assertDispatched(ToolCallCompleted::class, fn (ToolCallCompleted $event): bool => ! empty($event->spanId) + && ! empty($event->traceId) + && $event->parentSpanId === null); + + expect($results)->toHaveCount(1); +});