Current section

Files

Jump to
langchain lib open_telemetry span_handler.ex
Raw

lib/open_telemetry/span_handler.ex

# Guarded on the `:opentelemetry` module from the `opentelemetry_api` optional
# dep (see `LangChain.OpenTelemetry` for the full rationale) — not the SDK app.
if Code.ensure_loaded?(:opentelemetry) do
defmodule LangChain.OpenTelemetry.SpanHandler do
@moduledoc """
Telemetry handler that creates OpenTelemetry spans from LangChain telemetry events.
Spans follow a subset of the GenAI Semantic Conventions (v1.40+) — see
`LangChain.OpenTelemetry.Attributes` for the attributes that are emitted:
| Telemetry Event | OTel Span Name | Kind | `gen_ai.operation.name` |
|---------------------------------------|-----------------------------|------------|------------------------|
| `[:langchain, :chain, :execute, ...]` | `invoke_agent llm_chain` | `:internal` | `invoke_agent` |
| `[:langchain, :llm, :call, ...]` | `chat {model}` | `:client` | `chat` |
| `[:langchain, :tool, :call, ...]` | `execute_tool {tool_name}` | `:internal` | `execute_tool` |
Span hierarchy is automatic for synchronous work: chain → LLM call → tool call
run in the same process, so parent context propagation works via the process
dictionary.
> #### Async tools {: .info}
>
> Tools declared with `async: true` execute in a separate `Task` process, and
> the OpenTelemetry context is **not** inherited across a process boundary. For
> tools run by `LLMChain`'s built-in executor this is handled automatically —
> the chain captures the current context before spawning each async tool and
> re-attaches it inside the `Task`, so async tool spans nest under the chain
> span. If you spawn your own processes running LangChain operations, do the
> same yourself: capture `OpenTelemetry.Ctx.get_current/0` before spawning and
> `OpenTelemetry.Ctx.attach/1` inside the process (e.g. via the
> `:on_tool_pre_execution` callback).
## Usage
This module is used internally by `LangChain.OpenTelemetry.setup/1`. You
typically don't need to interact with it directly.
"""
alias LangChain.OpenTelemetry.Attributes
alias LangChain.OpenTelemetry.Config
alias LangChain.OpenTelemetry.MessageSerializer
require OpenTelemetry.Tracer, as: Tracer
require Logger
@handler_prefix "langchain-otel-span"
@doc """
Returns the list of telemetry events this handler attaches to.
"""
@spec events() :: [list(atom())]
def events do
[
[:langchain, :llm, :call, :start],
[:langchain, :llm, :call, :stop],
[:langchain, :llm, :call, :exception],
[:langchain, :llm, :prompt],
[:langchain, :llm, :stream, :first_token],
[:langchain, :chain, :execute, :start],
[:langchain, :chain, :execute, :stop],
[:langchain, :chain, :execute, :exception],
[:langchain, :tool, :call, :start],
[:langchain, :tool, :call, :stop],
[:langchain, :tool, :call, :exception]
]
end
@doc """
Returns the telemetry handler ID prefix used for attaching/detaching.
"""
@spec handler_id() :: String.t()
def handler_id, do: @handler_prefix
@doc """
Telemetry handler callback. Dispatches to the appropriate handler based on the event.
"""
@spec handle_event(list(atom()), map(), map(), Config.t()) :: :ok
def handle_event(event, measurements, metadata, config) do
# `:telemetry` permanently detaches a handler that raises (VM-wide, for the
# rest of the run). A single bad payload — e.g. a non-JSON-encodable message
# reaching the serializer — must never silently disable tracing for every
# subsequent request, so we trap and log instead.
do_handle_event(event, measurements, metadata, config)
rescue
exception ->
Logger.warning(fn ->
"[LangChain.OpenTelemetry] span handler failed for #{inspect(event)} and was " <>
"skipped (tracing remains attached): " <>
Exception.format(:error, exception, __STACKTRACE__)
end)
:ok
end
defp do_handle_event(event, measurements, metadata, config)
# --- LLM call events ---
defp do_handle_event(
[:langchain, :llm, :call, :start],
_measurements,
metadata,
%Config{} = config
) do
span_name = "chat #{metadata[:model] || "unknown"}"
attrs = Attributes.llm_call_start(metadata, config)
start_span(metadata, span_name, attrs, :client)
end
defp do_handle_event(
[:langchain, :llm, :call, :stop],
_measurements,
metadata,
%Config{} = config
) do
stop_attrs =
safe_stop_attributes([:langchain, :llm, :call, :stop], fn ->
Attributes.llm_call_stop(metadata, config)
end)
end_span(metadata, stop_attrs)
end
defp do_handle_event(
[:langchain, :llm, :call, :exception],
_measurements,
metadata,
%Config{}
) do
end_span_on_exception(metadata)
end
# --- LLM prompt event (opt-in message capture) ---
defp do_handle_event(
[:langchain, :llm, :prompt],
_measurements,
metadata,
%Config{} = config
) do
if config.capture_input_messages do
case {OpenTelemetry.Tracer.current_span_ctx(), metadata[:messages]} do
# No active span to attach to (e.g. the `:prompt` event fired outside
# an LLM call span). Nothing to record.
{:undefined, _} ->
:ok
{span_ctx, [_ | _] = messages} ->
OpenTelemetry.Span.set_attributes(span_ctx, [
{"gen_ai.input.messages", MessageSerializer.serialize_input(messages)}
])
_ ->
:ok
end
end
:ok
end
# --- LLM streaming: time-to-first-token ---
defp do_handle_event(
[:langchain, :llm, :stream, :first_token],
measurements,
_metadata,
%Config{}
) do
record_first_token(measurements[:duration])
end
# --- Chain execute events ---
defp do_handle_event(
[:langchain, :chain, :execute, :start],
_measurements,
metadata,
%Config{}
) do
chain_type = metadata[:chain_type] || "unknown"
span_name = "invoke_agent #{chain_type}"
attrs = Attributes.chain_start(metadata)
start_span(metadata, span_name, attrs, :internal)
end
defp do_handle_event(
[:langchain, :chain, :execute, :stop],
_measurements,
metadata,
%Config{} = config
) do
stop_attrs =
safe_stop_attributes([:langchain, :chain, :execute, :stop], fn ->
Attributes.chain_stop(metadata, config)
end)
end_span(metadata, stop_attrs)
end
defp do_handle_event(
[:langchain, :chain, :execute, :exception],
_measurements,
metadata,
%Config{}
) do
end_span_on_exception(metadata)
end
# --- Tool call events ---
defp do_handle_event(
[:langchain, :tool, :call, :start],
_measurements,
metadata,
%Config{} = config
) do
tool_name = metadata[:tool_name] || "unknown"
span_name = "execute_tool #{tool_name}"
attrs = Attributes.tool_call(metadata, config)
start_span(metadata, span_name, attrs, :internal)
end
defp do_handle_event(
[:langchain, :tool, :call, :stop],
_measurements,
metadata,
%Config{} = config
) do
stop_attrs =
safe_stop_attributes([:langchain, :tool, :call, :stop], fn ->
Attributes.tool_call_stop(metadata, config)
end)
end_span(metadata, stop_attrs)
end
defp do_handle_event(
[:langchain, :tool, :call, :exception],
_measurements,
metadata,
%Config{}
) do
end_span_on_exception(metadata)
end
# --- Private helpers ---
# Records time-to-first-token on the active LLM span. Streaming decode runs in
# the same process as the `chat` span, so the current span context IS that
# span. Adds a timestamped span event (backends can place it on the trace
# timeline) plus a numeric `gen_ai.response.time_to_first_token` attribute in
# seconds. A non-recording current span (e.g. inside `without_tracing/1`) makes
# both calls no-ops at the SDK level, so this stays correct there too.
defp record_first_token(nil), do: :ok
defp record_first_token(duration_native) do
case OpenTelemetry.Tracer.current_span_ctx() do
:undefined ->
:ok
span_ctx ->
seconds =
System.convert_time_unit(duration_native, :native, :microsecond) / 1_000_000
OpenTelemetry.Span.add_event(span_ctx, "gen_ai.first_token", %{})
OpenTelemetry.Span.set_attributes(span_ctx, [
{"gen_ai.response.time_to_first_token", seconds}
])
:ok
end
end
defp start_span(metadata, span_name, attributes, kind) do
call_id = metadata[:call_id]
parent_ctx = OpenTelemetry.Ctx.get_current()
span_ctx =
Tracer.start_span(span_name, %{
kind: kind,
attributes: attributes
})
new_ctx = OpenTelemetry.Tracer.set_current_span(parent_ctx, span_ctx)
token = OpenTelemetry.Ctx.attach(new_ctx)
if call_id do
Process.put({__MODULE__, call_id}, {span_ctx, token})
end
:ok
end
defp end_span(metadata, extra_attributes) do
call_id = metadata[:call_id]
case pop_span(call_id) do
{span_ctx, token} ->
# Ending the span and detaching its context MUST happen even if
# `set_attributes` raises — otherwise the span leaks and every later
# span in this process nests under the never-closed one. Setting the
# attributes is best-effort; closing the span is not.
try do
if extra_attributes != [] do
OpenTelemetry.Span.set_attributes(span_ctx, extra_attributes)
end
after
OpenTelemetry.Span.end_span(span_ctx)
OpenTelemetry.Ctx.detach(token)
end
:ok
nil ->
:ok
end
end
# Build a stop event's attributes without letting a failure abort the span's
# lifecycle. Attribute construction serializes message content (when the
# `capture_*_messages` flags are on), and serialization can raise on a
# non-JSON-encodable payload — e.g. an invalid-UTF-8 binary in a model or
# tool response. If that happened before `end_span/2`, the span would never
# be ended and its context never detached, silently corrupting the parent
# hierarchy of every subsequent trace in this (often long-lived) process. So
# trap here and fall back to no extra attributes: the span still closes and
# at most the enrichment attributes are dropped for this one event.
defp safe_stop_attributes(event, fun) do
fun.()
rescue
exception ->
Logger.warning(fn ->
"[LangChain.OpenTelemetry] failed to build stop attributes for #{inspect(event)}; " <>
"ending the span without them: " <>
Exception.format(:error, exception, __STACKTRACE__)
end)
[]
end
defp end_span_on_exception(metadata) do
call_id = metadata[:call_id]
case pop_span(call_id) do
{span_ctx, token} ->
# Ending the span and detaching its context MUST happen even if setting
# the error status/attributes raises (e.g. a custom exception whose
# `Exception.message/1` blows up) — otherwise the span leaks and every
# later span in this (often long-lived) process nests under the
# never-closed one. Recording the error is best-effort; closing is not.
# Mirrors `end_span/2`.
try do
error = metadata[:error]
status_message =
if error, do: Exception.message(error), else: "exception"
OpenTelemetry.Span.set_status(span_ctx, OpenTelemetry.status(:error, status_message))
# `error.type` lets GenAI-semconv backends (Langfuse, etc.) group and
# filter errors by kind. Use the exception's module name when available.
OpenTelemetry.Span.set_attributes(span_ctx, [{"error.type", error_type(error)}])
if error do
OpenTelemetry.Span.record_exception(
span_ctx,
error,
metadata[:stacktrace] || []
)
end
after
OpenTelemetry.Span.end_span(span_ctx)
OpenTelemetry.Ctx.detach(token)
end
:ok
nil ->
:ok
end
end
defp error_type(%module{}), do: inspect(module)
defp error_type(_), do: "error"
defp pop_span(nil), do: nil
defp pop_span(call_id) do
Process.delete({__MODULE__, call_id})
end
end
end