Current section

Files

Jump to
langchain lib open_telemetry.ex
Raw

lib/open_telemetry.ex

# NOTE: `:opentelemetry` here is the Erlang module shipped by the
# `opentelemetry_api` package — our declared `optional: true` dependency — NOT
# the heavier `opentelemetry` SDK app (which we only pull in `only: :test`). The
# API is what provides the `OpenTelemetry.*` / `:otel_*` compile-time macros this
# module needs, and declaring it optional gives the consumer's build a compile
# edge so it's available here whenever the app pulls OTel in. Guard on the API,
# not the SDK: an instrumentation library compiles against the API and lets the
# host app choose (and start) the SDK at runtime.
if Code.ensure_loaded?(:opentelemetry) do
defmodule LangChain.OpenTelemetry do
@moduledoc """
OpenTelemetry integration for LangChain.
Attaches to LangChain's `:telemetry` events and translates them into
OpenTelemetry spans and metrics following a subset of the
[GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) (v1.40+).
See `LangChain.OpenTelemetry.Attributes` for exactly which attributes are (and are not) emitted.
## Setup
Add the optional dependencies to your `mix.exs`:
{:opentelemetry_api, "~> 1.4"},
{:opentelemetry, "~> 1.5"},
{:opentelemetry_exporter, "~> 1.8"}
Then call `setup/1` in your application startup (e.g., `Application.start/2`):
LangChain.OpenTelemetry.setup()
## Options
Options are passed through to `LangChain.OpenTelemetry.Config`:
* `:capture_input_messages` - Record input messages as span attributes. Default: `false`.
When enabled, serializes `LangChain.Message` structs sent to the LLM into
`gen_ai.input.messages` as a JSON string. **May contain sensitive data.**
* `:capture_output_messages` - Record output messages as span attributes. Default: `false`.
When enabled, serializes the LLM response into `gen_ai.output.messages`.
**May contain sensitive data.**
* `:capture_tool_arguments` - Record tool call arguments. Default: `false`.
Serializes tool call arguments into `gen_ai.tool.call.arguments`.
* `:capture_tool_results` - Record tool call results. Default: `false`.
Captures tool return values into `gen_ai.tool.call.result`.
* `:enable_metrics` - Re-emit operation-duration and token-usage metric
events. Default: `true`. See the note below.
> #### Metrics require a consumer {: .warning}
>
> `:enable_metrics` does **not** record OpenTelemetry histograms or counters
> on its own. It re-emits LangChain telemetry as the intermediary events
> `[:langchain, :otel, :operation, :duration]` and `[:langchain, :otel, :token, :usage]`.
> To turn these into real metrics, attach a consumer such as `Telemetry.Metrics`
> (with an OpenTelemetry reporter), `PromEx`, or equivalent. Without a consumer,
> `enable_metrics: true` has no observable effect. See
> `LangChain.OpenTelemetry.MetricsHandler` for the emitted event shapes.
## Span Mapping
| Telemetry Event | OTel Span Name | Kind |
|---------------------------------------|-----------------------------|------------|
| `[:langchain, :chain, :execute, ...]` | `invoke_agent llm_chain` | `:internal` |
| `[:langchain, :llm, :call, ...]` | `chat {model}` | `:client` |
| `[:langchain, :tool, :call, ...]` | `execute_tool {tool_name}` | `:internal` |
## Langfuse Integration
[Langfuse](https://langfuse.com/) can ingest OpenTelemetry traces via its
OTLP-compatible endpoint. Configure the OTel exporter to point at your
Langfuse instance:
# config/runtime.exs
config :opentelemetry_exporter,
otlp_protocol: :http_protobuf,
otlp_endpoint: "https://your-langfuse-host/api/public/otel",
otlp_headers: [
{"Authorization", "Basic " <> Base.encode64("pk-lf-...:sk-lf-...")}
]
### Langfuse-specific attributes via `custom_context`
Set `custom_context` on your `LLMChain` to propagate Langfuse trace attributes:
chain =
%{llm: llm, messages: messages}
|> LLMChain.new!()
|> Map.put(:custom_context, %{
langfuse_user_id: current_user.id,
langfuse_session_id: session_id,
langfuse_tags: ["production", "v2"],
langfuse_metadata: %{env: "prod", feature: "chat"}
})
These are mapped to span attributes on the root chain span:
| `custom_context` key | Span attribute |
|-------------------------|------------------------------------|
| `: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.*` |
"""
alias LangChain.OpenTelemetry.Config
alias LangChain.OpenTelemetry.SpanHandler
alias LangChain.OpenTelemetry.MetricsHandler
@doc """
Attaches OpenTelemetry handlers to LangChain telemetry events.
## Examples
LangChain.OpenTelemetry.setup()
LangChain.OpenTelemetry.setup(capture_input_messages: true, enable_metrics: false)
"""
@spec setup(keyword()) :: :ok
def setup(opts \\ []) do
config = Config.new(opts)
:telemetry.attach_many(
SpanHandler.handler_id(),
SpanHandler.events(),
&SpanHandler.handle_event/4,
config
)
if config.enable_metrics do
:telemetry.attach_many(
MetricsHandler.handler_id(),
MetricsHandler.events(),
&MetricsHandler.handle_event/4,
config
)
end
:ok
end
# Process-dictionary flag read by `MetricsHandler` to skip re-emitting metric
# events for operations inside a `without_tracing/1` block. Spans are dropped
# by the SDK via the non-recording context, but the metrics handler is a plain
# telemetry consumer with no span context, so it needs this out-of-band signal
# to stay consistent with "no observability for utility chains".
@suppression_key {__MODULE__, :suppress_telemetry}
@doc """
Executes a function with OpenTelemetry tracing suppressed.
Any LangChain operations inside the function will NOT create OTEL spans or
traces, and — when metrics are enabled — will NOT emit duration/token metric
events either. Useful for lightweight utility chains (translation, topic
generation) that should not appear in your traces or skew your metrics.
Suppression is scoped to the calling process. Work that runs in a separate
process spawned inside the block (e.g. an `async: true` tool) does not inherit
it, the same way OTel span context does not cross the process boundary.
## Example
LangChain.OpenTelemetry.without_tracing(fn ->
LLM.chain() |> LLMChain.run()
end)
"""
@spec without_tracing((-> result)) :: result when result: any()
def without_tracing(fun) when is_function(fun, 0) do
# Create a non-recording span context and attach it.
# Child spans inherit the non-recording flag and are silently dropped
# by the SDK, so no traces are exported for operations inside this block.
trace_id = apply(:otel_id_generator, :generate_trace_id, [])
span_id = apply(:otel_id_generator, :generate_span_id, [])
span_ctx = :otel_tracer.non_recording_span(trace_id, span_id, 0)
ctx = OpenTelemetry.Ctx.get_current()
new_ctx = OpenTelemetry.Tracer.set_current_span(ctx, span_ctx)
token = OpenTelemetry.Ctx.attach(new_ctx)
# Remember the prior flag so nested `without_tracing/1` calls restore it
# rather than clearing suppression when the inner block exits.
previous = Process.put(@suppression_key, true)
try do
fun.()
after
OpenTelemetry.Ctx.detach(token)
case previous do
nil -> Process.delete(@suppression_key)
prev -> Process.put(@suppression_key, prev)
end
end
end
@doc false
@spec telemetry_suppressed?() :: boolean()
def telemetry_suppressed?, do: Process.get(@suppression_key, false) == true
@doc """
Detaches all OpenTelemetry handlers from LangChain telemetry events.
"""
@spec teardown() :: :ok
def teardown do
for id <- [SpanHandler.handler_id(), MetricsHandler.handler_id()] do
case :telemetry.detach(id) do
:ok -> :ok
{:error, :not_found} -> :ok
end
end
:ok
end
end
end