diff --git a/composer.json b/composer.json index 9651b02..98892f9 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "test:lint": "@php vendor/bin/pint --config https://raw.githubusercontent.com/DIJ-digital/pint-config/main/pint.json", "test:unit": "pest", "test:types": "phpstan", - "test:refactor": "rector --dry-run", + "test:refactor": "rector", "test": [ "@test:lint", "@test:type-coverage", diff --git a/src/Contracts/TransporterInterface.php b/src/Contracts/TransporterInterface.php index 8234ade..558497a 100644 --- a/src/Contracts/TransporterInterface.php +++ b/src/Contracts/TransporterInterface.php @@ -9,34 +9,34 @@ interface TransporterInterface { /** - * @param array $options + * @param array $options */ public function request(string $method, string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function get(string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function post(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function postJson(string $uri, array $data = [], array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function delete(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function patchJson(string $uri, array $data = [], array $options = []): ResponseInterface; } diff --git a/src/Langfuse.php b/src/Langfuse.php index ebe96f9..8cb39c4 100644 --- a/src/Langfuse.php +++ b/src/Langfuse.php @@ -12,8 +12,7 @@ public function __construct( private readonly TransporterInterface $transporter, private readonly string $environment = 'default', private readonly string $label = 'latest', - ) { - } + ) {} public function prompt(): Prompt { @@ -37,4 +36,11 @@ public function score(): Score transporter: $this->transporter, ); } + + public function trace(): Trace + { + return new Trace( + transporter: $this->transporter, + ); + } } diff --git a/src/Responses/TraceListResponse.php b/src/Responses/TraceListResponse.php new file mode 100644 index 0000000..89e9688 --- /dev/null +++ b/src/Responses/TraceListResponse.php @@ -0,0 +1,53 @@ + $data + */ + public function __construct( + public array $data, + public MetaData $meta, + ) {} + + /** + * @param array{ + * data: array, + * public: bool|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * externalId?: string|null, + * totalCost?: float|null, + * latency?: float|null, + * htmlPath?: string|null, + * environment?: string|null + * }>, + * meta: array{page: int, limit: int, totalPages: int, totalItems: int} + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + data: array_map(fn (array $item): TraceResponse => TraceResponse::fromArray($item), $data['data']), + meta: MetaData::fromArray($data['meta']), + ); + } +} diff --git a/src/Responses/TraceResponse.php b/src/Responses/TraceResponse.php new file mode 100644 index 0000000..dc8cf57 --- /dev/null +++ b/src/Responses/TraceResponse.php @@ -0,0 +1,92 @@ + $tags + * @param array $observations + * @param array $scores + */ + public function __construct( + public string $id, + public string $timestamp, + public ?string $name, + public mixed $input, + public mixed $output, + public ?string $sessionId, + public ?string $release, + public ?string $version, + public ?string $userId, + public mixed $metadata, + public array $tags, + public ?bool $public, + public string $projectId, + public string $createdAt, + public string $updatedAt, + public ?string $externalId = null, + public ?float $totalCost = null, + public ?float $latency = null, + public array $observations = [], + public array $scores = [], + public ?string $htmlPath = null, + public ?string $environment = null, + ) {} + + /** + * @param array{ + * id: string, + * timestamp: string, + * name: string|null, + * input: mixed, + * output: mixed, + * sessionId: string|null, + * release: string|null, + * version: string|null, + * userId: string|null, + * metadata: mixed, + * tags: array, + * public: bool|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * externalId?: string|null, + * totalCost?: float|null, + * latency?: float|null, + * observations?: array, + * scores?: array, + * htmlPath?: string|null, + * environment?: string|null + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + timestamp: $data['timestamp'], + name: $data['name'], + input: $data['input'], + output: $data['output'], + sessionId: $data['sessionId'], + release: $data['release'], + version: $data['version'], + userId: $data['userId'], + metadata: $data['metadata'], + tags: $data['tags'], + public: $data['public'], + projectId: $data['projectId'], + createdAt: $data['createdAt'], + updatedAt: $data['updatedAt'], + externalId: $data['externalId'] ?? null, + totalCost: $data['totalCost'] ?? null, + latency: $data['latency'] ?? null, + observations: $data['observations'] ?? [], + scores: $data['scores'] ?? [], + htmlPath: $data['htmlPath'] ?? null, + environment: $data['environment'] ?? null, + ); + } +} diff --git a/src/Testing/Responses/GetTraceListResponse.php b/src/Testing/Responses/GetTraceListResponse.php new file mode 100644 index 0000000..25f9a2a --- /dev/null +++ b/src/Testing/Responses/GetTraceListResponse.php @@ -0,0 +1,79 @@ +|string> $headers + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null) + { + parent::__construct($status, $headers, (string) json_encode($this->payload()), $version, $reason); + } + + /** + * @return array + */ + public function payload(): array + { + return [ + 'data' => [ + [ + 'id' => 'trace-abc123', + 'timestamp' => '2025-01-22T10:30:00.000Z', + 'name' => 'test-trace-1', + 'input' => ['prompt' => 'Hello, world!'], + 'output' => ['response' => 'Hi there!'], + 'sessionId' => 'session-xyz789', + 'release' => '1.0.0', + 'version' => 'v1', + 'userId' => 'user-123', + 'metadata' => ['key' => 'value'], + 'tags' => ['production'], + 'public' => false, + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T10:30:00.000Z', + 'updatedAt' => '2025-01-22T10:30:00.000Z', + 'externalId' => null, + 'totalCost' => 0.0025, + 'latency' => 1.5, + 'htmlPath' => '/traces/trace-abc123', + 'environment' => 'production', + ], + [ + 'id' => 'trace-def456', + 'timestamp' => '2025-01-22T09:15:00.000Z', + 'name' => 'test-trace-2', + 'input' => ['prompt' => 'What is the weather?'], + 'output' => ['response' => 'It is sunny.'], + 'sessionId' => 'session-abc123', + 'release' => '1.0.0', + 'version' => 'v1', + 'userId' => 'user-456', + 'metadata' => null, + 'tags' => ['development'], + 'public' => true, + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T09:15:00.000Z', + 'updatedAt' => '2025-01-22T09:15:00.000Z', + 'externalId' => 'ext-001', + 'totalCost' => 0.0012, + 'latency' => 0.8, + 'htmlPath' => '/traces/trace-def456', + 'environment' => 'development', + ], + ], + 'meta' => [ + 'page' => 1, + 'limit' => 10, + 'totalPages' => 1, + 'totalItems' => 2, + ], + ]; + } +} diff --git a/src/Testing/Responses/GetTraceResponse.php b/src/Testing/Responses/GetTraceResponse.php new file mode 100644 index 0000000..d089e1f --- /dev/null +++ b/src/Testing/Responses/GetTraceResponse.php @@ -0,0 +1,63 @@ +|string> $headers + * @param array $data + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null, array $data = []) + { + parent::__construct($status, $headers, (string) json_encode($this->payload($data)), $version, $reason); + } + + /** + * @param array $data + * @return array + */ + public function payload(array $data = []): array + { + return array_merge([ + 'id' => 'trace-abc123', + 'timestamp' => '2025-01-22T10:30:00.000Z', + 'name' => 'test-trace', + 'input' => ['prompt' => 'Hello, world!'], + 'output' => ['response' => 'Hi there!'], + 'sessionId' => 'session-xyz789', + 'release' => '1.0.0', + 'version' => 'v1', + 'userId' => 'user-123', + 'metadata' => ['key' => 'value'], + 'tags' => ['production', 'test'], + 'public' => false, + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T10:30:00.000Z', + 'updatedAt' => '2025-01-22T10:30:00.000Z', + 'externalId' => null, + 'totalCost' => 0.0025, + 'latency' => 1.5, + 'observations' => [ + [ + 'id' => 'obs-001', + 'name' => 'generation-1', + 'type' => 'GENERATION', + ], + ], + 'scores' => [ + [ + 'id' => 'score-001', + 'name' => 'quality', + 'value' => 0.95, + ], + ], + 'htmlPath' => '/traces/trace-abc123', + 'environment' => 'production', + ], $data); + } +} diff --git a/src/Trace.php b/src/Trace.php new file mode 100644 index 0000000..db61422 --- /dev/null +++ b/src/Trace.php @@ -0,0 +1,143 @@ +transporter->get( + uri: sprintf('/api/public/traces/%s', urlencode($traceId)), + ); + + /** @var array{ + * id: string, + * timestamp: string, + * name: string|null, + * input: mixed, + * output: mixed, + * sessionId: string|null, + * release: string|null, + * version: string|null, + * userId: string|null, + * metadata: mixed, + * tags: array, + * public: bool|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * externalId: string|null, + * totalCost: float|null, + * latency: float|null, + * observations: array, + * scores: array, + * htmlPath: string|null, + * environment: string|null + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return TraceResponse::fromArray($data); + } + + /** + * List traces with optional filters. + * + * @param int|null $page Page number, starts at 1 + * @param int|null $limit Limit of items per page + * @param string|null $userId Filter by user ID + * @param string|null $name Filter by name + * @param string|null $sessionId Filter by session ID + * @param string|null $fromTimestamp Filter by minimum timestamp (ISO 8601) + * @param string|null $toTimestamp Filter by maximum timestamp (ISO 8601) + * @param string|null $orderBy Order by field (e.g., "timestamp") + * @param array|null $tags Filter by tags + * @param string|null $version Filter by version + * @param string|null $release Filter by release + * @param array|null $environment Filter by environment + * @param string|null $fields Select fields to return + * @param string|null $filter Advanced filter expression + * + * @throws JsonException + */ + public function list( + ?int $page = null, + ?int $limit = null, + ?string $userId = null, + ?string $name = null, + ?string $sessionId = null, + ?string $fromTimestamp = null, + ?string $toTimestamp = null, + ?string $orderBy = null, + ?array $tags = null, + ?string $version = null, + ?string $release = null, + ?array $environment = null, + ?string $fields = null, + ?string $filter = null, + ): TraceListResponse { + $response = $this->transporter->get( + uri: '/api/public/traces', + options: ['query' => array_filter([ + 'page' => $page, + 'limit' => $limit, + 'userId' => $userId, + 'name' => $name, + 'sessionId' => $sessionId, + 'fromTimestamp' => $fromTimestamp, + 'toTimestamp' => $toTimestamp, + 'orderBy' => $orderBy, + 'tags' => $tags, + 'version' => $version, + 'release' => $release, + 'environment' => $environment, + 'fields' => $fields, + 'filter' => $filter, + ])] + ); + + /** @var array{ + * data: array, + * public: bool|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * externalId: string|null, + * totalCost: float|null, + * latency: float|null, + * htmlPath: string|null, + * environment: string|null + * }>, + * meta: array{page: int, limit: int, totalPages: int, totalItems: int} + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return TraceListResponse::fromArray($data); + } +} diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index 319cf49..a2b734b 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -21,8 +21,7 @@ class HttpTransporter implements TransporterInterface { public function __construct( public readonly ClientInterface $client - ) { - } + ) {} /** * @throws BadRequestException @@ -45,7 +44,7 @@ public function request(string $method, string $uri, array $options = []): Respo } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -61,7 +60,7 @@ public function get(string $uri, array $options = []): ResponseInterface } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -82,7 +81,7 @@ public function postJson(string $uri, array $data = [], array $options = []): Re } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -98,8 +97,8 @@ public function delete(string $uri, array $options = []): ResponseInterface } /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options * * @throws BadRequestException * @throws ForbiddenException diff --git a/tests/Feature/TraceTest.php b/tests/Feature/TraceTest.php new file mode 100644 index 0000000..cf66e5c --- /dev/null +++ b/tests/Feature/TraceTest.php @@ -0,0 +1,115 @@ + $handlerStack]); + + $trace = (new Langfuse(new HttpTransporter($client))) + ->trace() + ->get('trace-abc123'); + + expect($trace)->toBeInstanceOf(TraceResponse::class) + ->and($trace->id)->toBe('trace-abc123') + ->and($trace->name)->toBe('test-trace') + ->and($trace->sessionId)->toBe('session-xyz789') + ->and($trace->userId)->toBe('user-123') + ->and($trace->projectId)->toBe('proj-abc123') + ->and($trace->tags)->toBe(['production', 'test']) + ->and($trace->totalCost)->toBe(0.0025) + ->and($trace->latency)->toBe(1.5) + ->and($trace->observations)->toBeArray() + ->and($trace->scores)->toBeArray() + ->and($trace->environment)->toBe('production'); +}); + +it('can list traces', function (): void { + $mock = new MockHandler([ + new GetTraceListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $traces = (new Langfuse(new HttpTransporter($client))) + ->trace() + ->list(); + + expect($traces)->toBeInstanceOf(TraceListResponse::class) + ->and($traces->data)->toBeArray() + ->and($traces->data)->toHaveCount(2) + ->and($traces->data[0])->toBeInstanceOf(TraceResponse::class) + ->and($traces->data[0]->id)->toBe('trace-abc123') + ->and($traces->data[0]->name)->toBe('test-trace-1') + ->and($traces->data[1]->id)->toBe('trace-def456') + ->and($traces->data[1]->name)->toBe('test-trace-2') + ->and($traces->meta)->toBeInstanceOf(MetaData::class) + ->and($traces->meta->page)->toBe(1) + ->and($traces->meta->limit)->toBe(10) + ->and($traces->meta->totalItems)->toBe(2); +}); + +it('can list traces with filters', function (): void { + $mock = new MockHandler([ + new GetTraceListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $traces = (new Langfuse(new HttpTransporter($client))) + ->trace() + ->list( + page: 1, + limit: 10, + userId: 'user-123', + name: 'test-trace', + sessionId: 'session-xyz789', + environment: ['production'], + ); + + expect($traces)->toBeInstanceOf(TraceListResponse::class) + ->and($traces->data)->toBeArray(); +}); + +it('can get trace with observations and scores', function (): void { + $mock = new MockHandler([ + new GetTraceResponse(), + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $trace = (new Langfuse(new HttpTransporter($client))) + ->trace() + ->get('trace-abc123'); + + expect($trace->observations)->toBeArray() + ->and($trace->observations)->not->toBeEmpty() + ->and($trace->scores)->toBeArray() + ->and($trace->scores)->not->toBeEmpty(); + + /** @var array{id: string} $observation */ + $observation = $trace->observations[0]; + expect($observation['id'])->toBe('obs-001'); + + /** @var array{id: string} $score */ + $score = $trace->scores[0]; + expect($score['id'])->toBe('score-001'); +});