Current section

Files

Jump to
langchain lib open_telemetry metrics_handler.ex
Raw

lib/open_telemetry/metrics_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.MetricsHandler do
@moduledoc """
Telemetry handler that re-emits LangChain telemetry events as GenAI
Semantic Convention-aligned intermediary metric events.
**Important:** This module does not directly record OpenTelemetry histograms
or counters. It emits `:telemetry.execute/3` events that must be consumed by
a metrics library to become actual OTel metrics. Without a consumer attached
to these events, `enable_metrics: true` has no visible effect.
To produce actual OTel metrics, attach a consumer such as `Telemetry.Metrics`
with an OpenTelemetry reporter, `PromEx`, or equivalent to the events below.
## Emitted events
* `[:langchain, :otel, :operation, :duration]` — with `%{duration_s: float()}`
measurement and GenAI attributes as metadata. Emitted for both successful
(`:stop`) and failed (`:exception`) operations; failures additionally carry
an `error.type` attribute so error rate is observable alongside latency.
* `[:langchain, :otel, :token, :usage]` — with `%{tokens: integer()}` measurement
and GenAI attributes (including `gen_ai.token.type`) as metadata
* `[:langchain, :otel, :operation, :time_to_first_token]` — with
`%{duration_s: float()}` measurement and GenAI attributes as metadata.
Emitted once per streaming LLM call, measuring the time from request start to
the first streamed chunk (aligns with the semantic-convention
`gen_ai.server.time_to_first_token` metric).
## Usage
This module is used internally by `LangChain.OpenTelemetry.setup/1` when
`enable_metrics: true` (the default). You typically don't need to interact
with it directly.
"""
alias LangChain.OpenTelemetry.ProviderMapping
require Logger
@handler_prefix "langchain-otel-metrics"
@doc """
Returns the list of telemetry events this handler attaches to.
"""
@spec events() :: [list(atom())]
def events do
[
[:langchain, :llm, :call, :stop],
[:langchain, :llm, :call, :exception],
[:langchain, :llm, :stream, :first_token],
[:langchain, :chain, :execute, :stop],
[:langchain, :chain, :execute, :exception],
[: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. Re-emits duration and token usage metric events.
"""
@spec handle_event(list(atom()), map(), map(), term()) :: :ok
def handle_event(event, measurements, metadata, config) do
# Like `SpanHandler`: `:telemetry` permanently detaches a handler that
# raises (VM-wide, for the rest of the run). A single bad payload must never
# silently disable metrics for every subsequent request, so we trap and log.
#
# Skip operations running inside `without_tracing/1`: spans are dropped by
# the SDK's non-recording context, but this handler has no span context, so
# it needs the explicit flag to stay consistent (no metrics for utility
# chains either).
if LangChain.OpenTelemetry.telemetry_suppressed?() do
:ok
else
do_handle_event(event, measurements, metadata, config)
end
rescue
exception ->
Logger.warning(fn ->
"[LangChain.OpenTelemetry] metrics handler failed for #{inspect(event)} and was " <>
"skipped (metrics remain attached): " <>
Exception.format(:error, exception, __STACKTRACE__)
end)
:ok
end
defp do_handle_event(event, measurements, metadata, config)
defp do_handle_event(
[:langchain, :llm, :call, :stop],
measurements,
metadata,
_config
) do
common_attrs = common_attributes("chat", metadata)
emit_duration(measurements, common_attrs)
emit_token_usage(metadata, common_attrs)
:ok
end
defp do_handle_event(
[:langchain, :chain, :execute, :stop],
measurements,
metadata,
_config
) do
common_attrs = common_attributes("invoke_agent", metadata)
emit_duration(measurements, common_attrs)
:ok
end
defp do_handle_event(
[:langchain, :tool, :call, :stop],
measurements,
metadata,
_config
) do
common_attrs = common_attributes("execute_tool", metadata)
emit_duration(measurements, common_attrs)
:ok
end
defp do_handle_event(
[:langchain, :llm, :stream, :first_token],
measurements,
metadata,
_config
) do
case measurements[:duration] do
nil ->
:ok
duration_native ->
seconds =
System.convert_time_unit(duration_native, :native, :microsecond) / 1_000_000
:telemetry.execute(
[:langchain, :otel, :operation, :time_to_first_token],
%{duration_s: seconds},
common_attributes("chat", metadata)
)
end
:ok
end
# Failed operations: emit a duration metric tagged with `error.type` so error
# rate and error latency are observable alongside successes. No token usage is
# available on a failure.
defp do_handle_event(
[:langchain, component, operation, :exception],
measurements,
metadata,
_config
) do
operation_name = operation_name_for(component, operation)
attrs =
operation_name
|> common_attributes(metadata)
|> Map.put("error.type", error_type(metadata[:error]))
emit_duration(measurements, attrs)
:ok
end
defp operation_name_for(:llm, :call), do: "chat"
defp operation_name_for(:chain, :execute), do: "invoke_agent"
defp operation_name_for(:tool, :call), do: "execute_tool"
defp error_type(%module{}), do: inspect(module)
defp error_type(_), do: "error"
defp emit_duration(measurements, attrs) do
case measurements[:duration] do
nil ->
:ok
duration_native ->
duration_s =
System.convert_time_unit(duration_native, :native, :microsecond) / 1_000_000
:telemetry.execute(
[:langchain, :otel, :operation, :duration],
%{duration_s: duration_s},
attrs
)
end
end
defp emit_token_usage(metadata, common_attrs) do
case metadata[:token_usage] do
%{input: input, output: output} ->
if input do
attrs = Map.put(common_attrs, "gen_ai.token.type", "input")
:telemetry.execute(
[:langchain, :otel, :token, :usage],
%{tokens: input},
attrs
)
end
if output do
attrs = Map.put(common_attrs, "gen_ai.token.type", "output")
:telemetry.execute(
[:langchain, :otel, :token, :usage],
%{tokens: output},
attrs
)
end
:ok
_ ->
:ok
end
end
defp common_attributes(operation_name, metadata) do
attrs = %{"gen_ai.operation.name" => operation_name}
attrs =
case metadata[:provider] do
nil -> attrs
provider -> Map.put(attrs, "gen_ai.provider.name", ProviderMapping.to_otel(provider))
end
case metadata[:model] do
nil -> attrs
model -> Map.put(attrs, "gen_ai.request.model", model)
end
end
end
end