Current section
Files
Jump to
Current section
Files
lib/agent_obs/handlers/generic.ex
defmodule AgentObs.Handlers.Generic do
@moduledoc """
Generic OpenTelemetry handler for AgentObs.
This handler creates basic OpenTelemetry spans without OpenInference semantic
conventions. Use this handler for generic OTel backends that don't support
OpenInference (e.g., Jaeger, Zipkin, generic APM tools).
## Configuration
config :agent_obs,
handlers: [AgentObs.Handlers.Generic]
config :agent_obs, AgentObs.Handlers.Generic,
endpoint: "http://localhost:4318"
## Differences from Phoenix Handler
- Does not use OpenInference semantic conventions
- Simpler attribute structure (no message flattening)
- Uses standard OTel span kinds (INTERNAL, CLIENT, etc.)
- Suitable for general-purpose observability platforms
## OpenTelemetry SDK Configuration
config :opentelemetry,
span_processor: :batch,
resource: [service: [name: "my_app"]]
config :opentelemetry_exporter,
otlp_protocol: :http_protobuf,
otlp_endpoint: System.get_env("OTEL_EXPORTER_OTLP_ENDPOINT")
"""
use GenServer
@behaviour AgentObs.Handler
require OpenTelemetry.Tracer, as: Tracer
require Logger
# Client API
@doc """
Starts the Generic handler GenServer.
"""
@spec start_link(map()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
# AgentObs.Handler callbacks
@impl AgentObs.Handler
def attach(config) do
event_prefix = Map.get(config, :event_prefix, [:agent_obs])
handler_id = {:agent_obs_generic, event_prefix, self()}
events_to_attach = [
event_prefix ++ [:agent, :start],
event_prefix ++ [:agent, :stop],
event_prefix ++ [:agent, :exception],
event_prefix ++ [:tool, :start],
event_prefix ++ [:tool, :stop],
event_prefix ++ [:tool, :exception],
event_prefix ++ [:llm, :start],
event_prefix ++ [:llm, :stop],
event_prefix ++ [:llm, :exception],
event_prefix ++ [:prompt, :start],
event_prefix ++ [:prompt, :stop],
event_prefix ++ [:prompt, :exception]
]
:ok =
:telemetry.attach_many(
handler_id,
events_to_attach,
&__MODULE__.handle_event/4,
config
)
Logger.debug(
"AgentObs.Handlers.Generic: Attached to events with prefix #{inspect(event_prefix)}"
)
{:ok, %{handler_id: handler_id, config: config}}
end
@impl AgentObs.Handler
def handle_event(event_name, measurements, metadata, _config) do
event_type = get_event_type(event_name)
phase = get_event_phase(event_name)
case phase do
:start -> handle_start(event_type, metadata)
:stop -> handle_stop(event_type, measurements, metadata)
:exception -> handle_exception(event_type, measurements, metadata)
end
:ok
rescue
exception ->
Logger.error(
"AgentObs.Handlers.Generic: Error handling event #{inspect(event_name)}: #{inspect(exception)}"
)
:ok
end
@impl AgentObs.Handler
def detach(state) do
:telemetry.detach(state.handler_id)
Logger.debug("AgentObs.Handlers.Generic: Detached from telemetry events")
:ok
end
# GenServer callbacks
@impl GenServer
def init(opts) do
{:ok, state} = attach(opts)
{:ok, state}
end
@impl GenServer
def terminate(_reason, state) do
detach(state)
end
# Private event handling functions
defp handle_start(event_type, metadata) do
attributes = build_start_attributes(event_type, metadata)
span_name = Map.get(metadata, :name, "#{event_type}_operation")
# Get current context to ensure proper parent-child relationships
parent_ctx = OpenTelemetry.Ctx.get_current()
# Start span - automatically uses current context as parent
span_ctx = Tracer.start_span(span_name, %{attributes: attributes})
# Update context to include this new span
new_ctx = OpenTelemetry.Tracer.set_current_span(parent_ctx, span_ctx)
OpenTelemetry.Ctx.attach(new_ctx)
# Store both span and parent context for later restoration
span_key = span_context_key(event_type)
Process.put(span_key, {span_ctx, parent_ctx})
:ok
end
defp handle_stop(event_type, measurements, metadata) do
span_key = span_context_key(event_type)
case Process.get(span_key) do
{_span_ctx, parent_ctx} ->
attributes = build_stop_attributes(event_type, metadata, measurements)
Tracer.set_attributes(attributes)
# Set span status based on error presence
if Map.has_key?(metadata, :error) do
error_msg = to_string_safe(metadata[:error])
Tracer.set_status(OpenTelemetry.status(:error, error_msg))
else
Tracer.set_status(OpenTelemetry.status(:ok))
end
Tracer.end_span()
# Restore parent context
OpenTelemetry.Ctx.attach(parent_ctx)
Process.delete(span_key)
nil ->
Logger.warning(
"AgentObs.Handlers.Generic: No active span found for #{event_type} :stop event"
)
end
:ok
end
defp handle_exception(event_type, _measurements, metadata) do
span_key = span_context_key(event_type)
case Process.get(span_key) do
{_span_ctx, parent_ctx} ->
kind = metadata[:kind] || :error
reason = metadata[:reason] || "Unknown error"
stacktrace = metadata[:stacktrace] || []
Tracer.record_exception(kind, reason, stacktrace)
Tracer.set_status(OpenTelemetry.status(:error, "Exception occurred"))
Tracer.end_span()
# Restore parent context
OpenTelemetry.Ctx.attach(parent_ctx)
Process.delete(span_key)
nil ->
Logger.warning(
"AgentObs.Handlers.Generic: No active span found for #{event_type} :exception event"
)
end
:ok
end
# Attribute building functions - simplified compared to Phoenix handler
defp build_start_attributes(:agent, metadata) do
%{
"agent.name" => metadata.name,
"input.value" => to_string_safe(metadata.input)
}
|> maybe_add("model", metadata[:model])
end
defp build_start_attributes(:tool, metadata) do
%{
"tool.name" => metadata.name,
"tool.arguments" => encode_json(metadata.arguments)
}
|> maybe_add("tool.description", metadata[:description])
end
defp build_start_attributes(:llm, metadata) do
%{
"llm.model" => metadata.model,
"llm.request" => encode_json(metadata)
}
end
defp build_start_attributes(:prompt, metadata) do
%{
"prompt.name" => metadata.name,
"prompt.variables" => encode_json(metadata.variables)
}
end
defp build_stop_attributes(:agent, metadata, measurements) do
%{
"output.value" => to_string_safe(metadata[:output])
}
|> maybe_add("agent.iterations", metadata[:iterations])
|> add_duration(measurements)
end
defp build_stop_attributes(:tool, metadata, measurements) do
%{
"output.value" => to_string_safe(metadata[:result])
}
|> add_duration(measurements)
end
defp build_stop_attributes(:llm, metadata, measurements) do
%{}
|> maybe_add("llm.token_count", get_in(metadata, [:tokens, :total]))
|> maybe_add("llm.cost", metadata[:cost])
|> add_duration(measurements)
end
defp build_stop_attributes(:prompt, metadata, measurements) do
%{
"output.value" => to_string_safe(metadata[:rendered])
}
|> add_duration(measurements)
end
# Private utility functions
defp get_event_type(event_name) do
event_name
|> Enum.reverse()
|> Enum.drop(1)
|> List.first()
end
defp get_event_phase(event_name) do
List.last(event_name)
end
defp span_context_key(event_type) do
:"agent_obs_generic_span_#{event_type}"
end
defp maybe_add(attrs, _key, nil), do: attrs
defp maybe_add(attrs, key, value), do: Map.put(attrs, key, value)
defp add_duration(attrs, measurements) do
case Map.get(measurements, :duration) do
nil ->
attrs
duration when is_integer(duration) ->
duration_ms = duration / 1_000_000
Map.put(attrs, "duration_ms", duration_ms)
_ ->
attrs
end
end
defp to_string_safe(value) when is_binary(value), do: value
defp to_string_safe(value) when is_atom(value), do: to_string(value)
defp to_string_safe(value), do: encode_json(value)
defp encode_json(value) do
case Jason.encode(value) do
{:ok, json} -> json
{:error, _} -> inspect(value)
end
end
end