Skip to content

feat: client-side OpenTelemetry traceparent propagation #2

@MitulShah1

Description

@MitulShah1

Inject a W3C traceparent header on every outbound request so, when the calling app already runs OpenTelemetry with an active span, the gateway's root span becomes a child of the caller's span. Without this, an instrumented app sees two disconnected traces for one logical request.

Must be done without adding @opentelemetry/* to dependencies — the SDK stays tree-shakeable and edge-runtime-friendly. OTel comes from peerDependenciesMeta (optional: true); detect it at runtime via dynamic import().

Already shipped (do not re-implement)

Response-side surfacing exists: ChatCompletion carries trace_id / provider / latency_ms, and usage carries cost_usd / cache_hit / provider (see README "Observability"). This issue is the outbound propagation half only.

In scope

  • New module src/observability/:
    • propagation.ts — runtime detection of @opentelemetry/api; W3C traceparent (+ tracestate) injection.
    • attrs.tsgen_ai.* attribute key constants for users instrumenting their own spans.
  • HttpClient (src/_internal/http.ts) calls injectHeaders(headers) on every outbound fetch — no-op when @opentelemetry/api isn't installed.
  • README "Observability and tracing" propagation example + examples/observability.ts.

Non-goals

  • ❌ Re-implementing response-side surfacing — already shipped.
  • ❌ Embedding vendor SDKs (LangSmith/Langfuse/Datadog) — those are gateway plugins in ai-gateway-plugins.
  • ❌ Auto-creating client-side spans — users wrap calls themselves (doc shows how).

Acceptance criteria

  • src/observability/ exports injectHeaders(headers: Record<string,string>): Record<string,string> and propagationActive(): boolean.
  • injectHeaders returns input unchanged when @opentelemetry/api is not importable at runtime (test via Vitest vi.doMock).
  • With OTel loaded + active span, a valid W3C traceparent is added.
  • HttpClient calls injectHeaders on every outbound request.
  • No new entry in dependencies; @opentelemetry/api only in peerDependenciesMeta (optional: true). Package keeps "sideEffects": false.
  • tsc --noEmit, ESLint, Prettier all clean.
  • Edge-runtime smoke: injectHeaders does not throw when process is undefined.

Implementation sketch (zero-dependency runtime detection)

let cachedApi: typeof import("@opentelemetry/api") | null | undefined;
async function loadOtel() {
  if (cachedApi !== undefined) return cachedApi;
  try { cachedApi = await import("@opentelemetry/api"); } catch { cachedApi = null; }
  return cachedApi;
}
export async function injectHeaders(headers: Record<string, string>): Promise<Record<string, string>> {
  const otel = await loadOtel();
  if (!otel) return headers;
  otel.propagation.inject(otel.context.active(), headers);
  return headers;
}

Mirror of ferrolabs-python-sdk#20 (Python trace_id ↔ TS trace_id).

Metadata

Metadata

Assignees

No one assigned

    Labels

    asyncAsync / runtime behaviorenhancementNew feature or requestobservabilityTracing, metadata, trace_id

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions