Files
54bc84f3ed feat(ai): add $ai_completion_id and $ai_provider_metadata to OpenAI events (#3306)
* feat(ai): add $ai_completion_id and $ai_provider_metadata to OpenAI events

Adds two new auto-captured properties to `$ai_generation` events emitted by
the OpenAI and Azure OpenAI wrappers, enabling direct correlation between
PostHog events and OpenAI's Logs dashboard
(`platform.openai.com/logs/{completion_id}`):

- `$ai_completion_id` — provider-assigned response ID (e.g. `chatcmpl-…`,
  `resp_…`). Generalises across providers, so it lives at the top of the
  `$ai_*` namespace.
- `$ai_provider_metadata` — OpenAI-specific fields (`system_fingerprint`,
  `request_id`) collected under a single blob rather than polluting the
  shared schema. Sets the pattern for Anthropic / Gemini wrappers to follow.

Both options are accepted by the public `captureAiGeneration` primitive, so
external instrumentation produces identical events. Coverage spans
non-streaming and streaming Chat Completions plus Responses API
(`create` + `parse`) for both OpenAI and Azure OpenAI. Streaming paths
extract `id` / `system_fingerprint` from accumulated chunks; the
`x-request-id` header is read via the OpenAI SDK's semi-private
`_request_id` field through a small typed helper. Streaming
`request_id` capture (which needs `.withResponse()`) is left as a
follow-up — see review thread on #3306.

Addresses review feedback on #3306:
- restructure `system_fingerprint` / `request_id` under `$ai_provider_metadata`
- fix the TS2353 build error on the test mock by widening `ChatCompletion`
  with `{ _request_id?: string }`
- consolidate the duplicated `(result as any)._request_id` cast into
  `extractRequestId` with an explanatory comment in one place
- add Responses API `$ai_completion_id` / `request_id` test coverage

Co-authored-by: Hanzo Dev <[email protected]>

* fix(ai): address Greptile feedback on the revision

- Streaming error path now surfaces accumulated completion metadata.
  When a stream fails mid-flight after consuming chunks that carried
  `chunk.id` / `chunk.system_fingerprint`, the error event was being
  emitted without `$ai_completion_id` / `$ai_provider_metadata` — the
  exact correlation IDs this PR is meant to enable. Hoisted the
  accumulators above the `try` block and pass them in the `catch`
  capture for both `index.ts` and `azure.ts`, chat and Responses.

- Convert the `openai-utils` tests to parameterised `it.each` tables
  per the repo's "always prefer parameterised tests" convention; each
  input/output pair is now its own row so a failure points to the
  specific case that broke.

* fix(aiobs): align system_fingerprint guard with surrounding style

Task-Id: 672e907b-e741-4f0e-abf2-76722b296982

---------

Co-authored-by: Hanzo Dev <[email protected]>
Co-authored-by: Carlos Marchal <[email protected]>
Co-authored-by: Richard Solomou <[email protected]>
2026-05-29 12:00:40 +02:00

47 lines
1.9 KiB
TypeScript

import { extractRequestId, buildProviderMetadata } from '../src/openai/utils'
describe('extractRequestId', () => {
it.each<[name: string, input: unknown, expected: string | undefined]>([
['reads `_request_id` when present', { _request_id: 'req_abc123' }, 'req_abc123'],
['returns undefined when `_request_id` is absent', { id: 'chatcmpl-1' }, undefined],
['returns undefined when `_request_id` is null', { _request_id: null }, undefined],
['returns undefined for null input', null, undefined],
['returns undefined for undefined input', undefined, undefined],
['returns undefined for string input', 'not-an-object', undefined],
['returns undefined for numeric input', 42, undefined],
])('%s', (_name, input, expected) => {
expect(extractRequestId(input)).toBe(expected)
})
})
describe('buildProviderMetadata', () => {
it.each<
[
name: string,
input: Parameters<typeof buildProviderMetadata>[0],
expected: ReturnType<typeof buildProviderMetadata>,
]
>([
[
'includes both keys when both values are present',
{ systemFingerprint: 'fp_1', requestId: 'req_1' },
{ system_fingerprint: 'fp_1', request_id: 'req_1' },
],
[
'omits requestId when only systemFingerprint is present',
{ systemFingerprint: 'fp_1' },
{ system_fingerprint: 'fp_1' },
],
['omits systemFingerprint when only requestId is present', { requestId: 'req_1' }, { request_id: 'req_1' }],
['returns undefined for an empty input object', {}, undefined],
[
'returns undefined when both values are undefined',
{ systemFingerprint: undefined, requestId: undefined },
undefined,
],
['returns undefined when both values are null', { systemFingerprint: null, requestId: null }, undefined],
])('%s', (_name, input, expected) => {
expect(buildProviderMetadata(input)).toEqual(expected)
})
})