Builds OpenTelemetry span attribute maps from LangChain telemetry metadata, following a subset of the GenAI Semantic Conventions (v1.40+).
Attribute key constants are defined as string literals because the Hex
opentelemetry_semantic_conventions package lags behind the latest spec.
Coverage
This integration emits the following semantic-convention attributes:
gen_ai.operation.name,gen_ai.provider.name,gen_ai.output.type("json"when the model requests structured output, else"text")gen_ai.request.model,gen_ai.response.modelserver.address,server.port(derived from the model's request endpoint)- Request parameters (when the model sets them):
gen_ai.request.temperature,gen_ai.request.max_tokens,gen_ai.request.top_p,gen_ai.request.top_k,gen_ai.request.frequency_penalty,gen_ai.request.presence_penalty,gen_ai.request.seed,gen_ai.request.choice.count,gen_ai.request.stream,gen_ai.request.stop_sequences,gen_ai.request.reasoning.level gen_ai.usage.input_tokens,gen_ai.usage.output_tokens, and — best-effort from the provider-specificTokenUsage.raw—gen_ai.usage.cache_read.input_tokens,gen_ai.usage.cache_creation.input_tokens,gen_ai.usage.reasoning.output_tokensgen_ai.response.finish_reasons(best-effort fromMessage.status)gen_ai.input.messages,gen_ai.output.messages(opt-in — seeConfig)gen_ai.tool.name,gen_ai.tool.call.id,gen_ai.tool.type,gen_ai.tool.description,gen_ai.tool.call.arguments/gen_ai.tool.call.result(opt-in)gen_ai.agent.name,gen_ai.agent.id,gen_ai.conversation.id(fromcustom_context)error.type(on failed operations)- Any attributes the caller supplies via
custom_context[:otel_attributes]— seepassthrough_attributes/1
It does not currently emit gen_ai.response.id or a distinct
gen_ai.response.model (LangChain keeps no response id and normalizes the
response model to the requested one). Treat the output as a useful subset
rather than full conformance.
Streaming time-to-first-token is captured, but by
LangChain.OpenTelemetry.SpanHandler (as a gen_ai.response.time_to_first_token
attribute and a gen_ai.first_token span event) rather than here, because it is
derived from a streaming lifecycle event rather than the call metadata this
module maps.
Summary
Functions
Coerces a caller-supplied value into something safe to set as a span attribute.
Builds attributes for a chain execution event.
Builds attributes for a chain execution stop event.
Extracts Langfuse-specific attributes from a custom_context map.
Builds attributes for an LLM call start event.
Builds attributes for an LLM call stop event (token usage and response model).
Merges two attribute lists, with override winning on duplicate keys.
Returns the gen_ai.operation.name attribute key.
Extracts caller-supplied span attributes from custom_context[:otel_attributes].
Builds attributes for a tool call start event.
Builds attributes for a tool call stop event.
Functions
Coerces a caller-supplied value into something safe to set as a span attribute.
Values the OpenTelemetry spec supports natively — strings, integers, floats,
booleans, and homogeneous lists of those — pass through unchanged, so numeric
attributes stay numeric and remain filterable as numbers in the backend. Anything
else is JSON-encoded, falling back to inspect/1 when it cannot be encoded.
This exists because an uncoerced value is not a loud failure. A nested map has no
String.Chars implementation, so the SDK's stringification raises; the span
handler traps that exception (it must, since a raising :telemetry handler is
detached VM-wide) and the result is that the whole span silently disappears.
Every caller-supplied value must pass through here.
nil returns nil so callers can drop the attribute entirely; there is no
meaningful "null" span attribute.
Builds attributes for a chain execution event.
Sets gen_ai.conversation.id, gen_ai.agent.name, and gen_ai.agent.id from
custom_context, extracts Langfuse-specific attributes when present, and applies
the caller's :otel_attributes passthrough (see passthrough_attributes/1).
@spec chain_stop(map(), LangChain.OpenTelemetry.Config.t()) :: [{String.t(), term()}]
Builds attributes for a chain execution stop event.
Extracts the first user message as input and the last assistant message as output so they appear on the trace-level span in Langfuse (and other OTEL backends).
Extracts Langfuse-specific attributes from a custom_context map.
Supported keys:
:langfuse_trace_name->langfuse.trace.name:langfuse_user_id->langfuse.user.id:langfuse_session_id->langfuse.session.id:langfuse_tags->langfuse.trace.tags:langfuse_metadata->langfuse.trace.metadata.*(flattened)
@spec llm_call_start(map(), LangChain.OpenTelemetry.Config.t()) :: [ {String.t(), term()} ]
Builds attributes for an LLM call start event.
Returns operation name, output type, model, provider, and request-parameter
attributes (gen_ai.request.*, sourced from metadata[:request_options]).
Input message capture is handled separately by the prompt event handler in
SpanHandler.
@spec llm_call_stop(map(), LangChain.OpenTelemetry.Config.t()) :: [ {String.t(), term()} ]
Builds attributes for an LLM call stop event (token usage and response model).
When config.capture_output_messages is true and metadata[:result] contains
a message, serializes output messages into gen_ai.output.messages.
Merges two attribute lists, with override winning on duplicate keys.
Attribute lists are keyword-like lists of {key, value} tuples, and the SDK's
own de-duplication order is an implementation detail we should not depend on.
Resolving collisions here keeps precedence explicit and testable at the two
places it matters:
- caller-supplied
:otel_attributesoverride LangChain-derived values on the same span, because a caller who names a key means it; - inherited attributes (see
LangChain.OpenTelemetry.SpanHandler) lose to the values an event derives, so an inheritedgen_ai.request.modelcan never overwrite the real per-call model on achatspan.
Returns the gen_ai.operation.name attribute key.
Extracts caller-supplied span attributes from custom_context[:otel_attributes].
:otel_attributes is a reserved key in LLMChain.custom_context: a flat map
of attribute name to value that LangChain copies onto the spans it creates.
chain =
%{llm: llm, messages: messages}
|> LLMChain.new!()
|> Map.put(:custom_context, %{
otel_attributes: %{
"user.id" => user.id,
"organization.id" => org.id,
"myapp.feature" => "support_chat"
}
})The key is reserved and explicit rather than LangChain forwarding all of
custom_context, because custom_context routinely holds structs, PIDs, and
whole conversation states. Copying it wholesale would produce enormous spans and
leak data the caller never intended to export.
Keys may be strings or atoms; values are coerced by attribute_value/1. Entries
with an unusable key or a nil value are dropped rather than exported as empty
attributes.
Namespace application-specific names (myapp.*) per the semantic conventions. A
gen_ai.* key is honoured deliberately, so a caller can fill a convention slot
LangChain does not populate itself.
@spec tool_call(map(), LangChain.OpenTelemetry.Config.t()) :: [{String.t(), term()}]
Builds attributes for a tool call start event.
When config.capture_tool_arguments is true and metadata[:arguments] is present,
serializes arguments into gen_ai.tool.call.arguments. Also applies the caller's
:otel_attributes passthrough (see passthrough_attributes/1), which reaches tool
spans because LLMChain includes custom_context in the tool-call telemetry
metadata.
@spec tool_call_stop(map(), LangChain.OpenTelemetry.Config.t()) :: [ {String.t(), term()} ]
Builds attributes for a tool call stop event.
When config.capture_tool_results is true and metadata[:tool_result] is present,
extracts the result content into gen_ai.tool.call.result.