Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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": {
Expand Down
48 changes: 48 additions & 0 deletions config/prism.php
Original file line number Diff line number Diff line change
Expand Up @@ -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...
// ],
],
],
];
143 changes: 83 additions & 60 deletions src/Concerns/CallsTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
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;
use Prism\Prism\ValueObjects\ToolResult;

trait CallsTools
{
use EmitsTelemetry;

/**
* Execute tools and return results (for non-streaming handlers).
*
Expand Down Expand Up @@ -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,
);
}
}

Expand Down
143 changes: 143 additions & 0 deletions src/Concerns/EmitsTelemetry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Prism\Prism\Concerns;

use Generator;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Event;
use Prism\Prism\Contracts\TelemetryDriver;
use Prism\Prism\Telemetry\Events\SpanException;

trait EmitsTelemetry
{
/**
* Execute a callable with telemetry tracking.
*
* @template TResponse
*
* @param callable(string, string, ?string): object $startEventFactory Factory to create start event (spanId, traceId, parentSpanId) => 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<TYield> $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<TYield>
*/
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();
}
}
}
Loading