Skip to content

feat(otel): GenAI semconv metrics and richer spans (ellmer port) - #470

Open
cpsievert wants to merge 10 commits into
provider-base-urlfrom
otel-genai-semconv
Open

cpsievert wants to merge 10 commits into
provider-base-urlfrom
otel-genai-semconv

Conversation

@cpsievert

@cpsievert cpsievert commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Ports the OpenTelemetry stack ellmer merged on 2026-10-20: tidyverse/ellmer#1143, tidyverse/ellmer#1147, tidyverse/ellmer#1148, tidyverse/ellmer#1149, tidyverse/ellmer#1174, tidyverse/ellmer#1175, plus the prerequisites they build on (tidyverse/ellmer#1142 time-to-first-token, tidyverse/ellmer#1160 Content tool results as otel parts).

Summary

Metrics alongside spans (all from the GenAI semantic conventions): gen_ai.client.inference.duration / operation.input_tokens / operation.output_tokens histograms, gen_ai.client.inference.usage.* monotonic counters (input/output/cache-read/reasoning tokens, tagged gen_ai.token.modality="unknown"), gen_ai.client.inference.time_to_first_chunk and time_per_output_chunk histograms for streams, gen_ai.execute_tool.duration, and gen_ai.invoke_agent.{duration,inference_calls,tool_calls}. Histograms are created with the semconv's advised explicit bucket boundaries when the installed opentelemetry-api supports explicit_bucket_boundaries_advisory (detected by signature, so older versions keep working). Instruments are created lazily from a module-level meter, so everything is a no-op without an SDK configured.

Richer spans. Chat spans now record server.address/server.port (via the new Provider.base_url), the request parameters actually sent (gen_ai.request.*, including gen_ai.request.stream and a best-effort reasoning level across OpenAI/Anthropic/completions/Google kwargs shapes), gen_ai.output.type for structured output, finish reasons (gen_ai.response.finish_reasons as a proper string[] — Python OTel supports array attributes where R's otel could not), cache-read/reasoning token counts, time to first chunk, and (with content capture enabled) gen_ai.tool.definitions, including the private structured-output tool used by Anthropic/Bedrock (new Provider.structured_output_tool() hook). gen_ai.provider.name now maps to the well-known semconv values. The invoke_agent span is now internal kind and records duration, inference/tool call counts, and token totals.

Reliability of statuses and errors. Successful chat spans get an OK status — but stamped lazily, only once turn assembly and cleanup have completed, because the SDK treats Ok as final (the spec's Ok > Error > Unset total order) and an earlier stamp would mask a late failure such as a truncated data_model response (regression tests included; ellmer's record_chat_otel_span_status() has the same latent masking and could use the same fix). Failed, cancelled (StreamController.cancel()), and interrupted (e.g. abandoned streams, keyboard interrupt) requests now report an error status and error.type on spans and the duration metrics; the sync agent loop now closes its inner generator deterministically (mirroring the async path) so abandoned streams unwind child-before-parent on all Python implementations. Tool results holding Content values (e.g. images) convert to otel parts instead of stringifying.

Also: Provider.value_reasoning_tokens() hook (OpenAI Responses + Completions implementations).

Verification

19 new OpenTelemetry tests in tests/test_otel.py (metrics via InMemoryMetricReader, span attributes, span kinds, cancel/interrupt/error paths, tool definitions, structured-output tool modes, advised bucket boundaries) plus updates to the existing suite. Full test suite (1370 passed), ruff, and pyright pass locally; docs/get-started/monitor.qmd gains a Metrics section and updated attribute lists.

Deliberate divergences from ellmer

  • gen_ai.response.finish_reasons is a proper string[] (ellmer emits a scalar, blocked on No way to record a length-1 character vector as an array attribute r-lib/otel#48).
  • Request params are recorded before the request, so failed requests carry them too (ellmer records after chat_perform succeeds; chatlas drops unsupported params at set-time, so no request-time filtering is needed).
  • Tool definitions include user tools even when data_model is set (chatlas sends both in the request; ellmer suppresses user tools for structured-output requests).

@cpsievert
cpsievert marked this pull request as ready for review October 20, 2026 21:50
@cpsievert
cpsievert added this pull request to stack #481 October 21, 2026 15:41
cpsievert and others added 8 commits October 21, 2026 11:38
Ports the OpenTelemetry stack ellmer merged on 2026-10-20
(tidyverse/ellmer#1143, #1147, #1148, #1149, #1174, #1175, building on
#1142 and #1160):

- Metrics alongside spans: gen_ai.client.inference.* histograms (duration,
  input/output tokens, time to first chunk, time per output chunk) and
  monotonic usage counters (input/output/cache-read/reasoning tokens,
  tagged with gen_ai.token.modality), gen_ai.execute_tool.duration, and
  gen_ai.invoke_agent.{duration,inference_calls,tool_calls}.
- Richer chat spans: server.address/server.port, gen_ai.request.stream,
  gen_ai.output.type, request params actually sent (gen_ai.request.*,
  including reasoning effort on a best-effort basis), finish reasons
  (gen_ai.response.finish_reasons as a proper string array), cache-read
  and reasoning token counts, time-to-first-chunk, and (with content
  capture enabled) gen_ai.tool.definitions, including the private
  structured-output tool used by Anthropic/Bedrock.
- Well-known gen_ai.provider.name values (e.g. "anthropic", "openai",
  "gcp.gemini") via a display-name mapping.
- invoke_agent is now an internal-kind span and records duration,
  inference/tool call counts, and total token usage as attributes.
- Successful requests are marked with an OK span status; failed,
  cancelled (StreamController.cancel()), and interrupted (e.g. abandoned
  streams, keyboard interrupt) requests report an error status and
  error.type on spans and the duration metrics. Tool results holding
  Content values (e.g. images) now convert to otel parts instead of
  stringifying.

Provider gains value_reasoning_tokens() (OpenAI Responses + Completions)
and structured_output_tool() (Anthropic, Bedrock Converse) hooks to feed
the new attributes.
…fail

The OTel SDK treats Ok as final (the spec's Ok > Error > Unset total
order): it overrides any prior Error and ignores any later one. Recording
the OK status before the final turn was built meant a late failure --
e.g. a truncated data_model response raising during turn assembly --
produced a self-contradictory span: status OK alongside an exception
event, error.type, and an "error" finish reason.

The OK stamp now happens at the end of the request path, gated on a
flag set only after turn assembly and response cleanup complete, so a
late error always lands. record_chat_span_status() keeps the ellmer
structure (metrics + attributes + error status for cancelled requests)
but defers the success stamp to record_chat_span_ok(). ellmer has the
same latent masking in record_chat_otel_span_status().
…ised histogram buckets

Follow-ups on top of Provider.base_url:

- server.address/server.port now come from Provider.base_url uniformly,
  covering Google, Bedrock (all flavors), Posit, Databricks, and
  Snowflake in addition to the OpenAI/Anthropic SDK clients.
- gen_ai.request.reasoning.level also recognizes Google's
  config.thinking_config.thinking_level (ThinkingLevel enum).
- Histograms are created with the GenAI semconv's advised explicit bucket
  boundaries (durations 0.01..81.92s, agent duration 0.1..409.6s, token
  counts 1..67108864, call counts 1..128) when the installed
  opentelemetry-api supports explicit_bucket_boundaries_advisory;
  detected by signature so older API versions keep working with SDK
  default buckets.
botocore reads region from AWS config/AWS_DEFAULT_REGION, not AWS_REGION,
so the test failed in config-less CI environments.
…irectly

Bumping the opentelemetry-api/sdk floor from 1.12 to 1.30 (the release that
added explicit_bucket_boundaries_advisory) lets the semconv-advised bucket
boundaries be passed unconditionally, dropping the inspect.signature
runtime probe and the **kwargs indirection in _get_histogram().

This branch has not been deployed

No deployments
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.

1 participant