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..462498f 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 observation(): Observation + { + return new Observation( + transporter: $this->transporter, + ); + } } diff --git a/src/Observation.php b/src/Observation.php new file mode 100644 index 0000000..27f6955 --- /dev/null +++ b/src/Observation.php @@ -0,0 +1,138 @@ +transporter->get( + uri: sprintf('/api/public/observations/%s', urlencode($observationId)), + ); + + /** @var array{ + * id: string, + * traceId: string|null, + * type: string, + * name: string|null, + * startTime: string, + * endTime: string|null, + * completionStartTime: string|null, + * model: string|null, + * modelParameters: array|null, + * input: mixed, + * output: mixed, + * metadata: mixed, + * version: string|null, + * parentObservationId: string|null, + * level: string|null, + * statusMessage: string|null, + * promptId: string|null, + * promptName: string|null, + * promptVersion: int|null, + * usage: array{input: int|null, output: int|null, total: int|null, unit: string|null, inputCost: float|null, outputCost: float|null, totalCost: float|null}|null, + * calculatedInputCost: float|null, + * calculatedOutputCost: float|null, + * calculatedTotalCost: float|null, + * latency: float|null, + * timeToFirstToken: float|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * environment: string|null + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return ObservationResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function list( + ?int $page = null, + ?int $limit = null, + ?string $name = null, + ?string $userId = null, + ?string $type = null, + ?string $traceId = null, + ?string $level = null, + ?string $parentObservationId = null, + ?array $environment = null, + ?string $fromStartTime = null, + ?string $toStartTime = null, + ?string $version = null, + ?string $filter = null, + ): ObservationListResponse { + $response = $this->transporter->get( + uri: '/api/public/observations', + options: ['query' => array_filter([ + 'page' => $page, + 'limit' => $limit, + 'name' => $name, + 'userId' => $userId, + 'type' => $type, + 'traceId' => $traceId, + 'level' => $level, + 'parentObservationId' => $parentObservationId, + 'environment' => $environment, + 'fromStartTime' => $fromStartTime, + 'toStartTime' => $toStartTime, + 'version' => $version, + 'filter' => $filter, + ])] + ); + + /** @var array{ + * data: array|null, + * input: mixed, + * output: mixed, + * metadata: mixed, + * version: string|null, + * parentObservationId: string|null, + * level: string|null, + * statusMessage: string|null, + * promptId: string|null, + * promptName: string|null, + * promptVersion: int|null, + * usage: array{input: int|null, output: int|null, total: int|null, unit: string|null, inputCost: float|null, outputCost: float|null, totalCost: float|null}|null, + * calculatedInputCost: float|null, + * calculatedOutputCost: float|null, + * calculatedTotalCost: float|null, + * latency: float|null, + * timeToFirstToken: float|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * 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 ObservationListResponse::fromArray($data); + } +} diff --git a/src/Responses/ObservationListResponse.php b/src/Responses/ObservationListResponse.php new file mode 100644 index 0000000..1502af2 --- /dev/null +++ b/src/Responses/ObservationListResponse.php @@ -0,0 +1,62 @@ + $data + */ + public function __construct( + public array $data, + public MetaData $meta, + ) {} + + /** + * @param array{ + * data: array|null, + * input: mixed, + * output: mixed, + * metadata: mixed, + * version: string|null, + * parentObservationId: string|null, + * level: string|null, + * statusMessage: string|null, + * promptId: string|null, + * promptName: string|null, + * promptVersion: int|null, + * usage?: array{input: int|null, output: int|null, total: int|null, unit: string|null, inputCost: float|null, outputCost: float|null, totalCost: float|null}|null, + * calculatedInputCost?: float|null, + * calculatedOutputCost?: float|null, + * calculatedTotalCost?: float|null, + * latency?: float|null, + * timeToFirstToken?: float|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * 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): ObservationResponse => ObservationResponse::fromArray($item), $data['data']), + meta: MetaData::fromArray($data['meta']), + ); + } +} diff --git a/src/Responses/ObservationResponse.php b/src/Responses/ObservationResponse.php new file mode 100644 index 0000000..9345caf --- /dev/null +++ b/src/Responses/ObservationResponse.php @@ -0,0 +1,112 @@ +|null $modelParameters + * @param array{input: int|null, output: int|null, total: int|null, unit: string|null, inputCost: float|null, outputCost: float|null, totalCost: float|null}|null $usage + */ + public function __construct( + public string $id, + public ?string $traceId, + public string $type, + public ?string $name, + public string $startTime, + public ?string $endTime, + public ?string $completionStartTime, + public ?string $model, + public ?array $modelParameters, + public mixed $input, + public mixed $output, + public mixed $metadata, + public ?string $version, + public ?string $parentObservationId, + public ?string $level, + public ?string $statusMessage, + public ?string $promptId, + public ?string $promptName, + public ?int $promptVersion, + public ?array $usage, + public ?float $calculatedInputCost, + public ?float $calculatedOutputCost, + public ?float $calculatedTotalCost, + public ?float $latency, + public ?float $timeToFirstToken, + public string $projectId, + public string $createdAt, + public string $updatedAt, + public ?string $environment = null, + ) {} + + /** + * @param array{ + * id: string, + * traceId: string|null, + * type: string, + * name: string|null, + * startTime: string, + * endTime: string|null, + * completionStartTime: string|null, + * model: string|null, + * modelParameters: array|null, + * input: mixed, + * output: mixed, + * metadata: mixed, + * version: string|null, + * parentObservationId: string|null, + * level: string|null, + * statusMessage: string|null, + * promptId: string|null, + * promptName: string|null, + * promptVersion: int|null, + * usage?: array{input: int|null, output: int|null, total: int|null, unit: string|null, inputCost: float|null, outputCost: float|null, totalCost: float|null}|null, + * calculatedInputCost?: float|null, + * calculatedOutputCost?: float|null, + * calculatedTotalCost?: float|null, + * latency?: float|null, + * timeToFirstToken?: float|null, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * environment?: string|null + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + traceId: $data['traceId'], + type: $data['type'], + name: $data['name'], + startTime: $data['startTime'], + endTime: $data['endTime'], + completionStartTime: $data['completionStartTime'], + model: $data['model'], + modelParameters: $data['modelParameters'], + input: $data['input'], + output: $data['output'], + metadata: $data['metadata'], + version: $data['version'], + parentObservationId: $data['parentObservationId'], + level: $data['level'], + statusMessage: $data['statusMessage'], + promptId: $data['promptId'], + promptName: $data['promptName'], + promptVersion: $data['promptVersion'], + usage: $data['usage'] ?? null, + calculatedInputCost: $data['calculatedInputCost'] ?? null, + calculatedOutputCost: $data['calculatedOutputCost'] ?? null, + calculatedTotalCost: $data['calculatedTotalCost'] ?? null, + latency: $data['latency'] ?? null, + timeToFirstToken: $data['timeToFirstToken'] ?? null, + projectId: $data['projectId'], + createdAt: $data['createdAt'], + updatedAt: $data['updatedAt'], + environment: $data['environment'] ?? null, + ); + } +} diff --git a/src/Testing/Responses/GetObservationListResponse.php b/src/Testing/Responses/GetObservationListResponse.php new file mode 100644 index 0000000..7bba8cf --- /dev/null +++ b/src/Testing/Responses/GetObservationListResponse.php @@ -0,0 +1,106 @@ +|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' => 'obs-abc123', + 'traceId' => 'trace-abc123', + 'type' => 'GENERATION', + 'name' => 'llm-generation-1', + 'startTime' => '2025-01-22T10:30:00.000Z', + 'endTime' => '2025-01-22T10:30:02.500Z', + 'completionStartTime' => '2025-01-22T10:30:00.500Z', + 'model' => 'gpt-4o', + 'modelParameters' => ['temperature' => 0.7], + 'input' => ['prompt' => 'Hello'], + 'output' => ['response' => 'Hi there!'], + 'metadata' => null, + 'version' => 'v1', + 'parentObservationId' => null, + 'level' => 'DEFAULT', + 'statusMessage' => null, + 'promptId' => null, + 'promptName' => null, + 'promptVersion' => null, + 'usage' => [ + 'input' => 10, + 'output' => 25, + 'total' => 35, + 'unit' => 'TOKENS', + 'inputCost' => 0.0001, + 'outputCost' => 0.0005, + 'totalCost' => 0.0006, + ], + 'calculatedInputCost' => 0.0001, + 'calculatedOutputCost' => 0.0005, + 'calculatedTotalCost' => 0.0006, + 'latency' => 2.5, + 'timeToFirstToken' => 0.5, + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T10:30:00.000Z', + 'updatedAt' => '2025-01-22T10:30:02.500Z', + 'environment' => 'production', + ], + [ + 'id' => 'obs-def456', + 'traceId' => 'trace-abc123', + 'type' => 'SPAN', + 'name' => 'retrieval-span', + 'startTime' => '2025-01-22T10:29:58.000Z', + 'endTime' => '2025-01-22T10:29:59.500Z', + 'completionStartTime' => null, + 'model' => null, + 'modelParameters' => null, + 'input' => ['query' => 'search term'], + 'output' => ['results' => [['id' => 1], ['id' => 2]]], + 'metadata' => ['source' => 'vector-db'], + 'version' => 'v1', + 'parentObservationId' => null, + 'level' => 'DEFAULT', + 'statusMessage' => null, + 'promptId' => null, + 'promptName' => null, + 'promptVersion' => null, + 'usage' => null, + 'calculatedInputCost' => null, + 'calculatedOutputCost' => null, + 'calculatedTotalCost' => null, + 'latency' => 1.5, + 'timeToFirstToken' => null, + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T10:29:58.000Z', + 'updatedAt' => '2025-01-22T10:29:59.500Z', + 'environment' => 'production', + ], + ], + 'meta' => [ + 'page' => 1, + 'limit' => 10, + 'totalPages' => 1, + 'totalItems' => 2, + ], + ]; + + } +} diff --git a/src/Testing/Responses/GetObservationResponse.php b/src/Testing/Responses/GetObservationResponse.php new file mode 100644 index 0000000..6935bb5 --- /dev/null +++ b/src/Testing/Responses/GetObservationResponse.php @@ -0,0 +1,66 @@ +|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' => 'obs-abc123', + 'traceId' => 'trace-abc123', + 'type' => 'GENERATION', + 'name' => 'llm-generation', + 'startTime' => '2025-01-22T10:30:00.000Z', + 'endTime' => '2025-01-22T10:30:02.500Z', + 'completionStartTime' => '2025-01-22T10:30:00.500Z', + 'model' => 'gpt-4o', + 'modelParameters' => ['temperature' => 0.7, 'max_tokens' => 1000], + 'input' => ['messages' => [['role' => 'user', 'content' => 'Hello']]], + 'output' => ['content' => 'Hi there! How can I help you?'], + 'metadata' => ['key' => 'value'], + 'version' => 'v1', + 'parentObservationId' => null, + 'level' => 'DEFAULT', + 'statusMessage' => null, + 'promptId' => 'prompt-123', + 'promptName' => 'greeting-prompt', + 'promptVersion' => 1, + 'usage' => [ + 'input' => 10, + 'output' => 25, + 'total' => 35, + 'unit' => 'TOKENS', + 'inputCost' => 0.0001, + 'outputCost' => 0.0005, + 'totalCost' => 0.0006, + ], + 'calculatedInputCost' => 0.0001, + 'calculatedOutputCost' => 0.0005, + 'calculatedTotalCost' => 0.0006, + 'latency' => 2.5, + 'timeToFirstToken' => 0.5, + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T10:30:00.000Z', + 'updatedAt' => '2025-01-22T10:30:02.500Z', + 'environment' => 'production', + ], $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/ObservationTest.php b/tests/Feature/ObservationTest.php new file mode 100644 index 0000000..4c115a1 --- /dev/null +++ b/tests/Feature/ObservationTest.php @@ -0,0 +1,126 @@ + $handlerStack]); + + $observation = (new Langfuse(new HttpTransporter($client))) + ->observation() + ->get('obs-abc123'); + + expect($observation)->toBeInstanceOf(ObservationResponse::class) + ->and($observation->id)->toBe('obs-abc123') + ->and($observation->traceId)->toBe('trace-abc123') + ->and($observation->type)->toBe('GENERATION') + ->and($observation->name)->toBe('llm-generation') + ->and($observation->model)->toBe('gpt-4o') + ->and($observation->latency)->toBe(2.5) + ->and($observation->environment)->toBe('production'); +}); + +it('can list observations', function (): void { + $mock = new MockHandler([ + new GetObservationListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $observations = (new Langfuse(new HttpTransporter($client))) + ->observation() + ->list(); + + expect($observations)->toBeInstanceOf(ObservationListResponse::class) + ->and($observations->data)->toBeArray() + ->and($observations->data)->toHaveCount(2) + ->and($observations->data[0])->toBeInstanceOf(ObservationResponse::class) + ->and($observations->data[0]->id)->toBe('obs-abc123') + ->and($observations->data[0]->type)->toBe('GENERATION') + ->and($observations->data[1]->id)->toBe('obs-def456') + ->and($observations->data[1]->type)->toBe('SPAN') + ->and($observations->meta)->toBeInstanceOf(MetaData::class) + ->and($observations->meta->totalItems)->toBe(2); +}); + +it('can list observations with filters', function (): void { + /** @var array $history */ + $history = []; + + $mock = new MockHandler([ + new GetObservationListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $handlerStack->push(Middleware::history($history)); + $client = new Client(['handler' => $handlerStack]); + + $observations = (new Langfuse(new HttpTransporter($client))) + ->observation() + ->list( + page: 1, + limit: 10, + traceId: 'trace-abc123', + type: 'GENERATION', + environment: ['production'], + ); + + expect($observations)->toBeInstanceOf(ObservationListResponse::class) + ->and($observations->data)->toBeArray(); + + expect($history)->toHaveCount(1); + + /** @var array{request: RequestInterface} $first */ + $first = $history[0]; + /** @var RequestInterface $request */ + $request = $first['request']; + $query = $request->getUri()->getQuery(); + + expect($query)->toContain('page=1') + ->and($query)->toContain('limit=10') + ->and($query)->toContain('traceId=trace-abc123') + ->and($query)->toContain('type=GENERATION'); +}); + +it('can get observation with usage data', function (): void { + $mock = new MockHandler([ + new GetObservationResponse(), + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $observation = (new Langfuse(new HttpTransporter($client))) + ->observation() + ->get('obs-abc123'); + + expect($observation->usage)->toBeArray() + ->and($observation->usage)->not->toBeEmpty() + ->and($observation->calculatedTotalCost)->toBe(0.0006) + ->and($observation->promptName)->toBe('greeting-prompt') + ->and($observation->promptVersion)->toBe(1); + + /** @var array{input: int, output: int, total: int} $usage */ + $usage = $observation->usage; + expect($usage['input'])->toBe(10) + ->and($usage['output'])->toBe(25) + ->and($usage['total'])->toBe(35); +});