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.agent.id`, `gen_ai.conversation.id` (from `custom_context`) * `error.type` (on failed operations) * Any attributes the caller supplies via `custom_context[:otel_attributes]` — see `passthrough_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. 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" @agent_id "gen_ai.agent.id" @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`. 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(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 attrs = 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 merge(attrs, passthrough_attributes(metadata[:custom_context])) 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`, `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_start(map()) :: [{String.t(), term()}] def chain_start(metadata) do context = metadata[:custom_context] attrs = [{@operation_name, "invoke_agent"}] attrs = case agent_name(context, metadata[:chain_type]) do nil -> attrs name -> [{@agent_name, name} | attrs] end attrs = case agent_id(context) do nil -> attrs id -> [{@agent_id, id} | attrs] end attrs = case conversation_id(context) do nil -> attrs id -> [{@conversation_id, id} | attrs] end derived = custom_context_attributes(context) ++ attrs # Passthrough wins on collision: a caller who names a key means it. merge(derived, passthrough_attributes(context)) 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 # `gen_ai.agent.name` prefers an explicit `:agent_name` from `custom_context` and # falls back to the chain type. The fallback alone is nearly useless — every # `LLMChain` reports `"llm_chain"`, so the attribute is constant and cannot group # anything — but it is what earlier versions emitted, so it stays as the default. defp agent_name(%{agent_name: name}, _chain_type) when is_binary(name) and name != "", do: name defp agent_name(_context, chain_type), do: chain_type # `gen_ai.agent.id` distinguishes individual agent instances (and sub-agents) # within a trace. Frameworks built on LangChain generally already track an agent # id; surfacing it costs them nothing beyond putting it in `custom_context`. defp agent_id(%{agent_id: id}) when is_binary(id) and id != "", do: id defp agent_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. # # Note this deliberately stays string-only and separate from `attribute_value/1` # below. Langfuse reads `langfuse.trace.metadata.*` as strings, and widening # these to native types would silently change what existing dashboards receive. 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 @doc """ 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 passthrough_attributes(map() | nil) :: [{String.t(), term()}] def passthrough_attributes(%{otel_attributes: attrs}) when is_map(attrs) do Enum.flat_map(attrs, fn {key, value} -> case {attribute_key(key), attribute_value(value)} do {nil, _} -> [] {_key, nil} -> [] {key, value} -> [{key, value}] end end) end def passthrough_attributes(_), do: [] # Attribute names must be strings. Accept atoms for convenience (`:"user.id"`) # and drop anything else rather than letting it reach the SDK. defp attribute_key(key) when is_binary(key), do: key defp attribute_key(key) when is_atom(key) and not is_nil(key), do: Atom.to_string(key) defp attribute_key(_), do: nil @doc """ 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. """ @spec attribute_value(term()) :: term() def attribute_value(nil), do: nil def attribute_value(value) when is_binary(value), do: value # `is_boolean/1` must precede `is_atom/1` — booleans are atoms in Elixir, and # OTel supports them natively, so stringifying them would lose the type. def attribute_value(value) when is_boolean(value), do: value def attribute_value(value) when is_integer(value) or is_float(value), do: value def attribute_value(value) when is_atom(value), do: Atom.to_string(value) def attribute_value(value) when is_list(value) do if native_list?(value), do: value, else: encode_value(value) end def attribute_value(value), do: encode_value(value) # OTel array attributes must be homogeneous. A mixed list (or a list of maps) # is JSON-encoded instead, which keeps it faithful rather than rejected. defp native_list?([]), do: true defp native_list?([first | _] = list) do cond do is_binary(first) -> Enum.all?(list, &is_binary/1) is_boolean(first) -> Enum.all?(list, &is_boolean/1) is_integer(first) -> Enum.all?(list, &is_integer/1) is_float(first) -> Enum.all?(list, &is_float/1) true -> false end end defp encode_value(value) do case Jason.encode(value) do {:ok, json} -> json {:error, _} -> inspect(value) end end @doc """ 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_attributes` override 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 inherited `gen_ai.request.model` can never overwrite the real per-call model on a `chat` span. """ @spec merge(list(), list()) :: list() def merge(base, override) do override_keys = MapSet.new(override, fn {key, _value} -> key end) Enum.reject(base, fn {key, _value} -> MapSet.member?(override_keys, key) end) ++ override end end