Skip to content

feat: client-side OpenTelemetry traceparent propagation #20

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 — one in its APM, one in Ferro.

Must be done without adding a runtime dependency on OpenTelemetry — the SDK's httpx-only footprint is a feature. Use runtime detection (no-op when OTel isn't installed).

Already shipped (do not re-implement)

Response-side trace_id surfacing landed with langchain-ferrolabsai 0.1.0:

  • FerroClient._request reads x-trace-id / x-request-id headers and merges them into the parsed body, so ChatCompletion.trace_id / .provider / .latency_ms / .cost_usd are reliably populated (ferrolabsai/client.py, ferrolabsai/types.py).
  • FerroAPIError.request_id is populated from x-request-id.

This issue is the outbound propagation half.

In scope

  • New module ferrolabsai/observability/:
    • propagation.py — runtime OTel detection; W3C traceparent + tracestate injection.
    • attrs.pygen_ai.* attribute helpers for users instrumenting their own spans.
  • FerroClient._request and AsyncFerroClient._request call inject_headers(headers) before sending (no-op when no OTel SDK is loaded).
  • README "Observability and tracing" section + docs/architecture.md update.

Non-goals

  • ❌ Re-implementing response-side trace_id surfacing — already shipped.
  • ❌ Embedding any vendor SDK (LangSmith/Langfuse/Datadog) — those live as gateway plugins in ai-gateway-plugins.
  • ❌ Auto-creating client-side spans around calls — keeps the zero-dep promise; users wrap calls themselves (doc shows how).
  • ❌ Capturing trace_id / provider / latency_ms on ChatCompletionChunk (streaming) — tracked in Surface trace_id / provider / latency_ms on ChatCompletionChunk #17.

Acceptance criteria

  • ferrolabsai/observability/ exposes inject_headers(dict) -> dict and propagation_active() -> bool.
  • inject_headers is a no-op when opentelemetry.trace is not importable (test by monkey-patching sys.modules).
  • With OTel importable + an active span, inject_headers adds a valid traceparent per W3C Trace Context.
  • Both sync and async _request call inject_headers on every outbound request.
  • No new entry in [project].dependencies; the OTel SDK appears only under test extras.
  • mypy --strict passes.
  • pytest-httpx tests: no traceparent when OTel absent; matching traceparent when OTel + active span present.
  • README "Observability and tracing" runnable example.

Implementation sketch (zero-dependency runtime detection)

# ferrolabsai/observability/propagation.py
from __future__ import annotations

def propagation_active() -> bool:
    try:
        from opentelemetry import trace  # noqa: F401
    except ImportError:
        return False
    return True

def inject_headers(headers: dict[str, str]) -> dict[str, str]:
    if not propagation_active():
        return headers
    from opentelemetry import propagate  # local import keeps zero cost when off
    propagate.inject(headers)            # uses the globally configured TextMapPropagator
    return headers

Coordination

Repo Relationship
ai-gateway v1.1.0 Already honors inbound traceparent (custom OTel IDGenerator) — SDK-injected traceparent becomes the root span. Nothing to wait on. Telemetry contract: ferro.observability.v1 (docs/observability/schema.md).
ferrolabs-typescript-sdk Sibling issue, same shape. Naming: Python trace_id, TS traceId.
ai-gateway-cookbook Recipe 04-langsmith-tracing demonstrates the end-to-end story once both SDKs ship this.
ai-gateway-plugins (v1.2) observability/langsmith · langfuse · phoenix bridges are the consumer side of the joined trace.

Test plan

New tests/test_observability.py:

  • test_inject_headers_no_op_when_otel_missing
  • test_inject_headers_uses_active_span_when_otel_loaded
  • test_async_client_propagates_traceparent

Metadata

Metadata

Assignees

No one assigned

    Labels

    asyncAsync clientenhancementNew feature or requestobservabilityTracing, metadata, trace_id

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions