Skip to content

feat(telemetry): GenAI OpenTelemetry spans on _predict and _stream#270

Merged
Kamilbenkirane merged 6 commits into
mainfrom
feat/otel-instrumentation
May 4, 2026
Merged

feat(telemetry): GenAI OpenTelemetry spans on _predict and _stream#270
Kamilbenkirane merged 6 commits into
mainfrom
feat/otel-instrumentation

Conversation

@Kamilbenkirane

@Kamilbenkirane Kamilbenkirane commented May 4, 2026

Copy link
Copy Markdown
Member

Summary

Native OpenTelemetry GenAI v1.41.0 spans on every ModalityClient._predict and _stream call. Provider-SDK instrumentation packages (OpenInference, openllmetry, opentelemetry-instrumentation-openai-v2) cannot reach Celeste calls because Celeste bypasses provider SDKs; the right home for native LLM-semantic spans is celeste-python itself.

Closes #206.

What changed

  • New src/celeste/telemetry.py (~170 LOC) with lazy OTel import + private no-op fallback, GenAI provider/modality enum maps, and four pure helpers (request_attributes, span_name, output_attributes, trace_stream).
  • src/celeste/client.py:ModalityClient._predict — body wrapped in with telemetry.tracer.start_as_current_span(...) as span:. OTel's context manager auto-handles exception recording, status, and span end. Output attributes set on success before return.
  • src/celeste/client.py:ModalityClient._stream — detached span via tracer.start_span(...), then telemetry.trace_stream(sse_iterator, span) wraps the SSE iterator with use_span(span, end_on_exit=True) for clean lifecycle (completion / exception / GeneratorExit abandonment, no try/except in our code).
  • pyproject.toml — optional [otel] extra (opentelemetry-api>=1.30); per-file ANN401 ignore for **/telemetry.py mirroring the existing **/client.py ignore (forwarding shims need Any).
  • .github/workflows/ci.yml — type-check job installs [otel] extra so mypy sees the real OTel types (mirrors the existing [gcp] install in the test job).

Verified — real Gemini call

{
    "name": "chat gemini-3.1-flash-lite-preview",
    "attributes": {
        "gen_ai.request.model": "gemini-3.1-flash-lite-preview",
        "gen_ai.provider.name": "gcp.gemini",
        "gen_ai.operation.name": "chat",
        "gen_ai.usage.input_tokens": 13,
        "gen_ai.usage.output_tokens": 1,
        "gen_ai.response.finish_reasons": ["STOP"]
    }
}

Out of scope (future)

  • Content events (gen_ai.input.messages/output.messages, env-var gated).
  • Metrics histograms (gen_ai.client.token.usage, gen_ai.client.operation.duration).
  • operation: Operation kwarg on _predict/_stream for off-spec span fidelity (image edit vs generate, audio speak, video edit).

Behavior matrix

Install TracerProvider Result
celeste-ai (no extra) n/a helpers route to private no-op classes. Zero overhead.
celeste-ai[otel] no OTel's ProxyTracer returns NonRecordingSpan. Zero overhead.
celeste-ai[otel] yes Spans emitted with gen_ai.* attributes per spec.

Test plan

  • uv sync --all-extras
  • uv run mypy -p celeste clean (333 source files)
  • uv run ruff check src/celeste clean
  • uv run pytest tests/unit_tests/ -m "not integration" — 604 passed
  • Manual smoke against Gemini with ConsoleSpanExporter (output above)

celeste-python now emits GenAI v1.41 semantic-convention spans on every
ModalityClient._predict and ._stream call when a TracerProvider is
configured. No changes to public API; default installs gain zero new
deps; spans no-op when no provider is set.

- New src/celeste/telemetry.py: lazy OTel import + no-op fallback,
  provider/modality maps, request_attributes, span_name,
  output_attributes, trace_stream async generator
- client.py _predict: wrap body in tracer.start_as_current_span; OTel's
  context manager handles exception/lifecycle
- client.py _stream: detached span + use_span(end_on_exit=True) inside
  the iterator wrapper; records time_to_first_chunk on first chunk
- pyproject: optional [otel] extra adding opentelemetry-api>=1.30;
  per-file ANN401 ignore for telemetry.py (mirrors OTel API surface)

Closes #269.
Add a 'Tracing with OpenTelemetry' README section covering:
- uv add 'celeste-ai[otel]'
- minimal TracerProvider + OTLP/Console exporter snippet
- expected span name and attribute schema
- OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental opt-in
- attribute drift caveat until GenAI semconv stabilizes
@claude

claude Bot commented May 4, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

CI runs mypy without the [otel] extra installed, so 'from opentelemetry
import trace' fails the type check even though the runtime import is
guarded by try/except. Mirror the existing pattern used for httpx /
httpx_sse / google.* / etc.
…y ignore

The previous fix (`ignore_missing_imports` for opentelemetry.*) was a
workaround that disabled real type-checking on OTel symbols. The
opentelemetry-api package ships py.typed and is fully type-checkable
when installed.

Add opentelemetry-api to the dev dependency group so CI's mypy sees the
real types. Drop the ignore_missing_imports override.
Earlier commit moved opentelemetry-api into the dev group as a
workaround. Cleaner: mirror the existing 'extras-per-job' pattern (the
test job already does `uv sync --extra gcp`). The type-check job now
`uv sync --extra otel` so mypy sees the real OTel types.

Reverts the dev-group addition.
@Kamilbenkirane Kamilbenkirane merged commit 9c8f330 into main May 4, 2026
11 checks passed
Kamilbenkirane added a commit that referenced this pull request May 5, 2026
…ntent events (#273)

* feat(telemetry): widen usage attrs, add metrics histograms, opt-in content events

V2 telemetry expansion bundling three improvements on top of #270/#272:

1. Span attributes: output_attributes() now emits gen_ai.usage.total_tokens,
   reasoning_tokens, and cached_input_tokens when the typed Usage carries them.
   Off-spec modality fields fall through to celeste.usage.<field>. Adds the
   missing cached_tokens field to TextUsage so the data already produced by
   anthropic / openai / cohere / deepseek / openresponses providers actually
   reaches the span (previously dropped silently). Extends Google Gemini and
   chatcompletions provider mixins to surface cached-prompt tokens too.

2. Metrics: registers two GenAI semconv histograms — gen_ai.client.token.usage
   (one record per token category via gen_ai.token.type dimension) and
   gen_ai.client.operation.duration (with error.type on failures). Sampling-
   resilient token-rate and latency dashboards no longer require span scanning.
   Wired into _predict and _TracedStream._finalize.

3. Content events: opt-in via OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
   env flag, default off for PII safety. When enabled, spans carry
   gen_ai.input.messages and gen_ai.output.messages events with the semconv
   {role, parts: [...]} shape. Text + reasoning + tool calls inline; multimodal
   artifacts (image/audio/video/document) by URL reference, never inline bytes.

19 new tests across unit_tests/test_telemetry_metrics.py and
test_telemetry_content_events.py, plus extended attribute coverage in
test_telemetry_streaming.py. 630 unit tests pass.

Closes #271.

* refactor(telemetry): UsageField enum keys, public event helpers, shared finalize

Three small cleanups on top of the V2 telemetry expansion, no behavior change:

1. `_GEN_AI_USAGE_FIELDS` and `_GEN_AI_TOKEN_TYPES` now key on `UsageField` enum
   members instead of bare strings. Catches typos at import; aligns with the
   vocabulary providers already populate.

2. New public `add_input_event(span, inputs)` and `add_output_event(span, output)`
   helpers replace the cross-module calls to private `_input_messages_event` /
   `_output_messages_event`. Underscore-prefixed names stay as the dict-builder
   primitives; the public helpers handle `if event is not None: span.add_event(...)`.

3. New `record_output(span, output, attributes, duration, error=None)` extracts
   the four-step finalize sequence (set output_attributes + emit content event +
   record token usage + record duration) shared by `_predict` and
   `_TracedStream._finalize`.

Also: drop dead `hasattr(type(usage), "model_fields")` guards (Usage is always
Pydantic BaseModel by io.py contract); skip `bool` in the numeric usage iter
(bool is a subclass of int — paranoid guard against ever populating a Usage
boolean by accident).

630 tests still pass.

* fix(telemetry): sync iteration drives the wrapper's __anext__, not the inner Stream's

The previous `_TracedStream.__iter__` delegated to `iter(self._inner)`, which
makes Python iterate the inner Stream directly via its blocking-portal
`__iter__`. That bypasses the wrapper's `__anext__` — so for sync streaming
consumers, the span never finalized, TTFC was never recorded, and metrics
were never emitted.

Fix: `_TracedStream.__iter__` now spins its own portal and drives
`self.__anext__` (the wrapper's), mirroring `Stream.__iter__`. On
exhaustion, exception, or generator-close (consumer break / GC), the
finally block calls `self.aclose()` which finalizes the span via the
existing async path.

Caught by smoke-testing `celeste.text.sync.stream.generate(...)` against
a real Groq llama-3.1-8b-instant call: previously the span was missing
from finished spans entirely; now it appears alongside the async-streaming
span with the same attribute set.

* refactor: dedupe telemetry test fixtures, fix Windows time.monotonic flake

Three review agents flagged the +800 LOC PR for: triplicated test stream
classes, redundant fixtures, parametrizable repetition, and one Windows-fragile
timing assertion. Applied:

- New `tests/unit_tests/_telemetry_helpers.py` holds the canonical
  `TelemetryUsage` / `TelemetryOutput` / `TelemetryStream` / `async_iter`
  used by all three telemetry test files.
- New `tests/unit_tests/conftest.py` exposes the shared `exporter` fixture
  + `start_test_span` helper.
- `test_telemetry_metrics.py` parametrizes `record_token_usage` (3 cases)
  and `record_operation_duration` (success / failure). Drops the redundant
  `test_input_and_output_recorded_separately` (subsumed by the parametrized
  `all_token_types` case).
- `test_telemetry_metrics.py:191` now asserts `>= 0` on duration sum —
  Windows `time.monotonic()` resolution can return 0 for sub-millisecond
  in-memory streams; the previous `> 0` failed CI on Windows runners.
- `telemetry.py`: drop `add_output_event` (only ever called by
  `record_output` — inlined). Trim verbose comments above
  `_GEN_AI_USAGE_FIELDS`, `_GEN_AI_TOKEN_TYPES`, `_CAPTURE_CONTENT`,
  and the two messages-event helpers down to one line each.

Net: -210 modified LOC, +72 new helper LOC.
PR diff drops from +861 to ~+725. Coverage unchanged (630 tests pass).

* refactor(telemetry): extract gen_ai_span context manager — drop try/except + import time from client

User pushed back on `_predict` wrapping its entire body in try/except just to
record `gen_ai.client.operation.duration` on the error path. The right home
for that is a context manager inside `telemetry.py`.

`gen_ai_span(model=, provider=, protocol=, modality=)` opens the span via
`tracer.start_as_current_span`, captures the start time on enter, and in
`finally` records the operation duration with `error.type` populated when
the body raised. It yields `(span, request_attrs)` so `_predict` can call
`add_input_event` and `record_output` against them.

Result:
- `_predict` is straight-line code: no try/except, no manual time math.
- `client.py` no longer imports `time`.
- `record_output` no longer takes `duration_seconds` / `error` params —
  duration is the span's lifecycle concern, not the output recorder's.
- Streaming side (`_TracedStream._finalize`) updates symmetrically:
  records duration directly, then calls the simplified `record_output`.

Real-call validated against gemini-3.1-flash-lite-preview text non-streaming
+ async streaming: same span attributes, same metric histograms, same
content events as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make usage compatible with OpenTelemetry ?

1 participant