Skip to content

feat: Support for OpenTelemetry GenAI semantic conventions#472

Merged
brainlid merged 36 commits into
brainlid:mainfrom
teacherspace:telemetry-enhance
Jul 7, 2026
Merged

feat: Support for OpenTelemetry GenAI semantic conventions#472
brainlid merged 36 commits into
brainlid:mainfrom
teacherspace:telemetry-enhance

Conversation

@vasspilka

@vasspilka vasspilka commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Addresses #467 by adding OpenTelemetry GenAI telemetry to LangChain, following the OpenTelemetry GenAI semantic conventions (v1.40+). LangChain already emitted :telemetry events; this PR layers an opt-in OpenTelemetry integration on top that turns those events into spans and metrics, with privacy-safe defaults — and fixes the telemetry surface so token usage actually reaches those spans across every provider and streaming.

Spans carry a rich subset of the convention: request parameters, token usage (including cache/reasoning breakdowns), finish reasons, streaming time-to-first-token, tool metadata, server.*, and conversation grouping — all emitted best-effort, only when the model actually sets them.

For risks note breaking changes below

OpenTelemetry is an optional dependency — if :opentelemetry_api isn't loaded, the integration module isn't even compiled and there's zero overhead.

What's added

New LangChain.OpenTelemetry integration (lib/open_telemetry/)

Module Responsibility
OpenTelemetry Entry point — setup/1, teardown/0, without_tracing/1. Attaches span + metrics handlers.
OpenTelemetry.Config Capture/feature config struct (all message capture defaults to false).
OpenTelemetry.SpanHandler Turns :telemetry lifecycle events into spans with parent/child hierarchy. Records streaming first-token as a span attribute + event. Traps its own exceptions so a bad payload can't detach tracing VM-wide.
OpenTelemetry.MetricsHandler Re-emits operation-duration, token-usage, and time-to-first-token metric events.
OpenTelemetry.Attributes Builds GenAI-convention span attributes — request params, usage (incl. cache/reasoning), finish reasons, server.*, tool metadata, conversation.id, and optional Langfuse context.
OpenTelemetry.MessageSerializer Serializes messages to JSON for gen_ai.*.messages attributes.
OpenTelemetry.ProviderMapping Maps LangChain provider strings to GenAI provider names.

Span attributes (GenAI semantic conventions subset)

Emitted best-effort — a value appears only when the model actually sets it.

  • Operation / identity: gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.agent.name, gen_ai.conversation.id
  • Request parameters: gen_ai.request.temperature, .max_tokens, .top_p, .top_k, .frequency_penalty, .presence_penalty, .seed, .choice.count, .stream, .stop_sequences, .reasoning.level; plus gen_ai.output.type ("json" for structured-output requests, else "text")
  • Server: server.address, server.port (derived from the model's request endpoint)
  • Usage: gen_ai.usage.input_tokens, .output_tokens; best-effort gen_ai.usage.cache_read.input_tokens, .cache_creation.input_tokens, .reasoning.output_tokens
  • Response: gen_ai.response.finish_reasons (reconstructed from the normalized message status)
  • Streaming: gen_ai.response.time_to_first_token (seconds) + a gen_ai.first_token span event on the LLM span, for providers that stream through the shared path
  • Tools: gen_ai.tool.name, .call.id, .type, .description

Telemetry surface

  • LangChain.Telemetry gained documented metadata (auto call_id correlation, provider, custom_context, token usage, last_message, request_options, output_type, endpoint) and explicit privacy guarantees.
  • Every in-tree chat model now reports its provider: 13 implement the new ChatModel.provider/0 callback, and ChatReqLLM derives the provider per model spec at call time. ChatModel.provider/1 falls back to deriving it from the module name for custom models that don't implement the optional callback.
  • LLM-call telemetry is funneled through a single chokepoint, ChatModel.llm_telemetry_span/3, which injects request_options (standard model params), output_type, and endpoint, and centralizes token-usage extraction so no provider can silently drop it.
  • Streaming time-to-first-token is captured via a new [:langchain, :llm, :stream, :first_token] event, primed by llm_telemetry_span/3 and emitted on the first delta through the shared streaming path.
  • LLMChain wraps chain execution, run_until_tool_used, and tool calls in spans, aggregating token usage across all assistant messages of the run (multi-turn/tool-calling safe; scoped to exchanged_messages so persisted history isn't re-counted).
  • LLMChain's built-in tool executor propagates the OpenTelemetry context into async: true tool Tasks automatically, so async tool spans nest under the chain span. The propagation is a runtime no-op unless the OTel deps are installed.

Span hierarchy

invoke_agent llm_chain   [internal]
  └─ chat <model>         [client]
      └─ execute_tool <name> [internal]

Bug fixes included

  • ChatMistralAI token usage — a real product bug, not just a telemetry gap. Mistral fired the :on_llm_token_usage callback but, unlike every other chat model, never persisted usage onto the returned message. So TokenUsage.get/1 returned nil for every non-streaming Mistral response — any caller reading usage/cost off a Mistral message got nothing, regardless of telemetry. Usage is now attached via TokenUsage.set/2 (msg.metadata.usage).
  • Streaming token usage now reaches the :stop telemetry and OTel spans. The shared ChatModel.token_usage_from_result/1 (:enrich_stop) only matched %Message{} results, so streaming calls — which return %MessageDelta{} with usage on the final delta — reported nil usage at the call boundary. It now handles single/list %MessageDelta{} and list results. (TokenUsage.get/1 on the accumulated streaming message was already correct — deltas accumulate usage and to_message/1 preserves it — so this fixes the telemetry surface, not get/1.)
  • Mistral batches streamed deltas as a list of lists. Some providers (Mistral) return streamed deltas batched as a list-of-lists rather than a flat list, so the usage-bearing final delta sat one level deeper than the scan looked — silently dropping token usage from the :stop event and OTel span. token_usage_from_result/1 now List.flatten/1s before scanning (a no-op on already-flat results, safe for every provider's shape).
  • Failed operations are visible to latency metrics. [:langchain, *, :exception] events now carry a duration measurement and an error.type attribute; spans close on exit/throw, not just raise.

⚠️ Breaking changes & upgrade notes

These only affect code that attaches to LangChain's :telemetry events or implements a custom chat model. Normal chain/LLM usage is unaffected, and no public function signatures changed.

Breaking

  1. Chain telemetry metadata key tool_counttools_count.
    Events [:langchain, :chain, :execute, :start | :stop | :exception] now
    expose :tools_count (plural) instead of :tool_count.

    Why: the LLM-call events ([:langchain, :llm, :call, *]) already emit
    this field as :tools_count across all in-tree chat models, while the chain
    events were the lone holdout emitting :tool_count. The same conceptual
    field was therefore named two different ways depending on which event you
    attached to. This renames the two chain-event sites to match the key already
    used by every LLM-call event — the lower-breakage direction (2 call sites
    aligned to the existing 13, rather than renaming the shipped :tools_count
    on the LLM events). It is not tied to any OpenTelemetry attribute —
    tools_count is passthrough telemetry metadata and is not emitted as a span
    attribute.

    Action: if you read metadata.tool_count in a handler attached to
    [:langchain, :chain, :execute, *], rename it to tools_count. Handlers on
    LLM-call events are unaffected (they already used tools_count).

  2. ChatGrok message.metadata.usage type changed: raw API map → %TokenUsage{}.
    Grok previously stored the raw provider map (%{"prompt_tokens" => …, "completion_tokens" => …}) on message.metadata.usage. It now stores a %LangChain.TokenUsage{} struct (input, output, raw), matching every other provider. Action: code reading msg.metadata.usage["prompt_tokens"] off a Grok message must switch to msg.metadata.usage.input / .output (or TokenUsage.get/1). Only affects Grok; other providers were already %TokenUsage{}.

  3. Streaming/non-streaming response event names normalized.
    The per-model response events were emitted under a malformed keyword-in-path form: [:langchain, :llm, :response, streaming: true] (i.e. {:streaming, true}) and [:langchain, :llm, :response, streaming: false]. They are now [:langchain, :llm, :response, :streaming] and [:langchain, :llm, :response, :non_streaming] — matching what the moduledoc always documented (affects ChatAnthropic, ChatBumblebee, ChatGoogleAI). Action: a handler attached to the old streaming: true/false form stops firing; re-attach to the :streaming / :non_streaming names.

Behavior changes worth noting (non-breaking, additive)

  1. custom_context is now included in chain and tool telemetry metadata.
    [:langchain, :chain, :execute, *] and [:langchain, :tool, :call, *] events now carry the chain's custom_context. If your custom_context holds sensitive data and you fan telemetry out to third parties, be aware it's now on those events. It is intentionally not included on LLM-level events (correlate via call_id). Existing handlers that ignore unknown keys are unaffected.

  2. Mistral and Perplexity messages now carry %TokenUsage{} in metadata.
    ChatMistralAI and ChatPerplexity fired the :on_llm_token_usage callback but previously left msg.metadata.usage unset. Both now persist it via TokenUsage.set/2. Additive — nothing that read those messages breaks, but usage is now present where it was nil. (This is the Mistral product-bug fix above, and Perplexity had the same gap.)

  3. Every LangChain telemetry event now carries a :call_id.
    Telemetry.span/4 and start_event/2 inject a :call_id (a UUID, via Map.put_new, so you can supply your own to override). All existing chain/tool/LLM/message-process events gain this key. Additive.

  4. [:langchain, *, :exception] events now fire on throw/exit too, and carry a duration.
    Previously exception events carried no duration, and exits/timeouts/throws produced no event at all (leaking any span a consumer opened on :start). Now every failure emits :exception with a duration and an error.type. Note for handlers: exit/throw events set metadata.kind to :exit/:throw and metadata.error to nil — code that assumes kind == :error or a non-nil error should be relaxed.

  5. ChatGrok now emits the full LLM-call telemetry suite it never emitted before.
    Grok's call/3 now routes through ChatModel.llm_telemetry_span/3, so it emits [:langchain, :llm, :call, :start|:stop|:exception], :prompt, :response, and :response, :non_streaming like every other provider. Existing Grok users get brand-new events; any handler on [:langchain, :llm, :call, *] now fires for Grok. Additive.

  6. New LLM-call telemetry metadata: request_options, output_type, endpoint.
    [:langchain, :llm, :call, *] events now carry the model's standard request parameters (request_options), the requested output type, and the endpoint (when the model exposes one). Additive; existing handlers ignoring unknown keys are unaffected.

  7. New [:langchain, :llm, :stream, :first_token] telemetry event.
    Emitted once per streaming LLM call, on the first delta, with a duration measurement (time-to-first-token). Additive.

  8. New optional ChatModel.provider/0 callback.
    Declared with @optional_callbacks, so existing custom ChatModel implementations continue to compile and run unchanged — they simply get their provider derived from the module name via ChatModel.provider/1. Implement provider/0 to override.

  9. Telemetry.span/3 is now Telemetry.span/4 (added an optional opts \\ [] for :enrich_stop). Backward-compatible — existing span/3 calls keep working unchanged.

  10. New (optional) dependencies.
    :opentelemetry_api is added as optional: true; :opentelemetry and :opentelemetry_exporter are only: :test. Applications that want the integration must add the OTel deps themselves (see docs). Applications that don't are unaffected and pull in nothing new.

Privacy model

Message/tool content is never attached to lifecycle spans by default. Each capture is opt-in via setup/1:

Option Default
capture_input_messages false
capture_output_messages false
capture_tool_arguments false
capture_tool_results false
enable_metrics true

Note on metrics: enable_metrics: true does not record OTel histograms directly — it re-emits three intermediary events:

  • [:langchain, :otel, :operation, :duration]%{duration_s}
  • [:langchain, :otel, :token, :usage]%{tokens}, tagged gen_ai.token.type ("input"/"output")
  • [:langchain, :otel, :operation, :time_to_first_token]%{duration_s}, once per streaming call

Attach a consumer (Telemetry.Metrics + reporter, PromEx, …) to record them. Documented prominently in the guide and MetricsHandler moduledoc.

Grouping traces into conversations

LangChain has no first-class conversation id, so gen_ai.conversation.id (used by backends to group a multi-turn session) is read from the chain's custom_context — set :conversation_id (or reuse :langfuse_session_id) and it lands on the root chain span.

Usage

# application startup
LangChain.OpenTelemetry.setup()

# or, opting into content capture where PII is acceptable:
LangChain.OpenTelemetry.setup(capture_input_messages: true)

Documentation

  • New Observability guide (guides/observability.md) — telemetry event catalog, OTel setup, span/metric mappings, metrics wiring, Langfuse integration, conversation grouping, async-tool context propagation, and the full config table. Rendered on HexDocs under Guides.
  • New runnable Livebook notebooks/observability_tutorial.livemd — a hands-on tour of both layers (raw telemetry → OTel spans → Langfuse → metrics). Rendered under Notebooks.
  • README — new ## Observability section with a minimal setup snippet and links.
  • usage-rules.md — new Observability section so AI coding agents consuming LangChain get the telemetry rules (including the tools_count naming gotcha and PII-off-by-default).

(Breaking changes and upgrade notes are captured in this PR description rather than the CHANGELOG, which isn't edited manually.)

Followups & known limitations

Deferred or best-effort by design — none block this PR, but they're worth tracking:

  • gen_ai.response.model reflects the requested model, not the returned one. When a provider echoes a more specific id (gpt-4ogpt-4o-2024-08-06), that specific id isn't captured yet. Use gen_ai.request.model as source of truth.
  • gen_ai.request.* is a best-effort subset. Parameters are read by conventional struct field name; a param under a non-standard field name is silently omitted.
  • gen_ai.response.finish_reasons is best-effort, reconstructed from the normalized Message.status (stop / length / tool_calls / cancelled), not from the provider's raw finish reason.
  • Cache/reasoning token counts are best-effort, only for providers that report them in usage.
  • Time-to-first-token only covers the shared streaming decode path. It relies on process-dictionary markers set by llm_telemetry_span/3; streaming outside a telemetry span degrades to a no-op.
  • provider/1 module-name fallback can't recover acronym/multi-word canonical names (ChatOpenAIResponses"open_ai_responses"). Mitigated because every in-tree model implements provider/0; custom models should too.
  • without_tracing/1 and async-tool context propagation are process-scoped. The chain auto-propagates OTel context only for its own built-in async-tool executor; processes you spawn yourself must re-attach context manually.
  • Reserved-but-unemitted event families (:memory, :retriever) are kept as naming placeholders with no call sites yet — room for future instrumentation.

Dependencies

  • :opentelemetry_api ~> 1.4 (optional)
  • :opentelemetry ~> 1.5, :opentelemetry_exporter ~> 1.8 (test only)

Tests

~4,000 lines of new tests across test/open_telemetry/ (span handler, attributes, metrics handler, message serializer, config, provider mapping, without-tracing, async-tool context) and expanded test/chat_models/telemetry_test.exs, chat_model_test.exs, plus per-provider telemetry tests (Grok, Mistral, Perplexity, ReqLLM). The observability suite runs 207 tests, 0 failures; MetricsHandler, without_tracing/1, and async-tool context propagation are all covered (previously flagged as untested).

@vasspilka vasspilka marked this pull request as draft February 26, 2026 10:21
@brainlid

Copy link
Copy Markdown
Owner

This never left the draft state and appears to have been abandoned. Closing.

@brainlid brainlid closed this Jun 26, 2026
@vasspilka

Copy link
Copy Markdown
Contributor Author

Hey @brainlid the PR works as is. We have been using it in Teacherspace in production for around 3 months now.
It's not ideal but solves the basic usecase of gathering the telemetry data

The reason it remained a draft is because and was waiting for your thoughts around GenAI SemConv support so we can better coordinate the change.

@brainlid brainlid reopened this Jun 28, 2026
@brainlid

Copy link
Copy Markdown
Owner

Thanks @vasspilka for the update. I didn't realize you were waiting on me. I was waiting on the PR leaving draft status! 😆

Specifically, what would you like from me? Are you saying it's ready for a review and that's what you want? Or are you proposing something specific?

Resolve conflicts in llm_chain.ex (combine telemetry enrich_stop with
on_error callbacks and tool-call context enrichment), chat_ollama_ai.ex
(keep provider/0 and promote_thinking/2), mix.exs (docs groups), and
regenerate mix.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vasspilka

Copy link
Copy Markdown
Contributor Author

@brainlid Thanks for the quick reply, let me refresh my memory this work and I'll come back to you during the week.

Overall it's a net improvement. But could be a breaking changes if people relied on matching Telemetry [streaming: true]

@vasspilka vasspilka marked this pull request as ready for review June 28, 2026 22:07
vasspilka and others added 4 commits June 29, 2026 00:10
- Add MetricsHandler tests (duration + token-usage emission, attribute
  population, nil/absent handling).
- Add without_tracing/1 tests (value passthrough, span suppression,
  context restoration on raise) and a no-active-span prompt-event test.
- Guard the [:langchain, :llm, :prompt] handler against an undefined
  current span context so it no-ops instead of relying on silent failure.
- Clarify in docs that enable_metrics re-emits intermediary telemetry
  events and requires a consumer to record real metrics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SpanHandler traps its own exceptions so a bad payload (e.g. a
  non-encodable message reaching the serializer) can no longer
  permanently detach the handler VM-wide via :telemetry
- emit duration metrics on :exception events tagged with error.type
  so error rate and error latency are observable, not just successes;
  span/4 now reports a duration on the exception event
- set error.type on error spans for GenAI-semconv grouping (Langfuse)
- fix moduledoc: document async-tool span orphaning instead of
  claiming everything runs synchronously
- add failure-path tests for handler resilience and error metrics

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `:call, :stop` telemetry (and thus GenAI OTEL spans) surfaced token
usage only for non-streaming OpenAI responses. Two gaps:

- `ChatModel.token_usage_from_result/1` (the shared `enrich_stop`) only
  matched `{:ok, [%Message{} | _]}`. Streaming calls return a list of
  `%MessageDelta{}` with the accumulated `%TokenUsage{}` on the final
  delta, so it fell through to `nil` for every streaming provider. Now
  handles single/list `%MessageDelta{}` too.

- `ChatMistralAI` fired `:on_llm_token_usage` but never persisted usage
  onto the returned message metadata, so `enrich_stop` saw nothing. Now
  attaches it via `TokenUsage.set/2`, matching the other chat models.

Adds regression tests for `token_usage_from_result/1`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CJ75PfYaw7PegixbPR9Ji2
@vasspilka

Copy link
Copy Markdown
Contributor Author

@brainlid Sorry for the delay. I found that there were some issues, please give me a day or two and it will be finished

vasspilka and others added 5 commits July 3, 2026 12:51
`run_until_tool_used/3` terminates with a 3-tuple `{:ok, chain, tool_result}`
on its common success path, but both `chain_stop_metadata/1` (LLMChain) and
`extract_user_input/1` (OTEL attributes) only matched the 2-tuple `{:ok, chain}`.
The 3-tuple fell through to the nil clause, silently dropping `last_message`,
aggregated `token_usage`, and the captured user input from the chain `:stop`
event and span for that mode.

Both sites now handle the 2- and 3-tuple shapes via a shared helper. The input
extraction also swaps filter+first for `Enum.find` (short-circuits) while
preserving the empty/non-list -> nil behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…spec

Regression tests for the token-usage capture fixed earlier on this branch:

- Streaming: accumulated `%TokenUsage{}` rides on the final `%MessageDelta{}`,
  so `ChatModel.token_usage_from_result/1` must scan the delta list for the
  `[:langchain, :llm, :call, :stop]` event to report token counts.
- Mistral: `call/3` now persists usage onto the returned message metadata (via
  `attach_token_usage/2`), so it reaches the LLM `:stop` telemetry and OTEL span.

Also widens `TokenUsage.set/2`'s spec to `set(any(), nil | t()) :: any()` to
reflect that the function is intentionally total — its catch-all clause returns
non-message values unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`:telemetry` permanently detaches a handler that raises, VM-wide for the rest of
the run, so a single malformed payload could silently disable all metrics for
every subsequent request. The metrics handler now traps, logs a warning, and
returns `:ok` while staying attached — matching the existing `SpanHandler`
resilience guarantee. Dispatch moves to a private `do_handle_event/4` behind the
trapping `handle_event/4` entry point.

Adds a test that feeds a raising payload and asserts the handler stays
registered and keeps emitting on the next valid event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `chain_stop/2` describe plus a coverage test that walks every built-in
chat model's `provider/0` string and asserts it maps to a documented OTel
`gen_ai.provider.name`. The expected table is kept independent of the production
`@mapping` so drift (a changed or removed mapping, or a new unmapped provider)
fails loudly here rather than silently passing through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`gen_ai.response.model` is set from the request model, not the provider-echoed
id. Document that `gen_ai.request.model` is the source of truth so users don't
expect a more specific returned id (e.g. gpt-4o -> gpt-4o-2024-08-06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vasspilka and others added 8 commits July 3, 2026 13:44
…metry_span/2

Every chat model opened its `[:langchain, :llm, :call]` span by hand and had to
remember to pass `enrich_stop: &ChatModel.token_usage_from_result/1`. Nothing
failed if a model forgot it — token usage just silently vanished from the
`:stop` event (and the OTEL span) for that provider.

ChatAwsMantle and ChatReqLLM were exactly that case: both attach a `%TokenUsage{}`
to the returned message but neither wired `enrich_stop`, so their token counts
never reached telemetry at all.

Introduce `ChatModel.llm_telemetry_span/2`, which hard-wires the event name and
`:enrich_stop` in one place, and route all 14 LLM-span-emitting models through
it (12 already instrumented + the two that were silently dropping usage). A new
provider now only has to build its metadata and call the helper.

Adds unit tests locking the helper's contract: usage from a message and from a
streaming delta list both surface on `:stop`, and the `:token_usage` key is
always present (its presence is what proves enrich_stop ran).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Attributes.llm_call_stop/2` only matched `{:ok, %Message{}}` /
`{:ok, [%Message{} | _]}`, but a streaming call returns
`{:ok, [%MessageDelta{} | _]}` — which matched neither. So with
`capture_output_messages: true`, streamed responses produced an empty
`gen_ai.output.messages`, while input capture worked fine.

Merge the delta list into a single message (via `MessageDelta.merge_deltas/1`
+ `to_message/1`) before serializing, so streamed output is captured the same
as non-streaming. An incomplete/interrupted stream can't convert to a message,
so nothing is captured rather than raising or emitting a partial artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Telemetry.span/4` only had a `rescue` clause, which traps raised exceptions.
If the wrapped function `exit`s (a linked crash, a `Task.await` timeout) or
`throw`s, neither `:stop` nor `:exception` fired — so a span-based consumer
(the OTEL `SpanHandler`) that opened a span on `:start` never ended it, leaking
the span and its attached context in the process dictionary for the rest of a
long-lived process (e.g. a GenServer running many chains).

Add a `catch kind, reason` clause that emits `:exception` (with a duration, so
error latency stays observable) and then re-propagates the exit/throw unchanged
via `:erlang.raise/3`. Throws/exits carry no exception struct, so `:error` is
`nil` and consumers key off `:kind`/`:reason`; the existing handlers already end
their span when `:error` is nil, so this closes the leak with no handler change.

Tests cover the raw event emission (throw/exit/raise) and an end-to-end check
that the SpanHandler pops and ends the span (no leaked process-dict entry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration emits a useful subset of the GenAI Semantic Conventions, not
the full spec. Notably absent are the recommended request parameters
(`gen_ai.request.temperature` / `max_tokens` / `top_p`) and response fields
(`gen_ai.response.id` / `finish_reasons`).

Soften "following the GenAI Semantic Conventions" to "a subset of" across the
module docs, guide, notebook, and usage-rules, and add an explicit
captured-vs-not-captured list to `LangChain.OpenTelemetry.Attributes` that the
other docs point to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…effort fallback

`ChatModel.provider/1` falls back to deriving the provider from the module name
when a model doesn't implement `provider/0`. That derivation is best-effort and
can't recover canonical names for acronym module names (`ChatOpenAIResponses` ->
`open_ai_responses`, not `openai_responses`), so it won't round-trip through
`ProviderMapping`. Making `provider/0` required would break external ChatModel
implementers (the fallback is intentional and tested for them), so instead:

- Document `provider/1`'s fallback clearly as a best-effort heuristic and steer
  models toward implementing `provider/0`.
- Ensure no in-tree model relies on the heuristic. `ChatAwsMantle` now
  implements `provider/0` ("aws_mantle" -> "aws.bedrock") and puts `:provider`
  on its telemetry metadata. `ChatReqLLM`, a multi-provider adapter whose
  provider varies per call, derives `:provider` from its `"provider:model_id"`
  model spec instead of a static callback.

Both models previously emitted spans with no `gen_ai.provider.name` at all, so
this also fills a real gap. Extends the provider-mapping coverage test to
ChatAwsMantle and adds a ReqLLM test asserting per-call provider derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…M spans

Close the largest fidelity gap against the req_llm OTel integration: LLM
spans emitted no request parameters at all. Now every chat model's standard
parameters (temperature, max_tokens, top_p, top_k, frequency/presence
penalty, seed, choice.count, stream, stop_sequences, reasoning.level) are
captured as gen_ai.request.* attributes, plus gen_ai.output.type.

Rather than re-plumbing metadata through 16 providers, funnel it centrally:
ChatModel.llm_telemetry_span/2 becomes /3, taking the model struct, and a new
ChatModel.request_options/1 reads the conventional public schema fields (same
best-effort heuristic as provider/1) into metadata[:request_options]. The OTel
Attributes layer maps that to gen_ai.request.* and drops anything unset. A
provider that names a field differently simply won't have it captured.

Reconcile the provider mapping: keep google -> gcp.gemini (the spec-specific
value for the Gemini API that ChatGoogleAI targets) rather than the generic
gcp.gen_ai that req_llm uses. Documented and locked with a test.

Deliberately still absent (documented, not silent): response.id /
finish_reasons / real response.model (Message normalizes finish_reason into
:status lossily and keeps no response id), cache/reasoning tokens + cost
(TokenUsage tracks only input/output), and streaming time-to-first-chunk.

Verified against the open-telemetry/semantic-conventions-genai registry.
mix compile --warnings-as-errors clean; 140/140 telemetry + OTel tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…use metadata

Two correctness fixes surfaced by an interactive review of the telemetry work,
plus regression tests and coverage for the gaps that let them slip through.

1. Chain token-usage aggregation dropped all but the last turn for streaming
   providers. `aggregate_token_usage/1` folded per-message usage through
   `TokenUsage.add/2`, whose `cumulative: true` clause returns the second arg and
   discards the accumulator. That flag is a *delta-merge* signal for a single
   message, but streaming providers (e.g. Google AI) carry it onto the assembled
   message, so combining two turns kept only the latest — contradicting the
   documented "aggregated across all assistant messages" behavior. Strip the flag
   before summing per-turn totals.

2. A non-string `langfuse_metadata` value crashed the chain span. Flattening used
   `to_string/1`, which raises on a nested map (no String.Chars impl) — trapped by
   the span handler, that silently dropped the entire chain span — and lossily
   iolist-flattened lists (`["x", "y"]` -> `"xy"`). Route values through
   `stringify_metadata_value/1`: primitives stringify, collections JSON-encode.

Coverage added (each gap below was previously untested):
- multi-turn token summation with cumulative-flagged usage (guards fix 1)
- non-primitive langfuse_metadata values don't raise (guards fix 2)
- sibling LLM + tool spans both parent to the chain with no leaked context
- :stop / :exception for an unknown call_id are no-ops
- request_options/1 retains falsy-but-set values (stream: false, seed/temp 0)
- request_options/1 extracts max_tokens/top_p/top_k; empty map for a non-struct
- atom-valued reasoning level; single bare %MessageDelta{} output capture

mix precommit clean (compile --warnings-as-errors, format, sobelow, deps.audit);
affected suites 107/107 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…easoning tokens, finish_reasons, tool.description, conversation.id

Audited the emitted spans against the open-telemetry/semantic-conventions-genai
registry + span/metric docs and closed the actionable coverage gaps. The
inference span goes from ~13 to ~21 spec attributes, and one correctness bug is
fixed.

New span attributes (all best-effort, absent-when-unavailable):
- gen_ai.output.type — now "json" when the model requests structured output
  (json_response / json_schema / a JSON response_format). Was hardcoded "text",
  which mislabeled every JSON-mode call. Resolved centrally via a new
  ChatModel.output_type/1; llm_telemetry_span/3 injects :output_type.
- server.address / server.port — parsed from the model's :endpoint (injected as
  :endpoint metadata). URI supplies the scheme default port.
- gen_ai.usage.cache_read.input_tokens / cache_creation.input_tokens /
  reasoning.output_tokens — recovered from TokenUsage.raw (Anthropic top-level
  keys; OpenAI prompt_/completion_/output_tokens_details). Zeros dropped. This
  corrects the prior "TokenUsage tracks only input/output, so impossible" claim.
- gen_ai.response.finish_reasons — best-effort from the normalized Message.status
  (stop / length / tool_calls), streaming-aware.
- gen_ai.tool.description — from Function.description (threaded through the tool
  span metadata).
- gen_ai.conversation.id — from custom_context (:conversation_id, falling back to
  :langfuse_session_id) on the chain span.

Metrics docs: the Telemetry.Metrics example now uses the canonical
gen_ai.client.operation.duration / gen_ai.client.token.usage names and the spec's
recommended histogram bucket boundaries, so copied wiring is conformant.

Docs reconciled to state coverage honestly: only gen_ai.response.id, a distinct
gen_ai.response.model, and streaming time-to-first-chunk remain genuinely
unavailable. Observability guide, usage-rules, and module docs updated.

mix precommit clean (format + --warnings-as-errors); new unit tests cover
output_type/1, endpoint/output_type injection, server.* derivation, both cache
token shapes, zero-dropping, all finish-reason paths, tool.description, and
conversation.id precedence/fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vasspilka vasspilka changed the title Telemetry enhance feat: Support for OpenTelemetry GenAI semantic conventions Jul 3, 2026
@vasspilka

Copy link
Copy Markdown
Contributor Author

Hey @brainlid

I actually was not right when I said we've have this running in production. It was actually only our staging environment that had this live.

Upon further QA it worked well but was not a complete seamless solution. I've been working on getting full support on the OpenTelemetry GenAI semantic conventions specification.

Then will do a final round of testing. Note that the PR has grown considerably but the vast majority is tests and documentation.

vasspilka and others added 7 commits July 3, 2026 16:41
Addresses the high-severity issues from a branch review of the telemetry work:

* span_handler: the LLM/chain/tool `:stop` handlers built their attributes
  before ending the span. With `capture_*_messages` enabled, attribute
  construction serializes message content and can raise (e.g. `Jason.encode!`
  on invalid-UTF-8 bytes); the outer rescue then swallowed it and `end_span`
  never ran, so the span was never ended and its OTel context never detached —
  permanently reparenting every later span in that process. Build stop
  attributes behind `safe_stop_attributes/2` (falls back to `[]` on failure)
  and end the span + detach the context in an `after`, so serialization
  failures at most drop attributes.

* llm_chain: `chain_stop_from_chain/1` aggregated token usage over
  `chain.messages` (the whole history), so re-running a persisted chain
  re-counted every earlier turn on each `:chain,:execute,:stop` event. Sum
  over `chain.exchanged_messages` (this run's scope) instead.

* chat_grok / chat_perplexity: both dropped token usage from the LLM-call span
  because they never persisted a `%TokenUsage{}` onto the returned message
  (Grok stored the raw API map; Perplexity only fired the callback). Attach the
  struct like the OpenAI path so `token_usage_from_result/1` surfaces it.

Adds regression tests that exercise the real `call/3` (mocking only the HTTP
layer) rather than stubbing it away, plus a span-handler test proving the span
is ended and context detached when output serialization raises. Each new test
was confirmed to fail without its fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The private `endpoint/1` helper is spec'd `struct() | nil`, which the `nil`
and `%_module{}` clauses already cover in full, so the trailing `endpoint(_)`
catch-all is dead code. Dialyzer's `pattern_match_cov` warning on it was the
sole unignored finding failing the CI lint job; remove the clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ardown

Issues surfaced during branch review:

- token_usage_from_result/1: fold streamed delta usage with TokenUsage.add/2
  instead of taking the first delta. Google/Vertex tag every delta with a
  `cumulative: true` running total, so "first" reported an early partial and
  under-counted the [:langchain, :llm, :call, :stop] event and OTEL span.
  Folding is cumulative-aware (keeps the final total, sums Anthropic's split
  input/output) while still deduping the identical usage OpenAI attaches to
  every choice for n>1 non-streaming results.

- Attributes.finish_reason / output_messages_from_result: List.flatten the
  batched (list-of-lists) streaming shape (e.g. Mistral) so finish_reasons and
  output.messages are no longer dropped on that path.

- Attributes: also read OpenAI Responses-API cached tokens from
  input_tokens_details.cached_tokens (Chat Completions uses prompt_tokens_details).

- SpanHandler.end_span_on_exception: wrap error recording in try/after so the
  span always ends and its context detaches even if Exception.message/set_status
  raises (mirrors end_span/2); prevents a leaked span from corrupting the
  process context.

- Config: correct the :enable_metrics docstring — it re-emits intermediary
  metric events for a consumer, it does not record OTel histograms directly.

Adds regression tests for each fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…served

The moduledoc listed `[:langchain, :message, :process, …]` under "Reserved
events (not currently emitted)", but `LangChain.MessageProcessors.JsonProcessor`
emits that span directly via `span/4` whenever it runs in a chain's
`message_processors`. Move the events into Core Events, drop them (and the
unused `message_process_start/1` helper) from the reserved list, and reword the
helper's note to clarify the helper is uncalled while the events do fire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vasspilka

Copy link
Copy Markdown
Contributor Author

@brainlid This is now ready for review.

Note the breaking changes (only for low level consumers of old telemetry). Most notably tool_count → tools_count

Also if someone would be to use ReqLLM as a provider they would get double traces. This is something we can address in a follow up or I can patch this PR.

Please fire away any questions or clarifications :)

Cheers

@brainlid

brainlid commented Jul 7, 2026

Copy link
Copy Markdown
Owner

@vasspilka, what are you thinking about preventing the double traces when using ChatReqLLM? I'm fine with a follow-up for it. Have you got a plan in mind?

@brainlid brainlid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @vasspilka! It was a beast to review. But it adds a lot of value. Thanks for the guides, docs, and notebook. I can tell a lot of time was put into this. Even with a coding agent, there was still a lot of time and effort.
❤️💛💙💜

@brainlid brainlid merged commit 6a70f75 into brainlid:main Jul 7, 2026
2 checks passed
@brainlid

brainlid commented Jul 7, 2026

Copy link
Copy Markdown
Owner

@vasspilka released as v0.9.0

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.

2 participants