Skip to content

Commit f1c3511

Browse files
committed
feat(telemetry): propagate trace context
1 parent 1ab3a5d commit f1c3511

16 files changed

Lines changed: 856 additions & 154 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@
129129
seam for delivering them anywhere else, and `Condukt.Notifiers.PubSub` is the
130130
implementation for many viewers or more than one node. Subscribers are
131131
monitored, so a viewer that crashes is dropped rather than accumulating.
132+
- Native ReqLLM calls accept `:llm_request_options`; optional World Wide Web
133+
Consortium Trace Context is captured at each public run or stream boundary,
134+
then forwarded through tasks, tools, and sub-agents without requiring an
135+
OpenTelemetry exporter. The generated request headers are provider-neutral:
136+
`traceparent`, optional `tracestate`, and `baggage`; the opaque
137+
`condukt.session.id` baggage value provides session grouping metadata.
132138

133139
## Running the loop without a virtual machine
134140

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# Changelog
22

33
All notable changes to this project will be documented in this file.
4+
5+
## [Unreleased]
6+
7+
### Features
8+
9+
- propagate optional World Wide Web Consortium trace context and Condukt session grouping headers through native provider calls
10+
11+
### Changes
12+
13+
- keep prompts, tool payloads, and raw provider errors out of default telemetry metadata
14+
- require ReqLLM 1.9.0 or later so streaming OpenAI-compatible calls forward custom request headers
15+
416
## [1.12.0] - 2026-08-27
517

618
### Features

lib/condukt.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ defmodule Condukt do
225225
- `:api_key` - API key for the LLM provider
226226
- `:model` - Override the default model (format: "provider:model")
227227
- `:base_url` - Override the provider's default base URL
228+
- `:llm_request_options` - Additional ReqLLM request options merged into
229+
every native provider call. Use
230+
`req_http_options: [headers: [{"x-tenant", "acme"}]]` for trusted
231+
gateway headers; Condukt preserves them while adding optional trace
232+
propagation headers.
228233
- `:system_prompt` - System prompt for the agent
229234
- `:load_project_instructions` - Auto-load `AGENTS.md`, `CLAUDE.md`, and local skills from the project root (default: `true`)
230235
- `:thinking_level` - Override the thinking level
@@ -325,6 +330,9 @@ defmodule Condukt do
325330
- `:timeout` - Max time in ms (default: 300_000)
326331
- `:max_turns` - Max tool use cycles (default: 50)
327332
- `:images` - List of images to include
333+
- `:trace_context` - A `Condukt.TraceContext`, `true` to create one when the
334+
caller has none, or `false` to disable propagation for this run. When
335+
omitted, Condukt captures `Condukt.TraceContext.current/0`.
328336
329337
## Anonymous run (no agent module)
330338
@@ -399,7 +407,7 @@ defmodule Condukt do
399407
Anonymous and module-defined one-shot runs accept all the per-run options above (`:timeout`,
400408
`:max_turns`, `:images`) plus the session options accepted by an agent's
401409
`start_link/1` (`:model`, `:system_prompt`, `:api_key`, `:base_url`,
402-
`:thinking_level`, `:max_tokens`, `:tools`, `:sandbox`, `:cwd`, `:session_store`,
410+
`:llm_request_options`, `:thinking_level`, `:max_tokens`, `:tools`, `:sandbox`, `:cwd`, `:session_store`,
403411
`:session_store_key`, `:session_store_opts`, `:subagents`, `:compactor`,
404412
`:redactor`, `:load_project_instructions`).
405413
`:load_project_instructions` defaults to `false` for anonymous runs and to

lib/condukt/session.ex

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ defmodule Condukt.Session do
3535
SessionID,
3636
SessionStore,
3737
Telemetry,
38-
Tool
38+
Tool,
39+
TraceContext
3940
}
4041

4142
alias Condukt.MCP
@@ -58,9 +59,7 @@ defmodule Condukt.Session do
5859
:pid,
5960
:agent_module,
6061
:runtime,
61-
:model,
62-
:thinking_level,
63-
:max_tokens,
62+
:llm,
6463
:configured_system_prompt,
6564
:system_prompt,
6665
:tools,
@@ -71,8 +70,6 @@ defmodule Condukt.Session do
7170
:secrets,
7271
:mcp_registry,
7372
:notifier,
74-
:api_key,
75-
:base_url,
7673
:session_store,
7774
:session_store_opts,
7875
:compactor,
@@ -126,6 +123,7 @@ defmodule Condukt.Session do
126123
|> Keyword.put(:explicit_keys, explicit_keys)
127124
|> put_configured_opt(config, :api_key)
128125
|> put_configured_opt(config, :base_url)
126+
|> put_configured_opt(config, :llm_request_options, fn -> [] end)
129127
|> put_configured_opt(config, :runtime, fn -> agent_runtime(agent_module) end)
130128
|> put_configured_opt(config, :model, fn -> agent_module.model() end)
131129
|> put_configured_opt(config, :thinking_level, fn -> agent_module.thinking_level() end)
@@ -209,7 +207,7 @@ defmodule Condukt.Session do
209207
"""
210208
def run(agent, prompt, opts \\ []) do
211209
timeout = opts[:timeout] || @default_timeout
212-
call_session(agent, {:run, prompt, opts}, timeout)
210+
call_session(agent, {:run, prompt, attach_trace_context(opts)}, timeout)
213211
end
214212

215213
defp call_session(agent, request, timeout) do
@@ -263,7 +261,7 @@ defmodule Condukt.Session do
263261
fn ->
264262
ref = make_ref()
265263
:ok = GenServer.call(agent, {:subscribe, self(), ref})
266-
:ok = GenServer.cast(agent, {:stream, prompt, opts, ref})
264+
:ok = GenServer.cast(agent, {:stream, prompt, attach_trace_context(opts), ref})
267265
ref
268266
end,
269267
fn ref ->
@@ -381,9 +379,14 @@ defmodule Condukt.Session do
381379
pid: self(),
382380
agent_module: agent_module,
383381
runtime: runtime,
384-
model: restore_value(opts, :model, snapshot && snapshot.model),
385-
thinking_level: restore_value(opts, :thinking_level, snapshot && snapshot.thinking_level),
386-
max_tokens: Keyword.get(opts, :max_tokens),
382+
llm: %{
383+
model: restore_value(opts, :model, snapshot && snapshot.model),
384+
thinking_level: restore_value(opts, :thinking_level, snapshot && snapshot.thinking_level),
385+
max_tokens: Keyword.get(opts, :max_tokens),
386+
api_key: opts[:api_key],
387+
base_url: opts[:base_url],
388+
request_options: opts[:llm_request_options]
389+
},
387390
configured_system_prompt: configured_system_prompt,
388391
system_prompt: Context.compose_system_prompt(configured_system_prompt, project_context.prompt),
389392
tools: maybe_inject_subagent_tool(tools, subagents),
@@ -393,8 +396,6 @@ defmodule Condukt.Session do
393396
sandbox: sandbox,
394397
secrets: secrets,
395398
mcp_registry: mcp_registry,
396-
api_key: opts[:api_key],
397-
base_url: opts[:base_url],
398399
session_store: session_store,
399400
session_store_opts: session_store_opts(opts),
400401
compactor: opts[:compactor],
@@ -515,8 +516,10 @@ defmodule Condukt.Session do
515516
else
516517
state = %{state | turn: Turn.start(state.turn)}
517518
parent = self()
519+
trace_context = TraceContext.capture(opts)
518520

519521
Task.start(fn ->
522+
TraceContext.attach(trace_context)
520523
result = do_run(state, prompt, opts)
521524
GenServer.cast(parent, {:run_complete, from, result})
522525
end)
@@ -693,6 +696,7 @@ defmodule Condukt.Session do
693696
system_prompt: state.system_prompt,
694697
project_context: state.project_context,
695698
runtime_opts: runtime_opts,
699+
trace_context: TraceContext.current(),
696700
assigns: state.assigns,
697701
user_state: state.user_state
698702
}
@@ -745,7 +749,6 @@ defmodule Condukt.Session do
745749
defp agent_loop(state, messages, max_turns, turn) do
746750
context = Translate.context(messages, outbound_redactor(state), state.system_prompt)
747751
tools = build_req_llm_tools(state.tools, state)
748-
llm_opts = Translate.llm_opts(llm_config(state), tools)
749752

750753
result =
751754
Telemetry.span(
@@ -755,7 +758,7 @@ defmodule Condukt.Session do
755758
Retry.with_retry(
756759
state.retry,
757760
fn -> false end,
758-
fn -> ReqLLM.generate_text(state.model, context, llm_opts) end
761+
fn -> ReqLLM.generate_text(state.llm.model, context, llm_opts(state, tools)) end
759762
)
760763
end,
761764
&llm_turn_stop_metadata/1
@@ -798,8 +801,11 @@ defmodule Condukt.Session do
798801
defp start_stream_task(state, prompt, opts, subscriber_ref) do
799802
parent = self()
800803
abort_ref = state.turn.abort_ref
804+
trace_context = TraceContext.capture(opts)
801805

802806
Task.start(fn ->
807+
TraceContext.attach(trace_context)
808+
803809
result =
804810
do_stream(
805811
state,
@@ -820,7 +826,6 @@ defmodule Condukt.Session do
820826
defp stream_turn(state, messages, max_turns, turn, emit, abort_ref) do
821827
context = Translate.context(messages, outbound_redactor(state), state.system_prompt)
822828
tools = build_req_llm_tools(state.tools, state)
823-
llm_opts = Translate.llm_opts(llm_config(state), tools)
824829

825830
emitted_counter = :counters.new(1, [:atomics])
826831
emitted? = fn -> :counters.get(emitted_counter, 1) > 0 end
@@ -830,7 +835,7 @@ defmodule Condukt.Session do
830835
emit.(event)
831836
end
832837

833-
attempt = fn -> run_stream_attempt(state.model, context, llm_opts, tracked_emit) end
838+
attempt = fn -> run_stream_attempt(state.llm.model, context, llm_opts(state, tools), tracked_emit) end
834839

835840
result =
836841
Telemetry.span(
@@ -899,13 +904,30 @@ defmodule Condukt.Session do
899904

900905
defp llm_config(state) do
901906
[
902-
api_key: state.api_key,
903-
base_url: state.base_url,
904-
thinking_level: state.thinking_level,
905-
max_tokens: state.max_tokens
907+
api_key: state.llm.api_key,
908+
base_url: state.llm.base_url,
909+
thinking_level: state.llm.thinking_level,
910+
max_tokens: state.llm.max_tokens,
911+
request_options: state.llm.request_options
906912
]
907913
end
908914

915+
defp llm_opts(state, tools) do
916+
trace_headers =
917+
TraceContext.current()
918+
|> TraceContext.child()
919+
|> TraceContext.headers(state.id)
920+
921+
Translate.llm_opts(llm_config(state), tools, trace_headers)
922+
end
923+
924+
defp attach_trace_context(opts) do
925+
case TraceContext.capture(opts) do
926+
nil -> Keyword.delete(opts, :trace_context)
927+
context -> Keyword.put(opts, :trace_context, context)
928+
end
929+
end
930+
909931
defp build_req_llm_tools(tools, state) do
910932
Enum.map(tools, fn tool_spec ->
911933
spec = Tool.to_spec(tool_spec)
@@ -936,13 +958,17 @@ defmodule Condukt.Session do
936958
defp execute_tool_calls(state, assistant_message, messages, emit \\ &noop_emit/1) do
937959
tool_calls = Message.tool_calls(assistant_message)
938960
tool_map = build_tool_map(state.tools)
961+
trace_context = TraceContext.current()
939962

940963
Enum.each(tool_calls, fn {id, name, args} -> emit.({:tool_call, name, id, args}) end)
941964

942965
{tool_results, assigns_diff} =
943966
tool_calls
944967
|> Task.async_stream(
945-
fn tool_call -> execute_tool_call(tool_map, tool_call, state) end,
968+
fn tool_call ->
969+
TraceContext.attach(trace_context)
970+
execute_tool_call(tool_map, tool_call, state)
971+
end,
946972
ordered: true,
947973
timeout: tool_timeout(state)
948974
)
@@ -975,7 +1001,6 @@ defmodule Condukt.Session do
9751001
metadata = %{
9761002
tool: name,
9771003
tool_call_id: id,
978-
args: args,
9791004
session_id: state.id,
9801005
agent: state.agent_module
9811006
}
@@ -988,33 +1013,32 @@ defmodule Condukt.Session do
9881013
)
9891014
end
9901015

991-
defp tool_call_stop_metadata({%Message{content: {:error, _} = error}, _assigns}), do: %{status: :error, result: error}
1016+
defp tool_call_stop_metadata({%Message{content: {:error, _error}}, _assigns}), do: %{status: :error}
9921017

993-
defp tool_call_stop_metadata({%Message{content: content}, _assigns}), do: %{status: :ok, result: content}
1018+
defp tool_call_stop_metadata({%Message{}, _assigns}), do: %{status: :ok}
9941019

9951020
defp llm_turn_metadata(state, messages, turn, streaming?) do
9961021
%{
9971022
agent: state.agent_module,
9981023
session_id: state.id,
999-
model: Translate.model_identifier(state.model),
1024+
model: Translate.model_identifier(state.llm.model),
10001025
turn: turn,
10011026
streaming?: streaming?,
1002-
messages: messages,
1027+
message_count: length(messages),
10031028
tool_count: length(state.tools)
10041029
}
10051030
end
10061031

10071032
defp llm_turn_stop_metadata({:ok, response}) do
10081033
%{
10091034
status: :ok,
1010-
assistant_message: Translate.response_to_message(response),
10111035
usage: Map.get(response, :usage),
10121036
finish_reason: Map.get(response, :finish_reason)
10131037
}
10141038
end
10151039

10161040
defp llm_turn_stop_metadata({:error, reason}) do
1017-
%{status: :error, error: reason}
1041+
%{status: :error, error: Telemetry.error_name(reason)}
10181042
end
10191043

10201044
defp task_result_to_tool_result({{:ok, {message, assigns}}, _tool_call}), do: {message, assigns}
@@ -1084,11 +1108,13 @@ defmodule Condukt.Session do
10841108
secrets: state.secrets,
10851109
subagents: state.subagents,
10861110
subagent_supervisor: state.subagent_supervisor,
1087-
model: state.model,
1088-
thinking_level: state.thinking_level,
1089-
max_tokens: state.max_tokens,
1090-
api_key: state.api_key,
1091-
base_url: state.base_url,
1111+
model: state.llm.model,
1112+
thinking_level: state.llm.thinking_level,
1113+
max_tokens: state.llm.max_tokens,
1114+
api_key: state.llm.api_key,
1115+
base_url: state.llm.base_url,
1116+
llm_request_options: state.llm.request_options,
1117+
trace_context: TraceContext.current(),
10921118
assigns: state.assigns
10931119
}
10941120
end
@@ -1212,8 +1238,8 @@ defmodule Condukt.Session do
12121238
id: state.id,
12131239
actor: state.actor,
12141240
messages: state.messages,
1215-
model: state.model,
1216-
thinking_level: state.thinking_level,
1241+
model: state.llm.model,
1242+
thinking_level: state.llm.thinking_level,
12171243
system_prompt: state.configured_system_prompt,
12181244
created_at: state.created_at
12191245
})

0 commit comments

Comments
 (0)