Packages
langchain
0.9.0
0.9.2
0.9.1
0.9.0
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.4.0-rc.3
0.4.0-rc.2
0.4.0-rc.1
0.4.0-rc.0
0.3.3
0.3.2
0.3.1
0.3.0
0.3.0-rc.2
0.3.0-rc.1
0.3.0-rc.0
0.2.0
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Elixir implementation of a LangChain style framework that lets Elixir projects integrate with and leverage LLMs.
Current section
Files
Jump to
Current section
Files
lib/open_telemetry/attributes.ex
defmodule LangChain.OpenTelemetry.Attributes do
@moduledoc """
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.model`
* `server.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-specific `TokenUsage.raw` — `gen_ai.usage.cache_read.input_tokens`,
`gen_ai.usage.cache_creation.input_tokens`, `gen_ai.usage.reasoning.output_tokens`
* `gen_ai.response.finish_reasons` (best-effort from `Message.status`)
* `gen_ai.input.messages`, `gen_ai.output.messages` (opt-in — see `Config`)
* `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.conversation.id` (from `custom_context`)
* `error.type` (on failed operations)
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.
See: https://opentelemetry.io/docs/specs/semconv/gen-ai/
"""
alias LangChain.OpenTelemetry.Config
alias LangChain.OpenTelemetry.MessageSerializer
alias LangChain.OpenTelemetry.ProviderMapping
# Attribute key constants
@operation_name "gen_ai.operation.name"
@provider_name "gen_ai.provider.name"
@output_type "gen_ai.output.type"
@request_model "gen_ai.request.model"
@request_temperature "gen_ai.request.temperature"
@request_max_tokens "gen_ai.request.max_tokens"
@request_top_p "gen_ai.request.top_p"
@request_top_k "gen_ai.request.top_k"
@request_frequency_penalty "gen_ai.request.frequency_penalty"
@request_presence_penalty "gen_ai.request.presence_penalty"
@request_seed "gen_ai.request.seed"
@request_choice_count "gen_ai.request.choice.count"
@request_stream "gen_ai.request.stream"
@request_stop_sequences "gen_ai.request.stop_sequences"
@request_reasoning_level "gen_ai.request.reasoning.level"
@response_model "gen_ai.response.model"
@response_finish_reasons "gen_ai.response.finish_reasons"
@usage_input_tokens "gen_ai.usage.input_tokens"
@usage_output_tokens "gen_ai.usage.output_tokens"
@usage_cache_read_tokens "gen_ai.usage.cache_read.input_tokens"
@usage_cache_creation_tokens "gen_ai.usage.cache_creation.input_tokens"
@usage_reasoning_tokens "gen_ai.usage.reasoning.output_tokens"
@input_messages "gen_ai.input.messages"
@output_messages "gen_ai.output.messages"
@tool_name "gen_ai.tool.name"
@tool_call_id "gen_ai.tool.call.id"
@tool_type "gen_ai.tool.type"
@tool_description "gen_ai.tool.description"
@tool_call_arguments "gen_ai.tool.call.arguments"
@tool_call_result "gen_ai.tool.call.result"
@agent_name "gen_ai.agent.name"
@conversation_id "gen_ai.conversation.id"
@server_address "server.address"
@server_port "server.port"
@doc """
Returns the `gen_ai.operation.name` attribute key.
"""
def operation_name_key, do: @operation_name
@doc """
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_start(map()) :: [{String.t(), term()}]
@spec llm_call_start(map(), Config.t()) :: [{String.t(), term()}]
def llm_call_start(metadata, %Config{} = _config \\ %Config{}) do
# All LangChain chat models are chat-completion style, so output is text unless
# the model requested structured output. `ChatModel.output_type/1` resolves
# that; default to "text" when the model didn't set it.
attrs = [
{@operation_name, "chat"},
{@output_type, metadata[:output_type] || "text"},
{@request_model, metadata[:model]}
]
attrs =
case metadata[:provider] do
nil -> attrs
provider -> [{@provider_name, ProviderMapping.to_otel(provider)} | attrs]
end
request_option_attributes(metadata[:request_options]) ++
server_attributes(metadata[:endpoint]) ++ attrs
end
# Derives `server.address` / `server.port` from the request endpoint URL. `URI`
# supplies the default port for known schemes (443 for https), which is accurate
# per the semantic convention. Returns [] when there's no usable host.
@spec server_attributes(String.t() | nil) :: [{String.t(), term()}]
defp server_attributes(endpoint) when is_binary(endpoint) do
case URI.parse(endpoint) do
%URI{host: host, port: port} when is_binary(host) and host != "" ->
attrs = [{@server_address, host}]
if port, do: [{@server_port, port} | attrs], else: attrs
_ ->
[]
end
end
defp server_attributes(_), do: []
# Maps the provider-neutral `:request_options` map (built by
# `LangChain.ChatModels.ChatModel.request_options/1`) to `gen_ai.request.*`
# semantic-convention attributes, dropping any parameter the model didn't set.
@spec request_option_attributes(map() | nil) :: [{String.t(), term()}]
defp request_option_attributes(opts) when is_map(opts) do
[
{@request_temperature, opts[:temperature]},
{@request_max_tokens, opts[:max_tokens]},
{@request_top_p, opts[:top_p]},
{@request_top_k, opts[:top_k]},
{@request_frequency_penalty, opts[:frequency_penalty]},
{@request_presence_penalty, opts[:presence_penalty]},
{@request_seed, opts[:seed]},
{@request_choice_count, opts[:choice_count]},
{@request_stream, opts[:stream]},
{@request_stop_sequences, stop_sequences(opts[:stop_sequences])},
{@request_reasoning_level, reasoning_level(opts[:reasoning_level])}
]
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
end
defp request_option_attributes(_), do: []
# `gen_ai.request.stop_sequences` is a string array. LangChain models carry the
# stop value as either a single string or a list, so normalize to a list of
# strings (dropping non-strings). Returns nil when nothing usable remains.
defp stop_sequences(nil), do: nil
defp stop_sequences(seq) when is_binary(seq), do: [seq]
defp stop_sequences(seq) when is_list(seq) do
case Enum.filter(seq, &is_binary/1) do
[] -> nil
list -> list
end
end
defp stop_sequences(_), do: nil
defp reasoning_level(nil), do: nil
defp reasoning_level(level) when is_binary(level), do: level
defp reasoning_level(level) when is_atom(level), do: Atom.to_string(level)
defp reasoning_level(_), do: nil
@doc """
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`.
"""
@spec llm_call_stop(map()) :: [{String.t(), term()}]
@spec llm_call_stop(map(), Config.t()) :: [{String.t(), term()}]
def llm_call_stop(metadata, %Config{} = config \\ %Config{}) do
attrs =
case metadata[:token_usage] do
%{input: input, output: output} = usage ->
attrs = usage_extra_attributes(usage)
attrs = if output, do: [{@usage_output_tokens, output} | attrs], else: attrs
if input, do: [{@usage_input_tokens, input} | attrs], else: attrs
_ ->
[]
end
attrs =
case metadata[:model] do
nil -> attrs
model -> [{@response_model, model} | attrs]
end
attrs = finish_reason_attributes(metadata[:result]) ++ attrs
if config.capture_output_messages do
case output_messages_from_result(metadata[:result]) do
nil -> attrs
serialized -> [{@output_messages, serialized} | attrs]
end
else
attrs
end
end
# Best-effort cache/reasoning token counts from the provider-specific
# `TokenUsage.raw` map. Anthropic reports `cache_creation_input_tokens` /
# `cache_read_input_tokens` at the top level; OpenAI Chat Completions nests
# `cached_tokens` under `prompt_tokens_details` and `reasoning_tokens` under
# `completion_tokens_details`, while the Responses API nests them under
# `input_tokens_details` / `output_tokens_details` respectively. Only positive
# counts are emitted — providers report 0 for unused cache/reasoning on every
# call, which is pure noise on the span.
@spec usage_extra_attributes(map()) :: [{String.t(), term()}]
defp usage_extra_attributes(%{raw: raw}) when is_map(raw) do
[
{@usage_cache_read_tokens, cache_read_tokens(raw)},
{@usage_cache_creation_tokens, positive_int(Map.get(raw, "cache_creation_input_tokens"))},
{@usage_reasoning_tokens, reasoning_output_tokens(raw)}
]
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
end
defp usage_extra_attributes(_), do: []
defp cache_read_tokens(raw) do
positive_int(Map.get(raw, "cache_read_input_tokens")) ||
positive_int(nested(raw, "prompt_tokens_details", "cached_tokens")) ||
positive_int(nested(raw, "input_tokens_details", "cached_tokens"))
end
defp reasoning_output_tokens(raw) do
positive_int(nested(raw, "completion_tokens_details", "reasoning_tokens")) ||
positive_int(nested(raw, "output_tokens_details", "reasoning_tokens"))
end
defp nested(map, key1, key2) do
case Map.get(map, key1) do
inner when is_map(inner) -> Map.get(inner, key2)
_ -> nil
end
end
defp positive_int(n) when is_integer(n) and n > 0, do: n
defp positive_int(_), do: nil
# Best-effort `gen_ai.response.finish_reasons` reconstructed from the assembled
# message's normalized `:status`. LangChain collapses a provider's raw finish
# reason into `Message.status` (`:complete | :length | :cancelled`), so this is
# lossy: a `:complete` message that carries tool calls maps to `"tool_calls"`,
# otherwise `:complete` -> `"stop"`. Streaming results (a delta or list of
# deltas) are merged to a message first. Returns [] when nothing is derivable.
@spec finish_reason_attributes(term()) :: [{String.t(), term()}]
defp finish_reason_attributes(result) do
case finish_reason(result) do
nil -> []
reason -> [{@response_finish_reasons, [reason]}]
end
end
defp finish_reason({:ok, %LangChain.Message{} = msg}), do: message_finish_reason(msg)
defp finish_reason({:ok, %LangChain.MessageDelta{} = delta}), do: delta_finish_reason([delta])
# `List.flatten/1` first: some providers (e.g. Mistral) return streamed deltas
# *batched* as a list of lists, whose head is a list rather than a struct — the
# head-typed clauses below would otherwise miss it and drop `finish_reasons`.
defp finish_reason({:ok, [_ | _] = items}) do
case List.flatten(items) do
[%LangChain.Message{} | _] = msgs -> msgs |> List.last() |> message_finish_reason()
[%LangChain.MessageDelta{} | _] = deltas -> delta_finish_reason(deltas)
_ -> nil
end
end
defp finish_reason(_), do: nil
defp delta_finish_reason(deltas) do
case deltas |> LangChain.MessageDelta.merge_deltas() |> LangChain.MessageDelta.to_message() do
{:ok, %LangChain.Message{} = msg} -> message_finish_reason(msg)
_ -> nil
end
end
defp message_finish_reason(%LangChain.Message{tool_calls: [_ | _]}), do: "tool_calls"
defp message_finish_reason(%LangChain.Message{status: :complete}), do: "stop"
defp message_finish_reason(%LangChain.Message{status: :length}), do: "length"
defp message_finish_reason(%LangChain.Message{status: :cancelled}), do: "cancelled"
defp message_finish_reason(_), do: nil
# Serializes the LLM output for `gen_ai.output.messages`. Streaming calls return
# a list of `%MessageDelta{}` structs (or a single delta) rather than a
# `%Message{}`; these are merged and converted to a message first so streamed
# responses are captured the same as non-streaming ones. Returns `nil` when
# there is nothing to capture.
defp output_messages_from_result({:ok, %LangChain.Message{} = msg}),
do: MessageSerializer.serialize_output(msg)
defp output_messages_from_result({:ok, %LangChain.MessageDelta{} = delta}),
do: serialize_delta_output([delta])
# `List.flatten/1` first: batched (list-of-lists) streaming shapes (e.g. Mistral)
# have a list — not a struct — at the head, which the head-typed clauses would
# miss, dropping `output.messages`.
defp output_messages_from_result({:ok, [_ | _] = items}) do
case List.flatten(items) do
[%LangChain.Message{} | _] = msgs -> MessageSerializer.serialize_output(msgs)
[%LangChain.MessageDelta{} | _] = deltas -> serialize_delta_output(deltas)
_ -> nil
end
end
defp output_messages_from_result(_), do: nil
defp serialize_delta_output(deltas) do
case deltas |> LangChain.MessageDelta.merge_deltas() |> LangChain.MessageDelta.to_message() do
{:ok, %LangChain.Message{} = msg} -> MessageSerializer.serialize_output(msg)
# An incomplete stream can't convert to a message — nothing to capture.
{:error, _reason} -> nil
end
end
@doc """
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`.
"""
@spec tool_call(map()) :: [{String.t(), term()}]
@spec tool_call(map(), Config.t()) :: [{String.t(), term()}]
def tool_call(metadata, %Config{} = config \\ %Config{}) do
attrs = [
{@operation_name, "execute_tool"},
{@tool_type, "function"}
]
attrs =
case metadata[:tool_call_id] do
nil -> attrs
id -> [{@tool_call_id, id} | attrs]
end
attrs =
case metadata[:tool_name] do
nil -> attrs
name -> [{@tool_name, name} | attrs]
end
attrs =
case metadata[:tool_description] do
desc when is_binary(desc) and desc != "" -> [{@tool_description, desc} | attrs]
_ -> attrs
end
if config.capture_tool_arguments do
case metadata[:arguments] do
nil ->
attrs
args when is_map(args) ->
[{@tool_call_arguments, Jason.encode!(args)} | attrs]
args when is_binary(args) ->
[{@tool_call_arguments, args} | attrs]
_ ->
attrs
end
else
attrs
end
end
@doc """
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`.
"""
@spec tool_call_stop(map(), Config.t()) :: [{String.t(), term()}]
def tool_call_stop(metadata, %Config{} = config) do
if config.capture_tool_results do
case metadata[:tool_result] do
%{content: content} when is_binary(content) ->
[{@tool_call_result, content}]
%{content: content} when not is_nil(content) ->
[{@tool_call_result, inspect(content)}]
_ ->
[]
end
else
[]
end
end
@doc """
Builds attributes for a chain execution event.
Sets `gen_ai.conversation.id` from `custom_context` (a `:conversation_id` key,
falling back to `:langfuse_session_id` — the same session concept) and also
extracts Langfuse-specific attributes from `custom_context` when present.
"""
@spec chain_start(map()) :: [{String.t(), term()}]
def chain_start(metadata) do
attrs = [{@operation_name, "invoke_agent"}]
attrs =
case metadata[:chain_type] do
nil -> attrs
chain_type -> [{@agent_name, chain_type} | attrs]
end
attrs =
case conversation_id(metadata[:custom_context]) do
nil -> attrs
id -> [{@conversation_id, id} | attrs]
end
case metadata[:custom_context] do
nil -> attrs
context -> custom_context_attributes(context) ++ attrs
end
end
# `gen_ai.conversation.id` groups traces into a session/thread. LangChain has no
# first-class conversation id, so it's read from `custom_context`: an explicit
# `:conversation_id`, else the `:langfuse_session_id` a user may already set.
defp conversation_id(%{conversation_id: id}) when not is_nil(id), do: id
defp conversation_id(%{langfuse_session_id: id}) when not is_nil(id), do: id
defp conversation_id(_), do: nil
@doc """
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).
"""
@spec chain_stop(map(), Config.t()) :: [{String.t(), term()}]
def chain_stop(metadata, %Config{} = config) do
attrs = []
# Extract input from the original messages (first user message)
attrs =
if config.capture_input_messages do
case extract_user_input(metadata) do
nil -> attrs
input -> [{@input_messages, input} | attrs]
end
else
attrs
end
# Extract output from last_message (the final assistant response)
if config.capture_output_messages do
case metadata[:last_message] do
%LangChain.Message{role: :assistant} = msg ->
[{@output_messages, MessageSerializer.serialize_output(msg)} | attrs]
_ ->
attrs
end
else
attrs
end
end
defp extract_user_input(metadata) do
# The chain stop metadata inherits from start, which includes the chain's messages
# via the result tuple. Try to get the first user message.
#
# A standard run terminates with `{:ok, chain}`; `:until_tool_used` terminates
# with `{:ok, chain, tool_result}` when the target tool is found. Both carry the
# chain's messages, so handle either shape.
case metadata[:result] do
{:ok, %{messages: messages}} -> first_user_input(messages)
{:ok, %{messages: messages}, _extra} -> first_user_input(messages)
_ -> nil
end
end
defp first_user_input([_ | _] = messages) do
messages
|> Enum.find(fn
%LangChain.Message{role: :user} -> true
_ -> false
end)
|> case do
nil -> nil
first_user -> MessageSerializer.serialize_input([first_user])
end
end
defp first_user_input(_), do: nil
@doc """
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 custom_context_attributes(map() | nil) :: [{String.t(), term()}]
def custom_context_attributes(nil), do: []
def custom_context_attributes(context) when is_map(context) do
attrs = []
attrs =
case Map.get(context, :langfuse_trace_name) do
nil -> attrs
name -> [{"langfuse.trace.name", name} | attrs]
end
attrs =
case Map.get(context, :langfuse_user_id) do
nil -> attrs
user_id -> [{"langfuse.user.id", user_id} | attrs]
end
attrs =
case Map.get(context, :langfuse_session_id) do
nil -> attrs
session_id -> [{"langfuse.session.id", session_id} | attrs]
end
attrs =
case Map.get(context, :langfuse_tags) do
nil -> attrs
tags when is_list(tags) -> [{"langfuse.trace.tags", Enum.join(tags, ",")} | attrs]
_ -> attrs
end
case Map.get(context, :langfuse_metadata) do
nil ->
attrs
meta when is_map(meta) ->
flat =
Enum.map(meta, fn {k, v} ->
{"langfuse.trace.metadata.#{k}", stringify_metadata_value(v)}
end)
flat ++ attrs
_ ->
attrs
end
end
def custom_context_attributes(_), do: []
# Langfuse metadata values become flat span attributes, which must be strings.
# Primitives stringify directly. Collections (maps/lists) are JSON-encoded to
# preserve their structure: a nested map has no `String.Chars` implementation
# and would make `to_string/1` raise — which, trapped by the span handler,
# silently drops the entire chain span — while a list would be lossily flattened
# as an iolist (`["x", "y"]` -> `"xy"`). JSON keeps both faithful and safe.
defp stringify_metadata_value(value) when is_binary(value), do: value
defp stringify_metadata_value(value) when is_number(value), do: to_string(value)
defp stringify_metadata_value(value) when is_atom(value), do: to_string(value)
defp stringify_metadata_value(value) do
case Jason.encode(value) do
{:ok, json} -> json
{:error, _} -> inspect(value)
end
end
end