feat: Support for OpenTelemetry GenAI semantic conventions#472
Conversation
|
This never left the draft state and appears to have been abandoned. Closing. |
|
Hey @brainlid the PR works as is. We have been using it in Teacherspace in production for around 3 months now. 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. |
|
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>
|
@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] |
- 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
|
@brainlid Sorry for the delay. I found that there were some issues, please give me a day or two and it will be finished |
`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>
…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>
|
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. |
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>
|
@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 |
|
@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
left a comment
There was a problem hiding this comment.
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.
❤️💛💙💜
|
@vasspilka released as v0.9.0 |
Summary
Addresses #467 by adding OpenTelemetry GenAI telemetry to LangChain, following the OpenTelemetry GenAI semantic conventions (v1.40+). LangChain already emitted
:telemetryevents; 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_apiisn't loaded, the integration module isn't even compiled and there's zero overhead.What's added
New
LangChain.OpenTelemetryintegration (lib/open_telemetry/)OpenTelemetrysetup/1,teardown/0,without_tracing/1. Attaches span + metrics handlers.OpenTelemetry.Configfalse).OpenTelemetry.SpanHandler:telemetrylifecycle 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.MetricsHandlerOpenTelemetry.Attributesserver.*, tool metadata,conversation.id, and optional Langfuse context.OpenTelemetry.MessageSerializergen_ai.*.messagesattributes.OpenTelemetry.ProviderMappingSpan attributes (GenAI semantic conventions subset)
Emitted best-effort — a value appears only when the model actually sets it.
gen_ai.operation.name,gen_ai.provider.name,gen_ai.request.model,gen_ai.response.model,gen_ai.agent.name,gen_ai.conversation.idgen_ai.request.temperature,.max_tokens,.top_p,.top_k,.frequency_penalty,.presence_penalty,.seed,.choice.count,.stream,.stop_sequences,.reasoning.level; plusgen_ai.output.type("json"for structured-output requests, else"text")server.address,server.port(derived from the model's request endpoint)gen_ai.usage.input_tokens,.output_tokens; best-effortgen_ai.usage.cache_read.input_tokens,.cache_creation.input_tokens,.reasoning.output_tokensgen_ai.response.finish_reasons(reconstructed from the normalized message status)gen_ai.response.time_to_first_token(seconds) + agen_ai.first_tokenspan event on the LLM span, for providers that stream through the shared pathgen_ai.tool.name,.call.id,.type,.descriptionTelemetry surface
LangChain.Telemetrygained documented metadata (autocall_idcorrelation,provider,custom_context, token usage,last_message,request_options,output_type,endpoint) and explicit privacy guarantees.ChatModel.provider/0callback, andChatReqLLMderives the provider per model spec at call time.ChatModel.provider/1falls back to deriving it from the module name for custom models that don't implement the optional callback.ChatModel.llm_telemetry_span/3, which injectsrequest_options(standard model params),output_type, andendpoint, and centralizes token-usage extraction so no provider can silently drop it.[:langchain, :llm, :stream, :first_token]event, primed byllm_telemetry_span/3and emitted on the first delta through the shared streaming path.LLMChainwraps 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 toexchanged_messagesso persisted history isn't re-counted).LLMChain's built-in tool executor propagates the OpenTelemetry context intoasync: truetoolTasks 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
Bug fixes included
ChatMistralAItoken usage — a real product bug, not just a telemetry gap. Mistral fired the:on_llm_token_usagecallback but, unlike every other chat model, never persisted usage onto the returned message. SoTokenUsage.get/1returnednilfor every non-streaming Mistral response — any caller reading usage/cost off a Mistral message got nothing, regardless of telemetry. Usage is now attached viaTokenUsage.set/2(msg.metadata.usage).:stoptelemetry and OTel spans. The sharedChatModel.token_usage_from_result/1(:enrich_stop) only matched%Message{}results, so streaming calls — which return%MessageDelta{}with usage on the final delta — reportednilusage at the call boundary. It now handles single/list%MessageDelta{}and list results. (TokenUsage.get/1on the accumulated streaming message was already correct — deltas accumulate usage andto_message/1preserves it — so this fixes the telemetry surface, notget/1.):stopevent and OTel span.token_usage_from_result/1nowList.flatten/1s before scanning (a no-op on already-flat results, safe for every provider's shape).[:langchain, *, :exception]events now carry adurationmeasurement and anerror.typeattribute; spans close onexit/throw, not justraise.These only affect code that attaches to LangChain's
:telemetryevents or implements a custom chat model. Normal chain/LLM usage is unaffected, and no public function signatures changed.Breaking
Chain telemetry metadata key
tool_count→tools_count.Events
[:langchain, :chain, :execute, :start | :stop | :exception]nowexpose
:tools_count(plural) instead of:tool_count.Why: the LLM-call events (
[:langchain, :llm, :call, *]) already emitthis field as
:tools_countacross all in-tree chat models, while the chainevents were the lone holdout emitting
:tool_count. The same conceptualfield 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_counton the LLM events). It is not tied to any OpenTelemetry attribute —
tools_countis passthrough telemetry metadata and is not emitted as a spanattribute.
Action: if you read
metadata.tool_countin a handler attached to[:langchain, :chain, :execute, *], rename it totools_count. Handlers onLLM-call events are unaffected (they already used
tools_count).ChatGrokmessage.metadata.usagetype changed: raw API map →%TokenUsage{}.Grok previously stored the raw provider map (
%{"prompt_tokens" => …, "completion_tokens" => …}) onmessage.metadata.usage. It now stores a%LangChain.TokenUsage{}struct (input,output,raw), matching every other provider. Action: code readingmsg.metadata.usage["prompt_tokens"]off a Grok message must switch tomsg.metadata.usage.input/.output(orTokenUsage.get/1). Only affects Grok; other providers were already%TokenUsage{}.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 (affectsChatAnthropic,ChatBumblebee,ChatGoogleAI). Action: a handler attached to the oldstreaming: true/falseform stops firing; re-attach to the:streaming/:non_streamingnames.Behavior changes worth noting (non-breaking, additive)
custom_contextis now included in chain and tool telemetry metadata.[:langchain, :chain, :execute, *]and[:langchain, :tool, :call, *]events now carry the chain'scustom_context. If yourcustom_contextholds 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 viacall_id). Existing handlers that ignore unknown keys are unaffected.Mistral and Perplexity messages now carry
%TokenUsage{}in metadata.ChatMistralAIandChatPerplexityfired the:on_llm_token_usagecallback but previously leftmsg.metadata.usageunset. Both now persist it viaTokenUsage.set/2. Additive — nothing that read those messages breaks, but usage is now present where it wasnil. (This is the Mistral product-bug fix above, and Perplexity had the same gap.)Every LangChain telemetry event now carries a
:call_id.Telemetry.span/4andstart_event/2inject a:call_id(a UUID, viaMap.put_new, so you can supply your own to override). All existing chain/tool/LLM/message-process events gain this key. Additive.[:langchain, *, :exception]events now fire onthrow/exittoo, and carry aduration.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:exceptionwith adurationand anerror.type. Note for handlers: exit/throw events setmetadata.kindto:exit/:throwandmetadata.errortonil— code that assumeskind == :erroror a non-nilerrorshould be relaxed.ChatGroknow emits the full LLM-call telemetry suite it never emitted before.Grok's
call/3now routes throughChatModel.llm_telemetry_span/3, so it emits[:langchain, :llm, :call, :start|:stop|:exception],:prompt,:response, and:response, :non_streaminglike every other provider. Existing Grok users get brand-new events; any handler on[:langchain, :llm, :call, *]now fires for Grok. Additive.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.New
[:langchain, :llm, :stream, :first_token]telemetry event.Emitted once per streaming LLM call, on the first delta, with a
durationmeasurement (time-to-first-token). Additive.New optional
ChatModel.provider/0callback.Declared with
@optional_callbacks, so existing customChatModelimplementations continue to compile and run unchanged — they simply get their provider derived from the module name viaChatModel.provider/1. Implementprovider/0to override.Telemetry.span/3is nowTelemetry.span/4(added an optionalopts \\ []for:enrich_stop). Backward-compatible — existingspan/3calls keep working unchanged.New (optional) dependencies.
:opentelemetry_apiis added asoptional: true;:opentelemetryand:opentelemetry_exporterareonly: :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:capture_input_messagesfalsecapture_output_messagesfalsecapture_tool_argumentsfalsecapture_tool_resultsfalseenable_metricstrueGrouping 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'scustom_context— set:conversation_id(or reuse:langfuse_session_id) and it lands on the root chain span.Usage
Documentation
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.notebooks/observability_tutorial.livemd— a hands-on tour of both layers (raw telemetry → OTel spans → Langfuse → metrics). Rendered under Notebooks.## Observabilitysection with a minimal setup snippet and links.usage-rules.md— new Observability section so AI coding agents consuming LangChain get the telemetry rules (including thetools_countnaming 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.modelreflects the requested model, not the returned one. When a provider echoes a more specific id (gpt-4o→gpt-4o-2024-08-06), that specific id isn't captured yet. Usegen_ai.request.modelas 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_reasonsis best-effort, reconstructed from the normalizedMessage.status(stop/length/tool_calls/cancelled), not from the provider's raw finish reason.llm_telemetry_span/3; streaming outside a telemetry span degrades to a no-op.provider/1module-name fallback can't recover acronym/multi-word canonical names (ChatOpenAIResponses→"open_ai_responses"). Mitigated because every in-tree model implementsprovider/0; custom models should too.without_tracing/1and 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.: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 expandedtest/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).