From e95f2d726ba92e0fb84fe042afcabbb933fcf9ec Mon Sep 17 00:00:00 2001 From: lostbean Date: Sun, 8 Mar 2026 23:26:09 -0300 Subject: [PATCH 1/2] feat: add JidoTracer for Jido.Observe.Tracer integration Implement AgentObs.JidoTracer that bridges Jido composer telemetry events to OpenTelemetry spans with OpenInference semantic conventions. Maps [:jido, :composer, :agent|:llm|:tool] event prefixes to AgentObs event types and delegates to Phoenix.Translator for attribute generation. - Add jido ~> 2.0 as optional dependency - Classify event prefixes to :agent, :llm, :tool types - Translate Jido metadata (query, model, conversation, tool_name) to AgentObs format - Manage OTel span context nesting with parent restoration - Handle nil/error cases gracefully without crashing - 21 tests covering behaviour compliance, routing, metadata, nesting, errors --- lib/agent_obs/handlers/phoenix/translator.ex | 49 +- lib/agent_obs/jido_tracer.ex | 259 ++++++++ mix.exs | 1 + mix.lock | 58 +- test/agent_obs/jido_tracer_test.exs | 603 +++++++++++++++++++ test/support/test_helper.exs | 208 ++++--- 6 files changed, 1066 insertions(+), 112 deletions(-) create mode 100644 lib/agent_obs/jido_tracer.ex create mode 100644 test/agent_obs/jido_tracer_test.exs diff --git a/lib/agent_obs/handlers/phoenix/translator.ex b/lib/agent_obs/handlers/phoenix/translator.ex index deed976..948429c 100644 --- a/lib/agent_obs/handlers/phoenix/translator.ex +++ b/lib/agent_obs/handlers/phoenix/translator.ex @@ -14,7 +14,7 @@ defmodule AgentObs.Handlers.Phoenix.Translator do - `AGENT` - Agent loop or orchestration - `LLM` - Large Language Model API call - `TOOL` - Tool or function execution - - `CHAIN` - Sequence of operations (not used in AgentObs currently) + - `CHAIN` - Sequence of operations (e.g., orchestrator iteration) - `RETRIEVER` - Vector/document retrieval (not used in AgentObs currently) ## Key Attributes @@ -39,7 +39,7 @@ defmodule AgentObs.Handlers.Phoenix.Translator do ## Parameters - - `event_type` - One of `:agent`, `:tool`, `:llm`, `:prompt` + - `event_type` - One of `:agent`, `:tool`, `:llm`, `:chain`, `:prompt` - `metadata` - The start metadata from AgentObs event ## Returns @@ -65,6 +65,7 @@ defmodule AgentObs.Handlers.Phoenix.Translator do "tool.name" => metadata.name } |> maybe_add("tool.description", metadata[:description]) + |> maybe_add("input.value", to_json_safe(metadata[:arguments])) |> add_tool_arguments(metadata[:arguments]) end @@ -81,6 +82,15 @@ defmodule AgentObs.Handlers.Phoenix.Translator do |> maybe_add("ai.model.id", metadata.model) |> maybe_add("ai.model.provider", extract_provider(metadata.model)) |> Map.merge(flatten_input_messages(metadata[:input_messages])) + |> maybe_add("input.value", extract_llm_input_text(metadata[:input_messages])) + end + + def from_start_metadata(:chain, metadata) do + %{ + "openinference.span.kind" => "CHAIN" + } + |> maybe_add("metadata.iteration", metadata[:iteration]) + |> maybe_add("input.value", to_json_safe(metadata[:input])) end def from_start_metadata(:prompt, metadata) do @@ -97,7 +107,7 @@ defmodule AgentObs.Handlers.Phoenix.Translator do ## Parameters - - `event_type` - One of `:agent`, `:tool`, `:llm`, `:prompt` + - `event_type` - One of `:agent`, `:tool`, `:llm`, `:chain`, `:prompt` - `metadata` - The stop metadata from AgentObs event - `measurements` - Measurements map containing duration @@ -134,7 +144,7 @@ defmodule AgentObs.Handlers.Phoenix.Translator do |> maybe_add("llm.token_count.completion", get_in(metadata, [:tokens, :completion])) |> maybe_add("llm.token_count.total", get_in(metadata, [:tokens, :total])) |> maybe_add("llm.cost.total", metadata[:cost]) - |> maybe_add("output.value", metadata[:finish_reason]) + |> maybe_add("output.value", extract_llm_output_text(metadata[:output_messages])) # Add gen_ai usage attributes |> maybe_add("gen_ai.usage.input_tokens", get_in(metadata, [:tokens, :prompt])) |> maybe_add("gen_ai.usage.output_tokens", get_in(metadata, [:tokens, :completion])) @@ -145,6 +155,12 @@ defmodule AgentObs.Handlers.Phoenix.Translator do |> add_duration(measurements) end + def from_stop_metadata(:chain, metadata, measurements) do + %{} + |> maybe_add("output.value", to_json_safe(metadata[:output])) + |> add_duration(measurements) + end + def from_stop_metadata(:prompt, metadata, measurements) do %{ "output.value" => to_json_safe(metadata[:rendered]) @@ -157,7 +173,7 @@ defmodule AgentObs.Handlers.Phoenix.Translator do ## Parameters - - `event_type` - One of `:agent`, `:tool`, `:llm`, `:prompt` + - `event_type` - One of `:agent`, `:tool`, `:llm`, `:chain`, `:prompt` - `metadata` - The exception metadata from telemetry - `measurements` - Measurements map containing duration @@ -182,6 +198,27 @@ defmodule AgentObs.Handlers.Phoenix.Translator do # Private helper functions + defp extract_llm_input_text(nil), do: nil + defp extract_llm_input_text([]), do: nil + + defp extract_llm_input_text(messages) do + messages + |> Enum.reverse() + |> Enum.find(fn msg -> get_message_field(msg, :role) in ["user", :user] end) + |> case do + nil -> nil + msg -> to_json_safe(get_message_field(msg, :content)) + end + end + + defp extract_llm_output_text(nil), do: nil + defp extract_llm_output_text([]), do: nil + + defp extract_llm_output_text([first | _]) do + content = get_message_field(first, :content) + if content, do: to_json_safe(content) + end + defp flatten_input_messages(nil), do: %{} defp flatten_input_messages(messages) when is_list(messages) do @@ -304,12 +341,12 @@ defmodule AgentObs.Handlers.Phoenix.Translator do end) end + defp to_json_safe(nil), do: nil defp to_json_safe(value) when is_binary(value), do: value defp to_json_safe(value) when is_number(value), do: value defp to_json_safe(value) when is_boolean(value), do: value defp to_json_safe(value) when is_atom(value), do: to_string(value) defp to_json_safe(value) when is_map(value) or is_list(value), do: encode_json(value) - defp to_json_safe(nil), do: nil defp encode_json(value) do case Jason.encode(value) do diff --git a/lib/agent_obs/jido_tracer.ex b/lib/agent_obs/jido_tracer.ex new file mode 100644 index 0000000..d5614b2 --- /dev/null +++ b/lib/agent_obs/jido_tracer.ex @@ -0,0 +1,259 @@ +defmodule AgentObs.JidoTracer do + @moduledoc """ + Jido.Observe.Tracer implementation that bridges Jido composer events to + AgentObs OpenTelemetry spans with OpenInference semantic conventions. + + This module translates Jido's event prefixes and metadata into the AgentObs + format expected by `AgentObs.Handlers.Phoenix.Translator`, then creates and + manages OpenTelemetry spans with proper parent-child nesting. + + ## Configuration + + In your Jido application config: + + config :jido, :observability, + tracer: AgentObs.JidoTracer + + ## Event Prefix Mapping + + | Jido prefix | AgentObs type | + |---|---| + | `[:jido, :composer, :agent, ...]` | `:agent` | + | `[:jido, :composer, :llm, ...]` | `:llm` | + | `[:jido, :composer, :tool, ...]` | `:tool` | + | anything else | `:agent` (fallback) | + """ + + @behaviour Jido.Observe.Tracer + + require OpenTelemetry.Tracer, as: Tracer + require Logger + + alias AgentObs.Handlers.Phoenix.Translator + + @type tracer_ctx :: %{ + otel_span_ctx: term(), + parent_ctx: term(), + event_type: :agent | :llm | :tool | :chain + } + + @impl Jido.Observe.Tracer + @spec span_start([atom()], map()) :: tracer_ctx() + def span_start(event_prefix, metadata) do + event_type = classify_event_type(event_prefix) + translated = translate_start_metadata(event_type, metadata) + span_name = build_span_name(event_type, metadata, event_prefix) + + attributes = Translator.from_start_metadata(event_type, translated) + + parent_ctx = OpenTelemetry.Ctx.get_current() + span_ctx = Tracer.start_span(span_name, %{attributes: attributes}) + new_ctx = OpenTelemetry.Tracer.set_current_span(parent_ctx, span_ctx) + OpenTelemetry.Ctx.attach(new_ctx) + + %{ + otel_span_ctx: span_ctx, + parent_ctx: parent_ctx, + event_type: event_type + } + rescue + e -> + Logger.warning("AgentObs.JidoTracer span_start failed: #{inspect(e)}") + %{otel_span_ctx: nil, parent_ctx: nil, event_type: classify_event_type(event_prefix)} + end + + @impl Jido.Observe.Tracer + @spec span_stop(term(), map()) :: :ok + def span_stop(nil, _measurements), do: :ok + + def span_stop(%{otel_span_ctx: nil}, _measurements), do: :ok + + def span_stop( + %{otel_span_ctx: span_ctx, parent_ctx: parent_ctx, event_type: event_type}, + measurements + ) do + translated = translate_stop_metadata(event_type, measurements) + attributes = Translator.from_stop_metadata(event_type, translated, measurements) + + OpenTelemetry.Span.set_attributes(span_ctx, attributes) + + if Map.has_key?(measurements, :error) do + error_msg = inspect(measurements[:error]) + OpenTelemetry.Span.set_status(span_ctx, OpenTelemetry.status(:error, error_msg)) + else + OpenTelemetry.Span.set_status(span_ctx, OpenTelemetry.status(:ok)) + end + + OpenTelemetry.Span.end_span(span_ctx) + OpenTelemetry.Ctx.attach(parent_ctx) + + :ok + rescue + e -> + Logger.warning("AgentObs.JidoTracer span_stop failed: #{inspect(e)}") + :ok + end + + @impl Jido.Observe.Tracer + @spec span_exception(term(), atom(), term(), list()) :: :ok + def span_exception(nil, _kind, _reason, _stacktrace), do: :ok + + def span_exception(%{otel_span_ctx: nil}, _kind, _reason, _stacktrace), do: :ok + + def span_exception( + %{otel_span_ctx: span_ctx, parent_ctx: parent_ctx, event_type: event_type}, + kind, + reason, + stacktrace + ) do + exception_metadata = %{kind: kind, reason: reason, stacktrace: stacktrace} + attributes = Translator.from_exception_metadata(event_type, exception_metadata, %{}) + + OpenTelemetry.Span.set_attributes(span_ctx, attributes) + + if is_exception(reason) do + OpenTelemetry.Span.record_exception(span_ctx, reason, stacktrace) + end + + error_message = format_exception_message(kind, reason) + OpenTelemetry.Span.set_status(span_ctx, OpenTelemetry.status(:error, error_message)) + + OpenTelemetry.Span.end_span(span_ctx) + OpenTelemetry.Ctx.attach(parent_ctx) + + :ok + rescue + e -> + Logger.warning("AgentObs.JidoTracer span_exception failed: #{inspect(e)}") + :ok + end + + # -- Event type classification -- + + defp classify_event_type([:jido, :composer, :agent | _]), do: :agent + defp classify_event_type([:jido, :composer, :llm | _]), do: :llm + defp classify_event_type([:jido, :composer, :tool | _]), do: :tool + defp classify_event_type([:jido, :composer, :iteration | _]), do: :chain + defp classify_event_type(_), do: :agent + + # -- Metadata translation (Jido → AgentObs format) -- + + defp translate_start_metadata(:agent, metadata) do + %{ + input: metadata[:query] || metadata[:input] || "", + name: metadata[:name] || metadata[:agent_module] || "agent" + } + |> maybe_put(:model, metadata[:model]) + end + + defp translate_start_metadata(:llm, metadata) do + base = + %{ + model: metadata[:model] || "unknown" + } + |> maybe_put(:iteration, metadata[:iteration]) + + # Prefer input_messages, fall back to extracting from conversation + messages = + cond do + metadata[:input_messages] -> + metadata[:input_messages] + + metadata[:conversation] -> + normalize_messages(metadata[:conversation]) + + true -> + nil + end + + if messages, do: Map.put(base, :input_messages, messages), else: base + end + + defp translate_start_metadata(:tool, metadata) do + %{ + name: metadata[:tool_name] || metadata[:node_name] || metadata[:name] || "tool", + arguments: metadata[:arguments] || metadata[:params] + } + end + + defp translate_start_metadata(:chain, metadata) do + %{iteration: metadata[:iteration] || 1} + |> maybe_put(:input, metadata[:input]) + end + + defp translate_stop_metadata(:agent, measurements) do + %{} + |> maybe_put(:output, measurements[:result] || measurements[:output]) + |> maybe_put(:iterations, measurements[:iterations]) + |> maybe_put(:tokens, measurements[:tokens]) + end + + defp translate_stop_metadata(:llm, measurements) do + %{} + |> maybe_put(:tokens, measurements[:tokens]) + |> maybe_put(:output_messages, measurements[:output_messages]) + |> maybe_put(:finish_reason, measurements[:finish_reason]) + end + + defp translate_stop_metadata(:tool, measurements) do + %{ + result: measurements[:result] || measurements[:output] + } + end + + defp translate_stop_metadata(:chain, measurements) do + %{} + |> maybe_put(:output, measurements[:output] || measurements[:result]) + end + + # -- Span name building -- + + defp build_span_name(:agent, metadata, _prefix) do + metadata[:name] || metadata[:agent_module] || "agent" + end + + defp build_span_name(:llm, metadata, _prefix) do + model = metadata[:model] || "llm_call" + + case metadata[:iteration] do + nil -> model + n -> "#{model} ##{n}" + end + end + + defp build_span_name(:tool, metadata, _prefix) do + metadata[:tool_name] || metadata[:node_name] || metadata[:name] || "tool_call" + end + + defp build_span_name(:chain, metadata, _prefix) do + "iteration #{metadata[:iteration] || 1}" + end + + # -- Helpers -- + + defp normalize_messages(messages) when is_list(messages) do + Enum.map(messages, fn msg -> + %{ + role: to_string(msg[:role] || Map.get(msg, "role", "unknown")), + content: msg[:content] || Map.get(msg, "content", "") + } + end) + end + + defp normalize_messages(_), do: nil + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp format_exception_message(kind, reason) when is_exception(reason) do + "#{kind}: #{Exception.message(reason)}" + end + + defp format_exception_message(kind, reason) when is_binary(reason) do + "#{kind}: #{reason}" + end + + defp format_exception_message(kind, reason) do + "#{kind}: #{inspect(reason)}" + end +end diff --git a/mix.exs b/mix.exs index f1cfc64..2cadc66 100644 --- a/mix.exs +++ b/mix.exs @@ -39,6 +39,7 @@ defmodule AgentObs.MixProject do # Optional dependencies for integrations {:req_llm, "~> 1.0", optional: true}, + {:jido, "~> 2.0", optional: true}, # Development and testing dependencies {:ex_doc, "~> 0.28", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 158515b..a23860e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,9 +1,13 @@ %{ + "abacus": {:hex, :abacus, "2.1.0", "b6db5c989ba3d9dd8c36d1cb269e2f0058f34768d47c67eb8ce06697ecb36dd4", [:mix], [], "hexpm", "255de08b02884e8383f1eed8aa31df884ce0fb5eb394db81ff888089f2a1bbff"}, "abnf_parsec": {:hex, :abnf_parsec, "2.1.0", "c4e88d5d089f1698297c0daced12be1fb404e6e577ecf261313ebba5477941f9", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e0ed6290c7cc7e5020c006d1003520390c9bdd20f7c3f776bd49bfe3c5cd362a"}, "acceptor_pool": {:hex, :acceptor_pool, "1.0.1", "d88c2e8a0be9216cf513fbcd3e5a4beb36bee3ff4168e85d6152c6f899359cdb", [:rebar3], [], "hexpm", "f172f3d74513e8edd445c257d596fc84dbdd56d2c6fa287434269648ae5a421e"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, - "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, + "crontab": {:hex, :crontab, "1.1.14", "233fcfdc2c74510cabdbcb800626babef414e7cb13cea11ddf62e10e16e2bf76", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "4e3b9950bc22ae8d0395ffb5f4b127a140005cba95745abf5ff9ee7e8203c6fa"}, "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, @@ -11,41 +15,67 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "ex_aws_auth": {:hex, :ex_aws_auth, "1.3.1", "3963992d6f7cb251b53573603c3615cec70c3f4d86199fdb865ff440295ef7a4", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: true]}], "hexpm", "025793aa08fa419aabdb652db60edbdb2e12346bd447988a1bb5854c4dd64903"}, - "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "fuse": {:hex, :fuse, "2.5.0", "71afa90be21da4e64f94abba9d36472faa2d799c67fedc3bd1752a88ea4c4753", [:rebar3], [], "hexpm", "7f52a1c84571731ad3c91d569e03131cc220ebaa7e2a11034405f0bac46a4fef"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "jido_keys": {:hex, :jido_keys, "1.0.0", "3a0b79ca00aa3a7ddaeb8012a644d799565d7ea06308b6508c5664558dea87db", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "e319197e655ba6d9f18fa1fc8a4d619fa2f0210c9f1aa0d9a0e63dc2dcf5d866"}, - "jsv": {:hex, :jsv, "0.14.0", "aa685a48b07a5e93b503f9130f22fbc6a33bfcb4d7fdcd67c465e1888a3a1e49", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.1", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:poison, ">= 3.0.0 and < 7.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:texture, "~> 0.3", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "74cb0142aa03044c988cec2370cddd6dfd8fa3a313e725deb3d4a54b7573fed6"}, - "llm_db": {:hex, :llm_db, "2025.12.4", "e568546f1ca0aa937296d016416eff6da1e75b518df059792d0833efd6dc1ade", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "b3029a337e6648acd56853c05fc693af75a43e6e006b66c4a35315665c250d42"}, + "jido": {:hex, :jido, "2.0.0", "2e062987cee8d372dfbfe2b52dee2779eb9b195024662e2bae77564d35ded705", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jido_action, "~> 2.0", [hex: :jido_action, repo: "hexpm", optional: false]}, {:jido_signal, "~> 2.0", [hex: :jido_signal, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:ok, "~> 2.3", [hex: :ok, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:sched_ex, "~> 1.1", [hex: :sched_ex, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.1", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6.1", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "5ed8f7db5e06e31b190d8ffb687328df0f8ecff543edcd11a6fe8891499ebe09"}, + "jido_action": {:hex, :jido_action, "2.0.0", "a19f571e36eea8bf40a645ae1a42e3e19096482e7c42454c698ca47edb2e1aed", [:mix], [{:abacus, "~> 2.1", [hex: :abacus, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16.0", [hex: :libgraph, repo: "hexpm", optional: false]}, {:lua, "~> 0.3", [hex: :lua, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:private, "~> 0.1.2", [hex: :private, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10", [hex: :req, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.1", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:tentacat, "~> 2.5", [hex: :tentacat, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6.1", [hex: :uniq, repo: "hexpm", optional: false]}, {:weather, "~> 0.4.0", [hex: :weather, repo: "hexpm", optional: false]}, {:zoi, "~> 0.17", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "096c0319900677b50f7c2960f6108455231de713ad9c56c15ff6c9b8ca850d29"}, + "jido_signal": {:hex, :jido_signal, "2.0.0", "b203b516eea0ee13c4f274c7a5b3d20e457a60f0528c32b7bec6a84927d4a66b", [:mix], [{:fuse, "~> 2.5", [hex: :fuse, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:memento, "~> 0.5.0", [hex: :memento, repo: "hexpm", optional: false]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6.1", [hex: :uniq, repo: "hexpm", optional: false]}, {:zoi, "~> 0.17.1", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "9c7f9bc8645d3e32b66bfd7760a774418eb7e1d0ee89cb71792cbe500ebf2c0d"}, + "jsv": {:hex, :jsv, "0.16.0", "b29e44da822db9f52010edf9db75b58f016434d9862bd76d18aec7a4712cf318", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.1", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:poison, ">= 3.0.0 and < 7.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:texture, "~> 0.3", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "a4b2aaf5f62641640519da5de479e5704f6f7c8b6e323692bf71b4800d7b69ee"}, + "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, + "llm_db": {:hex, :llm_db, "2026.3.0", "31c4235c52280cff46c166ffb19a2a53734e8fda44c82864f3f38521e7bc4c2d", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "4c9cbc6f47eb6d62eb52bca296692f9171c963e3eb3af69f3a555e8c5cff391e"}, + "lua": {:hex, :lua, "0.4.0", "de0f04871fdd133cd13a0662690b4fd3ba7a73ca5857493c4665a0a4251908fe", [:mix], [{:luerl, "~> 1.5.1", [hex: :luerl, repo: "hexpm", optional: false]}], "hexpm", "648e17ab9faa1ab1a788fa58ed608366a7026d0eeaec2f311510c065817c4067"}, + "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "memento": {:hex, :memento, "0.5.0", "9c6943aa9c4c792b19ab2862159e2f7f5b7ec011801e3270b0bf220f15cb6aed", [:mix], [], "hexpm", "f4c2108737640a0e9d3cd2f230f46863d746d5ab333b0ecd28619a2ae330d881"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "ok": {:hex, :ok, "2.3.0", "0a3d513ec9038504dc5359d44e14fc14ef59179e625563a1a144199cdc3a6d30", [:mix], [], "hexpm", "f0347b3f8f115bf347c704184b33cf084f2943771273f2b98a3707a5fa43c4d5"}, "opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"}, "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.10.0", "972e142392dbfa679ec959914664adefea38399e4f56ceba5c473e1cabdbad79", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.7.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "33a116ed7304cb91783f779dec02478f887c87988077bfd72840f760b8d4b952"}, - "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, - "req_llm": {:hex, :req_llm, "1.2.0", "f34300bbc6734e67fc4c7faa18790453df077f899e17a1e70c22c269ec2d743c", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.3", [hex: :ex_aws_auth, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, "~> 2025.12", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 0.2", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.2.3", [hex: :splode, repo: "hexpm", optional: false]}, {:typedstruct, "~> 0.5", [hex: :typedstruct, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "7e793d35d6ec4da3c83d91647f8ba81f0532883d5f15c5e20331e950efa8a0da"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "private": {:hex, :private, "0.1.2", "da4add9f36c3818a9f849840ca43016c8ae7f76d7a46c3b2510f42dcc5632932", [:mix], [], "hexpm", "22ee01c3f450cf8d135da61e10ec59dde006238fab1ea039014791fc8f3ff075"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "req_llm": {:hex, :req_llm, "1.6.0", "9866726cb590848e4f68cfb0ed8030509219dd9e81880e395d1b0b273d2102e6", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.3", [hex: :ex_aws_auth, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, "~> 2026.1", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 0.2", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "0711ae09fa297e1e842837b0259ea179f9212893420abd9cb93a020b6bf69348"}, + "sched_ex": {:hex, :sched_ex, "1.1.4", "893de8ffcb1590ae6089d9862d49447fbb948535ba5777c231e55c8404bc3e6e", [:mix], [{:crontab, "~> 1.1.2", [hex: :crontab, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "f32336c40a62aba581c57d6d6009e40e5c52011bb8305fc3a33ef9e5815b861c"}, "server_sent_events": {:hex, :server_sent_events, "0.2.1", "f83b34f01241302a8bf451efc8dde3a36c533d5715463c31c653f3db8695f636", [:mix], [], "hexpm", "c8099ce4f9acd610eb7c8e0f89dba7d5d1c13300ea9884b0bd8662401d3cf96f"}, - "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, + "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry": {:hex, :telemetry, "1.4.0", "69aa83d53f152f93f05fd40b77ded6fab247de093b7a3c4ca2879e634144446e", [:rebar3], [], "hexpm", "d1ff426f988ac1092f9d684d34d08e51042a70567c16be793fbc8f399fd2e77d"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "tentacat": {:hex, :tentacat, "2.5.0", "d0177ae1289faf6814a85aea044bcdc1ca64a4b1f961e44189451d5c9060a662", [:mix], [{:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c7d3d34a56d5dc870c2155444f7d6cd0e6959dd65c16bb9174442e347f34334f"}, "texture": {:hex, :texture, "0.3.2", "ca68fc2804ce05ffe33cded85d69b5ebadb0828233227accfe3c574e34fd4e3f", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}], "hexpm", "43bb1069d9cf4309ed6f0ff65ade787a76f986b821ab29d1c96b5b5102cb769c"}, + "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, "tls_certificate_check": {:hex, :tls_certificate_check, "1.31.0", "9a910b54d8cb96cc810cabf4c0129f21360f82022b20180849f1442a25ccbb04", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "9d2b41b128d5507bd8ad93e1a998e06d0ab2f9a772af343f4c00bf76c6be1532"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, - "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, - "typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"}, + "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, + "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "uniq": {:hex, :uniq, "0.6.2", "51846518c037134c08bc5b773468007b155e543d53c8b39bafe95b0af487e406", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "95aa2a41ea331ef0a52d8ed12d3e730ef9af9dbc30f40646e6af334fbd7bc0fc"}, - "zoi": {:hex, :zoi, "0.14.0", "d193e9d31b2cabb674f369c2ffcc1b1397780914b0718e8dd2a8c032962a0168", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "745bf18af7905f55d98d3f1ad5bf8aafb5c2d184f220cb3433f0d83738ebb917"}, + "weather": {:hex, :weather, "0.4.0", "8ca733d3f78cbc81fed23178aa58e38f706e96283930e884514e56fb7e6f6777", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:tz, "~> 0.27", [hex: :tz, repo: "hexpm", optional: false]}], "hexpm", "718d7b714d0f2daf4b799515c04025c45757ff08a703246437409432c346a7f7"}, + "zoi": {:hex, :zoi, "0.17.1", "406aa87bb4181f41dee64336b75434367b7d3e88db813b0e6db0ae2d0f81f743", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "3a11bf3bc9189f988ac74e81b5d7ca0c689b2a20eed220746a7043aa528e2aab"}, } diff --git a/test/agent_obs/jido_tracer_test.exs b/test/agent_obs/jido_tracer_test.exs new file mode 100644 index 0000000..cd563e0 --- /dev/null +++ b/test/agent_obs/jido_tracer_test.exs @@ -0,0 +1,603 @@ +defmodule AgentObs.JidoTracerTest do + use ExUnit.Case, async: false + + alias AgentObs.Handlers.Phoenix + alias AgentObs.Handlers.Phoenix.Translator + alias AgentObs.JidoTracer + + @moduletag :capture_log + + setup do + Application.ensure_all_started(:opentelemetry) + Application.ensure_all_started(:opentelemetry_exporter) + + # Attach Phoenix handler so OTel spans get created + config = %{event_prefix: [:agent_obs]} + {:ok, handler_state} = Phoenix.attach(config) + + on_exit(fn -> + Phoenix.detach(handler_state) + + Process.get_keys() + |> Enum.filter(fn + key when is_atom(key) -> String.starts_with?(Atom.to_string(key), "agent_obs_") + _ -> false + end) + |> Enum.each(&Process.delete/1) + end) + + :ok + end + + describe "behaviour compliance" do + test "implements span_start/2 and returns valid tracer_ctx" do + ctx = JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + assert is_map(ctx) + assert Map.has_key?(ctx, :otel_span_ctx) + assert Map.has_key?(ctx, :parent_ctx) + assert Map.has_key?(ctx, :event_type) + end + + test "implements span_stop/2 and returns :ok" do + ctx = JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + result = JidoTracer.span_stop(ctx, %{duration: 1_000_000}) + assert result == :ok + end + + test "implements span_exception/4 and returns :ok" do + ctx = JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + + result = + JidoTracer.span_exception( + ctx, + :error, + %RuntimeError{message: "boom"}, + [{__MODULE__, :test, 0, []}] + ) + + assert result == :ok + end + end + + describe "event prefix routing" do + test "[:jido, :composer, :agent] maps to :agent event type" do + ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + + assert ctx.event_type == :agent + JidoTracer.span_stop(ctx, %{}) + end + + test "[:jido, :composer, :llm] maps to :llm event type" do + ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "anthropic:claude-sonnet-4-20250514" + }) + + assert ctx.event_type == :llm + JidoTracer.span_stop(ctx, %{}) + end + + test "[:jido, :composer, :tool] maps to :tool event type" do + ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{tool_name: "search", name: "search"}) + + assert ctx.event_type == :tool + JidoTracer.span_stop(ctx, %{}) + end + + test "unknown prefix falls back to :agent" do + ctx = JidoTracer.span_start([:some, :other, :prefix], %{name: "unknown"}) + assert ctx.event_type == :agent + JidoTracer.span_stop(ctx, %{}) + end + end + + describe "metadata mapping" do + test "agent metadata maps query to input" do + ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{ + query: "What is the weather?", + name: "weather_agent" + }) + + # Span was created with correct attributes - verify no crash + assert ctx.event_type == :agent + JidoTracer.span_stop(ctx, %{}) + end + + test "llm metadata maps model correctly" do + ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "anthropic:claude-sonnet-4-20250514", + input_messages: [%{role: "user", content: "Hello"}] + }) + + assert ctx.event_type == :llm + JidoTracer.span_stop(ctx, %{}) + end + + test "llm metadata extracts messages from conversation" do + ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "anthropic:claude-sonnet-4-20250514", + conversation: [ + %{role: "user", content: "Hi"}, + %{role: "assistant", content: "Hello"} + ] + }) + + assert ctx.event_type == :llm + JidoTracer.span_stop(ctx, %{}) + end + + test "tool metadata maps tool_name and arguments" do + ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{ + tool_name: "search", + name: "search", + arguments: %{query: "elixir"}, + params: %{query: "elixir"} + }) + + assert ctx.event_type == :tool + JidoTracer.span_stop(ctx, %{}) + end + + test "tool metadata uses node_name when tool_name missing" do + ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{ + node_name: "extract_node", + name: "extract_node" + }) + + assert ctx.event_type == :tool + JidoTracer.span_stop(ctx, %{}) + end + end + + describe "nesting" do + test "agent span then nested LLM span creates parent-child" do + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{ + query: "test", + name: "my_agent" + }) + + llm_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "anthropic:claude-sonnet-4-20250514" + }) + + # Both contexts should be valid + assert agent_ctx.event_type == :agent + assert llm_ctx.event_type == :llm + + # Stop inner first, then outer + JidoTracer.span_stop(llm_ctx, %{}) + JidoTracer.span_stop(agent_ctx, %{}) + end + + test "stopping inner span restores outer context" do + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{ + query: "test", + name: "my_agent" + }) + + llm_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "anthropic:claude-sonnet-4-20250514" + }) + + JidoTracer.span_stop(llm_ctx, %{}) + + # Start another LLM span - should be sibling, not child of first LLM + llm_ctx2 = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "anthropic:claude-sonnet-4-20250514" + }) + + assert llm_ctx2.event_type == :llm + JidoTracer.span_stop(llm_ctx2, %{}) + JidoTracer.span_stop(agent_ctx, %{}) + end + + test "sequential siblings don't nest incorrectly" do + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{ + query: "test", + name: "my_agent" + }) + + # Tool 1 + tool1_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{ + tool_name: "tool1", + name: "tool1" + }) + + JidoTracer.span_stop(tool1_ctx, %{}) + + # Tool 2 - should NOT be child of tool1 + tool2_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{ + tool_name: "tool2", + name: "tool2" + }) + + JidoTracer.span_stop(tool2_ctx, %{}) + + JidoTracer.span_stop(agent_ctx, %{}) + end + + test "deep nesting: agent -> llm -> tool" do + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{ + query: "test", + name: "my_agent" + }) + + llm_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "anthropic:claude-sonnet-4-20250514" + }) + + tool_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{ + tool_name: "calc", + name: "calc" + }) + + JidoTracer.span_stop(tool_ctx, %{}) + JidoTracer.span_stop(llm_ctx, %{}) + JidoTracer.span_stop(agent_ctx, %{}) + end + end + + describe "span-targeted operations" do + test "span_stop targets specific span when OTel context has been changed" do + # Start agent span, then start multiple tool spans (simulating sibling dispatch) + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + + tool1_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{tool_name: "tool1", name: "tool1"}) + + # Start second tool — this clobbers the process-level OTel context + tool2_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{tool_name: "tool2", name: "tool2"}) + + # Stopping tool1 should still work correctly despite context clobber + assert JidoTracer.span_stop(tool1_ctx, %{result: "tool1 done"}) == :ok + assert JidoTracer.span_stop(tool2_ctx, %{result: "tool2 done"}) == :ok + assert JidoTracer.span_stop(agent_ctx, %{}) == :ok + end + + test "span_exception targets specific span when OTel context has been changed" do + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + + tool_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{tool_name: "tool1", name: "tool1"}) + + # Start another span to clobber context + _tool2_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{tool_name: "tool2", name: "tool2"}) + + # Exception on tool1 should target tool1's span, not the current process span + assert JidoTracer.span_exception( + tool_ctx, + :error, + %RuntimeError{message: "boom"}, + [{__MODULE__, :test, 0, []}] + ) == :ok + + assert JidoTracer.span_stop(agent_ctx, %{}) == :ok + end + + test "LLM span stop with output_messages passes content not finish_reason to translator" do + ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{model: "test-model"}) + + measurements = %{ + tokens: %{prompt: 100, completion: 50, total: 150}, + finish_reason: :stop, + output_messages: [%{role: "assistant", content: "Hello world"}] + } + + # Should not crash and should pass content through to translator + assert JidoTracer.span_stop(ctx, measurements) == :ok + end + end + + describe "input.value translation" do + test "LLM start metadata includes input.value from last user message" do + attrs = + Translator.from_start_metadata(:llm, %{ + model: "test-model", + input_messages: [ + %{role: "system", content: "You are helpful"}, + %{role: "user", content: "What is 2+2?"}, + %{role: "assistant", content: "4"}, + %{role: "user", content: "And 3+3?"} + ] + }) + + assert attrs["input.value"] == "And 3+3?" + assert attrs["openinference.span.kind"] == "LLM" + end + + test "LLM start metadata input.value is nil when no user messages" do + attrs = + Translator.from_start_metadata(:llm, %{ + model: "test-model", + input_messages: [%{role: "system", content: "system prompt"}] + }) + + refute Map.has_key?(attrs, "input.value") + end + + test "LLM start metadata input.value is nil when no messages" do + alias AgentObs.Handlers.Phoenix.Translator + + attrs = Translator.from_start_metadata(:llm, %{model: "test-model"}) + refute Map.has_key?(attrs, "input.value") + end + + test "tool start metadata includes input.value from arguments" do + attrs = + Translator.from_start_metadata(:tool, %{ + name: "search", + arguments: %{query: "elixir", limit: 10} + }) + + assert attrs["input.value"] != nil + decoded = Jason.decode!(attrs["input.value"]) + assert decoded["query"] == "elixir" + assert decoded["limit"] == 10 + end + + test "tool start metadata input.value is nil when no arguments" do + alias AgentObs.Handlers.Phoenix.Translator + + attrs = Translator.from_start_metadata(:tool, %{name: "ping"}) + refute Map.has_key?(attrs, "input.value") + end + end + + describe "LLM span naming" do + test "LLM span name includes iteration number" do + ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "test-model", + iteration: 2 + }) + + assert ctx.event_type == :llm + JidoTracer.span_stop(ctx, %{}) + end + + test "LLM span name without iteration uses model only" do + ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "test-model" + }) + + assert ctx.event_type == :llm + JidoTracer.span_stop(ctx, %{}) + end + end + + describe "chain/iteration spans" do + test "[:jido, :composer, :iteration] maps to :chain event type" do + ctx = + JidoTracer.span_start([:jido, :composer, :iteration], %{ + iteration: 1, + input: "some query" + }) + + assert ctx.event_type == :chain + JidoTracer.span_stop(ctx, %{output: "tool_calls dispatched"}) + end + + test "chain span name includes iteration number" do + ctx = + JidoTracer.span_start([:jido, :composer, :iteration], %{ + iteration: 3 + }) + + assert ctx.event_type == :chain + JidoTracer.span_stop(ctx, %{}) + end + + test "chain span nests correctly under agent" do + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{ + query: "test", + name: "my_agent" + }) + + chain_ctx = + JidoTracer.span_start([:jido, :composer, :iteration], %{ + iteration: 1 + }) + + llm_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "test-model", + iteration: 1 + }) + + JidoTracer.span_stop(llm_ctx, %{}) + JidoTracer.span_stop(chain_ctx, %{output: "done"}) + JidoTracer.span_stop(agent_ctx, %{}) + end + + test "chain translator produces CHAIN span kind" do + attrs = + Translator.from_start_metadata(:chain, %{ + iteration: 2, + input: "query text" + }) + + assert attrs["openinference.span.kind"] == "CHAIN" + assert attrs["metadata.iteration"] == 2 + assert attrs["input.value"] == "query text" + end + + test "chain stop translator produces output.value" do + attrs = + Translator.from_stop_metadata(:chain, %{output: "final_answer: 42"}, %{ + duration: 1_000_000 + }) + + assert attrs["output.value"] == "final_answer: 42" + assert attrs["latency_ms"] == 1.0 + end + end + + describe "span ID parent-child verification" do + test "agent → LLM: LLM span parents under agent span" do + alias AgentObs.TestHelper, as: TH + + {_, spans} = + TH.capture_spans(fn -> + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + + llm_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{ + model: "test-model", + iteration: 1 + }) + + JidoTracer.span_stop(llm_ctx, %{tokens: %{prompt: 10, completion: 5, total: 15}}) + JidoTracer.span_stop(agent_ctx, %{result: "done"}) + end) + + agent_span = TH.find_span(spans, "my_agent") + llm_span = TH.find_span(spans, "test-model #1") + + assert agent_span != nil, + "Expected agent span, got: #{inspect(Enum.map(spans, &TH.span_name/1))}" + + assert llm_span != nil, + "Expected LLM span, got: #{inspect(Enum.map(spans, &TH.span_name/1))}" + + TH.assert_span_parent(llm_span, agent_span) + end + + test "agent → LLM → tool: three-level parent-child chain" do + alias AgentObs.TestHelper, as: TH + + {_, spans} = + TH.capture_spans(fn -> + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "deep_agent"}) + + llm_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{model: "test-model"}) + + tool_ctx = + JidoTracer.span_start([:jido, :composer, :tool], %{ + tool_name: "calc", + name: "calc" + }) + + JidoTracer.span_stop(tool_ctx, %{result: "42"}) + JidoTracer.span_stop(llm_ctx, %{}) + JidoTracer.span_stop(agent_ctx, %{}) + end) + + agent_span = TH.find_span(spans, "deep_agent") + llm_span = TH.find_span(spans, "test-model") + tool_span = TH.find_span(spans, "calc") + + assert agent_span != nil, "Expected agent span" + assert llm_span != nil, "Expected LLM span" + assert tool_span != nil, "Expected tool span" + + TH.assert_span_parent(llm_span, agent_span) + TH.assert_span_parent(tool_span, llm_span) + end + + test "sequential siblings don't accidentally nest" do + alias AgentObs.TestHelper, as: TH + + {_, spans} = + TH.capture_spans(fn -> + agent_ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{ + query: "test", + name: "sibling_agent" + }) + + llm1_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{model: "model-1"}) + + JidoTracer.span_stop(llm1_ctx, %{}) + + llm2_ctx = + JidoTracer.span_start([:jido, :composer, :llm], %{model: "model-2"}) + + JidoTracer.span_stop(llm2_ctx, %{}) + JidoTracer.span_stop(agent_ctx, %{}) + end) + + agent_span = TH.find_span(spans, "sibling_agent") + llm1_span = TH.find_span(spans, "model-1") + llm2_span = TH.find_span(spans, "model-2") + + assert agent_span != nil + assert llm1_span != nil + assert llm2_span != nil + + # Both LLM spans should be children of agent, not of each other + TH.assert_span_parent(llm1_span, agent_span) + TH.assert_span_parent(llm2_span, agent_span) + + # They should be siblings + TH.assert_span_siblings(llm1_span, llm2_span) + end + end + + describe "error handling" do + test "exception spans get error status" do + ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + + result = + JidoTracer.span_exception( + ctx, + :error, + %RuntimeError{message: "crash"}, + [{__MODULE__, :test, 0, [file: ~c"test.exs", line: 1]}] + ) + + assert result == :ok + end + + test "missing metadata fields don't crash" do + # No name, no query — should still work + ctx = JidoTracer.span_start([:jido, :composer, :agent], %{}) + assert is_map(ctx) + JidoTracer.span_stop(ctx, %{}) + end + + test "nil tracer_ctx handled gracefully in span_stop" do + assert JidoTracer.span_stop(nil, %{}) == :ok + end + + test "nil tracer_ctx handled gracefully in span_exception" do + assert JidoTracer.span_exception(nil, :error, "boom", []) == :ok + end + + test "empty measurements in span_stop" do + ctx = + JidoTracer.span_start([:jido, :composer, :agent], %{query: "test", name: "my_agent"}) + + assert JidoTracer.span_stop(ctx, %{}) == :ok + end + end +end diff --git a/test/support/test_helper.exs b/test/support/test_helper.exs index b2200a7..22bd9cb 100644 --- a/test/support/test_helper.exs +++ b/test/support/test_helper.exs @@ -4,53 +4,89 @@ defmodule AgentObs.TestHelper do Provides utilities for capturing and asserting on OpenTelemetry spans during testing without requiring actual backend connections. - """ - @doc """ - Captures all spans created during the execution of a function. + Uses `otel_exporter_pid` to route exported spans to the test process + as `{:span, SpanRecord}` messages. + """ - Uses OpenTelemetry's in-process exporter to collect spans without - sending them to an external backend. + require Record - ## Examples + # Define Elixir record accessors from the Erlang #span{} record. + # Fields: trace_id, span_id, tracestate, parent_span_id, parent_span_is_remote, + # name, kind, start_time, end_time, attributes, events, links, + # status, trace_flags, is_recording, instrumentation_scope + Record.defrecord(:span, Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl")) - spans = capture_spans(fn -> - AgentObs.trace_agent("test", %{input: "test"}, fn -> - {:ok, "result"} - end) - end) + @doc """ + Configures `otel_exporter_pid` to send spans to the calling process, + then executes the given function, flushes the processor, and collects + all exported spans. - assert length(spans) == 1 + Returns `{function_result, spans}` where spans is a list of Erlang + span records. """ def capture_spans(fun) when is_function(fun, 0) do - # Start a simple span processor with in-memory collector - collector_pid = start_span_collector() + pid = self() + + # Point the exporter at this process. + # Detect whether the test env uses batch or simple processor. + set_exporter(pid) result = try do - # Execute the function that should create spans fun.() after - # Give spans time to be processed - Process.sleep(50) + # Force flush to ensure all spans are exported + force_flush() + Process.sleep(100) end - # Get collected spans - spans = get_collected_spans(collector_pid) - stop_span_collector(collector_pid) + # Collect all span messages + spans = collect_span_messages(500) {result, spans} end - @doc """ - Asserts that a span with the given name exists in the list of spans. + defp set_exporter(pid) do + cond do + Process.whereis(:otel_batch_processor_global) -> + :otel_batch_processor.set_exporter(:otel_exporter_pid, pid) - ## Examples + Process.whereis(:otel_simple_processor_global) -> + :otel_simple_processor.set_exporter(:otel_exporter_pid, pid) - assert_span_exists(spans, "my_agent") + true -> + raise "No OTel span processor found. Ensure :opentelemetry is started." + end + end + + defp force_flush do + cond do + Process.whereis(:otel_batch_processor_global) -> + :otel_batch_processor.force_flush(%{reg_name: :otel_batch_processor_global}) + + Process.whereis(:otel_simple_processor_global) -> + :otel_simple_processor.force_flush(%{reg_name: :otel_simple_processor_global}) + + true -> + :ok + end + end + + defp collect_span_messages(timeout) do + receive do + {:span, span_record} -> + [span_record | collect_span_messages(timeout)] + after + timeout -> [] + end + end + + @doc """ + Asserts that a span with the given name exists in the list of spans. """ def assert_span_exists(spans, expected_name) do - span_names = Enum.map(spans, & &1.name) + span_names = Enum.map(spans, &span_name/1) if expected_name in span_names do :ok @@ -67,25 +103,17 @@ defmodule AgentObs.TestHelper do Finds a span by name in the list of spans. Returns the first matching span or nil if not found. - - ## Examples - - span = find_span(spans, "my_agent") - assert span.attributes["input.value"] == "test" """ def find_span(spans, name) do - Enum.find(spans, &(&1.name == name)) + Enum.find(spans, &(span_name(&1) == name)) end @doc """ Asserts that a span has a specific attribute with the expected value. - - ## Examples - - assert_span_attribute(span, "llm.model", "gpt-4o") """ - def assert_span_attribute(span, key, expected_value) do - actual_value = get_in(span, [:attributes, key]) + def assert_span_attribute(span_record, key, expected_value) do + attrs = span_attributes(span_record) + actual_value = Map.get(attrs, key) if actual_value == expected_value do :ok @@ -95,27 +123,24 @@ defmodule AgentObs.TestHelper do Span attribute mismatch for "#{key}": Expected: #{inspect(expected_value)} Actual: #{inspect(actual_value)} + Available: #{inspect(attrs)} """ end end @doc """ Asserts that a span has a specific attribute (regardless of value). - - ## Examples - - assert_span_has_attribute(span, "openinference.span.kind") """ - def assert_span_has_attribute(span, key) do - if Map.has_key?(span.attributes || %{}, key) do + def assert_span_has_attribute(span_record, key) do + attrs = span_attributes(span_record) + + if Map.has_key?(attrs, key) do :ok else - available_keys = Map.keys(span.attributes || %{}) - raise ExUnit.AssertionError, message: """ Span does not have attribute "#{key}". - Available attributes: #{inspect(available_keys)} + Available attributes: #{inspect(Map.keys(attrs))} """ end end @@ -124,12 +149,6 @@ defmodule AgentObs.TestHelper do Asserts parent-child relationship between two spans. Verifies that child_span's parent_span_id matches parent_span's span_id. - - ## Examples - - parent = find_span(spans, "agent") - child = find_span(spans, "llm_call") - assert_span_parent(child, parent) """ def assert_span_parent(child_span, parent_span) do parent_id = extract_span_id(parent_span) @@ -141,59 +160,64 @@ defmodule AgentObs.TestHelper do raise ExUnit.AssertionError, message: """ Span parent mismatch: - Expected child's parent_span_id to match parent's span_id - Parent span_id: #{inspect(parent_id)} - Child parent_span_id: #{inspect(child_parent_id)} + Parent: #{inspect(span_name(parent_span))} (span_id: #{inspect(parent_id)}) + Child: #{inspect(span_name(child_span))} (parent_span_id: #{inspect(child_parent_id)}) + Expected child's parent_span_id to match parent's span_id. """ end end @doc """ - Builds a mock telemetry span collector process. - - This is used internally by capture_spans/1. + Asserts that two spans are siblings (have the same parent_span_id). """ - def start_span_collector do - {:ok, pid} = Agent.start_link(fn -> [] end) - pid - end + def assert_span_siblings(span_a, span_b) do + parent_a = extract_parent_span_id(span_a) + parent_b = extract_parent_span_id(span_b) - @doc """ - Retrieves all collected spans from the collector. - """ - def get_collected_spans(collector_pid) do - # Since we're using OpenTelemetry's built-in simple processor in test mode, - # we need to read spans using OTel's test utilities - # For now, return empty list - this will be enhanced when we integrate - # with actual OTel test exporter - Agent.get(collector_pid, & &1) + if parent_a == parent_b do + :ok + else + raise ExUnit.AssertionError, + message: """ + Spans are not siblings: + Span A: #{inspect(span_name(span_a))} (parent_span_id: #{inspect(parent_a)}) + Span B: #{inspect(span_name(span_b))} (parent_span_id: #{inspect(parent_b)}) + Expected both to have the same parent_span_id. + """ + end end - @doc """ - Stops the span collector process. - """ - def stop_span_collector(collector_pid) do - Agent.stop(collector_pid) - end + # -- Span record accessors -- - # Private helpers + @doc "Extract span_id from a span record." + def extract_span_id(span_record), do: span(span_record, :span_id) - defp extract_span_id(span) do - # OpenTelemetry span records have a span_id field - # The actual structure depends on OTel version - case span do - %{span_id: id} -> id - {_, _, id, _, _, _, _, _, _, _, _, _} -> id - _ -> nil - end + @doc "Extract parent_span_id from a span record." + def extract_parent_span_id(span_record), do: span(span_record, :parent_span_id) + + @doc "Extract name from a span record." + def span_name(span_record), do: span(span_record, :name) + + @doc "Extract attributes as a plain map from a span record." + def span_attributes(span_record) do + attrs = span(span_record, :attributes) + otel_attributes_to_map(attrs) end - defp extract_parent_span_id(span) do - # OpenTelemetry span records have a parent_span_id field - case span do - %{parent_span_id: id} -> id - {_, _, _, id, _, _, _, _, _, _, _, _} -> id - _ -> nil + # -- Private helpers -- + + defp otel_attributes_to_map(attrs) when is_map(attrs) do + case Map.get(attrs, :map) do + map when is_map(map) -> map + _ -> attrs end end + + defp otel_attributes_to_map(attrs) when is_list(attrs), do: Map.new(attrs) + + defp otel_attributes_to_map({:attributes, _limit, _value_limit, _count, map}) + when is_map(map), + do: map + + defp otel_attributes_to_map(_), do: %{} end From 482bb17ca8d4ab750b2c6dc8d6256f2dc0b8625d Mon Sep 17 00:00:00 2001 From: lostbean Date: Wed, 11 Mar 2026 13:46:05 -0300 Subject: [PATCH 2/2] docs: add JidoTracer documentation to README, exdocs, and guides --- README.md | 31 ++++++ guides/jido_integration.md | 188 +++++++++++++++++++++++++++++++++++ lib/agent_obs.ex | 5 + lib/agent_obs/jido_tracer.ex | 33 +++++- mix.exs | 5 +- 5 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 guides/jido_integration.md diff --git a/README.md b/README.md index 332ff1f..f15cd85 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ observability backends through a pluggable handler architecture. - 🌟 **OpenInference support** - Full semantic conventions for Arize Phoenix - 📊 **Rich metadata tracking** - Token usage, costs, tool calls, and more - 🚀 **Built on OTP** - Supervised handlers with fault tolerance +- 🔗 **Jido integration (optional)** - Zero-code tracing for Jido composer + workflows - 🧪 **Backend-agnostic** - Standardized event schema independent of backends ## Architecture @@ -218,6 +220,35 @@ object = ReqLLM.Response.object(response) See the [demo agent](demo/lib/demo/agent.ex) and [ReqLLM integration guide](guides/req_llm_integration.md) for complete examples. +## Jido Integration (Optional) + +For applications using [Jido](https://hexdocs.pm/jido), AgentObs provides +`AgentObs.JidoTracer` — a drop-in `Jido.Observe.Tracer` implementation that +automatically instruments all composer events with OpenTelemetry spans. + +```elixir +# Add to your deps +{:jido, "~> 2.0"} + +# Configure Jido to use the tracer +config :jido, :observability, + tracer: AgentObs.JidoTracer +``` + +That's it. All `[:jido, :composer, :agent|:llm|:tool]` events are automatically +mapped to AgentObs event types and traced with OpenInference semantic conventions. +Parent-child span nesting is preserved, so you get a full trace tree in Phoenix: + +``` +weather_assistant (agent) + ├── gpt-4o #1 (llm) + ├── get_weather (tool) + └── gpt-4o #2 (llm) +``` + +See the [Jido integration guide](guides/jido_integration.md) for details on +event mapping, metadata translation, and advanced usage. + ## API Reference ### High-Level Instrumentation diff --git a/guides/jido_integration.md b/guides/jido_integration.md new file mode 100644 index 0000000..1142399 --- /dev/null +++ b/guides/jido_integration.md @@ -0,0 +1,188 @@ +# Jido Integration Guide + +AgentObs provides a drop-in tracer for [Jido](https://hexdocs.pm/jido) that +automatically instruments composer events with OpenTelemetry spans and +OpenInference semantic conventions. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Configuration](#configuration) +- [How It Works](#how-it-works) +- [Event Mapping](#event-mapping) +- [Metadata Translation](#metadata-translation) +- [Viewing Traces](#viewing-traces) +- [Advanced Usage](#advanced-usage) + +## Overview + +If you're using Jido's composer to orchestrate agent, LLM, and tool calls, +`AgentObs.JidoTracer` bridges those telemetry events into AgentObs. This gives +you full observability in Arize Phoenix (or any OpenTelemetry backend) with zero +manual instrumentation of your Jido workflows. + +**Key benefits:** + +- Zero-code instrumentation for Jido composer workflows +- Automatic parent-child span nesting +- OpenInference semantic conventions for rich visualization in Phoenix +- Graceful error handling (never crashes your application) + +## Installation + +Add both dependencies to `mix.exs`: + +```elixir +def deps do + [ + {:agent_obs, "~> 0.1.3"}, + {:jido, "~> 2.0"} + ] +end +``` + +`jido` is an **optional** dependency of `agent_obs`. The `AgentObs.JidoTracer` +module is only available when Jido is installed. + +## Configuration + +Point Jido's observability config at the tracer: + +```elixir +# config/config.exs +config :jido, :observability, + tracer: AgentObs.JidoTracer + +# Also configure AgentObs handlers as usual +config :agent_obs, + enabled: true, + handlers: [AgentObs.Handlers.Phoenix] +``` + +Make sure your OpenTelemetry exporter is configured to send spans to your +backend: + +```elixir +# config/runtime.exs +config :opentelemetry, + span_processor: :batch, + resource: [service: [name: "my_jido_app"]] + +config :opentelemetry_exporter, + otlp_protocol: :http_protobuf, + otlp_endpoint: System.get_env("ARIZE_PHOENIX_OTLP_ENDPOINT", "http://localhost:6006") +``` + +That's it. All Jido composer events will now appear as traced spans. + +## How It Works + +`AgentObs.JidoTracer` implements the `Jido.Observe.Tracer` behaviour, which +Jido calls automatically during composer execution: + +1. **`span_start/2`** - Called when a composer event begins. Creates an + OpenTelemetry span with attributes translated to OpenInference conventions. +2. **`span_stop/2`** - Called when the event completes. Sets result attributes + and ends the span. +3. **`span_exception/4`** - Called on errors. Records the exception on the span + and sets error status. + +Parent-child relationships are maintained automatically through OpenTelemetry +context propagation, so nested agent > LLM > tool calls appear correctly in +trace visualizers. + +## Event Mapping + +Jido event prefixes are classified into AgentObs event types: + +| Jido Event Prefix | AgentObs Type | Span Name Example | +| -------------------------------------- | ------------- | --------------------- | +| `[:jido, :composer, :agent, ...]` | `:agent` | `"weather_assistant"` | +| `[:jido, :composer, :llm, ...]` | `:llm` | `"gpt-4o #2"` | +| `[:jido, :composer, :tool, ...]` | `:tool` | `"get_weather"` | +| `[:jido, :composer, :iteration, ...]` | `:chain` | `"iteration 3"` | +| Any other prefix | `:agent` | `"agent"` | + +## Metadata Translation + +The tracer automatically maps Jido metadata fields to AgentObs format: + +### Agent Events + +| Jido Field | AgentObs Field | Notes | +| ---------------- | -------------- | -------------------------- | +| `:query` | `:input` | Falls back to `:input` | +| `:name` | `:name` | Falls back to `:agent_module` | +| `:model` | `:model` | Optional | +| `:result` | `:output` | On stop, falls back to `:output` | + +### LLM Events + +| Jido Field | AgentObs Field | Notes | +| ------------------ | ------------------ | ------------------------------ | +| `:model` | `:model` | Defaults to `"unknown"` | +| `:input_messages` | `:input_messages` | Preferred | +| `:conversation` | `:input_messages` | Normalized to `%{role, content}` maps | +| `:iteration` | `:iteration` | Appended to span name | +| `:tokens` | `:tokens` | On stop | + +### Tool Events + +| Jido Field | AgentObs Field | Notes | +| ------------- | -------------- | ---------------------------------- | +| `:tool_name` | `:name` | Falls back to `:node_name`, `:name` | +| `:arguments` | `:arguments` | Falls back to `:params` | +| `:result` | `:result` | On stop, falls back to `:output` | + +## Viewing Traces + +Start a local Arize Phoenix instance: + +```bash +docker run -p 6006:6006 -p 4317:4317 arizephoenix/phoenix:latest +``` + +Navigate to `http://localhost:6006` to see your Jido workflows as nested traces: + +``` +weather_assistant (agent) + ├── gpt-4o #1 (llm) + ├── get_weather (tool) + └── gpt-4o #2 (llm) +``` + +## Advanced Usage + +### Combining with Manual Instrumentation + +You can use `AgentObs.JidoTracer` for Jido workflows while using the +`AgentObs.trace_*` helpers for non-Jido code in the same application: + +```elixir +def my_workflow(query) do + # Manual instrumentation for the outer operation + AgentObs.trace_agent("my_workflow", %{input: query}, fn -> + # Jido composer is automatically traced via JidoTracer + {:ok, result} = Jido.Composer.run(my_composer, query) + {:ok, result} + end) +end +``` + +### Error Handling + +The tracer is designed to never crash your application. If span creation or +attribute translation fails, a warning is logged and execution continues +normally: + +``` +[warning] AgentObs.JidoTracer span_start failed: %RuntimeError{message: "..."} +``` + +## Next Steps + +- **[Getting Started](getting_started.md)** - Basic AgentObs setup +- **[Instrumentation Guide](instrumentation.md)** - Manual instrumentation patterns +- **[ReqLLM Integration](req_llm_integration.md)** - ReqLLM auto-instrumentation +- **[Configuration](configuration.md)** - Advanced configuration options diff --git a/lib/agent_obs.ex b/lib/agent_obs.ex index ce3a68a..1a355ff 100644 --- a/lib/agent_obs.ex +++ b/lib/agent_obs.ex @@ -54,6 +54,11 @@ defmodule AgentObs do - `trace_llm/3` - Instruments LLM API calls - `trace_prompt/3` - Instruments prompt template rendering + ## Integrations + + - `AgentObs.ReqLLM` - Automatic instrumentation for ReqLLM streaming calls + - `AgentObs.JidoTracer` - Zero-code tracing for Jido composer workflows + ## Low-Level API - `emit/2` - Emits custom telemetry events diff --git a/lib/agent_obs/jido_tracer.ex b/lib/agent_obs/jido_tracer.ex index d5614b2..588a0bb 100644 --- a/lib/agent_obs/jido_tracer.ex +++ b/lib/agent_obs/jido_tracer.ex @@ -1,19 +1,26 @@ defmodule AgentObs.JidoTracer do @moduledoc """ - Jido.Observe.Tracer implementation that bridges Jido composer events to + A `Jido.Observe.Tracer` implementation that bridges Jido composer events to AgentObs OpenTelemetry spans with OpenInference semantic conventions. This module translates Jido's event prefixes and metadata into the AgentObs format expected by `AgentObs.Handlers.Phoenix.Translator`, then creates and manages OpenTelemetry spans with proper parent-child nesting. - ## Configuration + ## Setup - In your Jido application config: + Add `jido` to your deps (it's an optional dependency of `agent_obs`): + {:jido, "~> 2.0"} + + Then point Jido at this tracer: + + # config/config.exs config :jido, :observability, tracer: AgentObs.JidoTracer + No other changes are needed — all composer events are automatically traced. + ## Event Prefix Mapping | Jido prefix | AgentObs type | @@ -21,7 +28,27 @@ defmodule AgentObs.JidoTracer do | `[:jido, :composer, :agent, ...]` | `:agent` | | `[:jido, :composer, :llm, ...]` | `:llm` | | `[:jido, :composer, :tool, ...]` | `:tool` | + | `[:jido, :composer, :iteration, ...]` | `:chain` | | anything else | `:agent` (fallback) | + + ## Metadata Translation + + Jido metadata fields are mapped to AgentObs conventions: + + - **Agent events:** `:query` / `:input` → `:input`, `:name` / `:agent_module` → `:name` + - **LLM events:** `:model`, `:conversation` → normalized `:input_messages`, `:iteration` + - **Tool events:** `:tool_name` / `:node_name` → `:name`, `:arguments` / `:params` + + ## Error Handling + + All callbacks are wrapped in `rescue` blocks. If span creation or attribute + translation fails, a warning is logged and execution continues — the tracer + will never crash your application. + + ## See Also + + - [Jido Integration Guide](guides/jido_integration.md) for full documentation + - `AgentObs.Handlers.Phoenix.Translator` for attribute generation details """ @behaviour Jido.Observe.Tracer diff --git a/mix.exs b/mix.exs index 2cadc66..46dd65e 100644 --- a/mix.exs +++ b/mix.exs @@ -85,6 +85,7 @@ defmodule AgentObs.MixProject do "guides/configuration.md", "guides/instrumentation.md", "guides/req_llm_integration.md", + "guides/jido_integration.md", "guides/custom_handlers.md" ], groups_for_extras: [ @@ -93,6 +94,7 @@ defmodule AgentObs.MixProject do "guides/configuration.md", "guides/instrumentation.md", "guides/req_llm_integration.md", + "guides/jido_integration.md", "guides/custom_handlers.md" ] ], @@ -108,7 +110,8 @@ defmodule AgentObs.MixProject do AgentObs.Handlers.Phoenix.Translator ], Integrations: [ - AgentObs.ReqLLM + AgentObs.ReqLLM, + AgentObs.JidoTracer ], Infrastructure: [ AgentObs.Application,