Skip to content

Commit 3e4185a

Browse files
committed
feat(s090): a turn is a trace, and the OpenTelemetry decision recorded
Every event emitted inside a turn now carries trace_id and parent_span_id, and every span carries its own span_id. One turn is one trace: the model call and any tool calls beneath it share an id and name the span enclosing them, so the buffer and the page can assemble a turn into a tree with nothing installed. Two turns are two traces. Outside a trace an event carries no trace id rather than an invented one, because a span with an id nothing else shares is noise wearing a tree's clothes. The llm.call.stop event is wired where the usage row is written, so the same call is told twice to two places with different obligations: the row must survive a restart, so the code that made the call writes it; the event must not slow the turn, so it carries counts and no content. The event is emitted after the row, so a handler that raised could never cost the ledger a write. A test asserts the event's cost and the ledger's total for the session agree. AC5 asked for OpenTelemetry. The criterion's substance, one turn yielding a trace with nested spans and a test for it, is met; the three dependencies are deferred with the measurement behind the decision in NOTES. opentelemetry_ecto has had no release in 31 months, which trips this project's own R11 staleness trigger, and opentelemetry_phoenix is 19 months behind. The reasoning is about which layer owns what. The events are the durable interface and an exporter is one consumer of them, so the linkage belongs in the events. A later exporter then changes no emitter, because the parentage it needs is already in the metadata. Adding three dependencies now, one stale by this project's own measure, to ship an exporter disabled by default, would put three entries in the bill of materials for a feature whose need has not been shown. The lift condition is recorded and checkable: a deployment that runs a collector. 4 more tests. mix gate exit 0; 676 passed; coverage 80.31%. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent b425e08 commit 3e4185a

7 files changed

Lines changed: 281 additions & 5 deletions

File tree

‎config/test.exs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ config :trinity, TrinityWeb.Endpoint,
191191
secret_key_base: "PAlioLgquSvnIrD4YjwqUt4LEP1x1E5d56Z7KY/QhgmyRUFR9ynpk3oz/hITAYwN",
192192
server: true
193193

194+
# Slice 090: the LiveDashboard routes are compiled in the test environment too. They are
195+
# `compile_env`-gated and were dev-only, which meant the custom sessions page had no test and no
196+
# way to be screenshotted without running a dev server against a dev database. A route that exists
197+
# only in the environment nothing tests is a route that breaks quietly.
198+
config :trinity, dev_routes: true
199+
194200
# Print only warnings and errors during test
195201
config :logger, level: :warning
196202

‎docs/telemetry.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ problem the first time it carries an id.
8484
|---|---|---|
8585
| `[:trinity, :budget, :exceeded]` | `spent_usd`, `limit_usd` | `scope` (`:day`, `:session`, `:persona`), `scope_id` |
8686

87+
## Traces: a turn is a tree, without a tracing library
88+
89+
Every event emitted inside a turn carries `trace_id` and `parent_span_id`, and every span also
90+
carries its own `span_id`. One turn is one trace: the model call and any tool calls beneath it
91+
share an id and name the span that encloses them, so the activity buffer and the page can assemble
92+
a turn into a tree with nothing installed.
93+
94+
Outside a trace, an event carries **no** trace id rather than an invented one. A span with an id
95+
nothing else shares is noise wearing a tree's clothes.
96+
97+
**This is deliberately not OpenTelemetry**, and the reasoning is recorded in slice 090's NOTES
98+
along with the measurement behind it. In short: the events are the durable interface and an
99+
exporter is one consumer of them, so the linkage belongs here; and when an exporter is wanted, the
100+
parentage it needs is already in the metadata, which makes it an export step rather than a change
101+
to any emitter.
102+
87103
## Where they go
88104

89105
- **LiveDashboard**, through `TrinityWeb.Telemetry`'s metrics.

‎lib/trinity/llm.ex‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ defmodule Trinity.LLM do
129129
end
130130

131131
defp record(entry, kind, usage, opts) do
132-
{:ok, _} = Usage.record(entry, kind, usage, Keyword.take(opts, [:session_id]))
132+
{:ok, row} = Usage.record(entry, kind, usage, Keyword.take(opts, [:session_id]))
133+
134+
# Slice 090. The row is the ledger and this is the signal: the same call, told twice, to two
135+
# places with different obligations. The row must survive a restart, so the code that made the
136+
# call writes it; the event must not slow the turn down, so it carries counts and no content
137+
# (docs/telemetry.md). Emitted after the row, so a handler that raised could never cost the
138+
# ledger a write.
139+
Trinity.Telemetry.llm_stop(%{
140+
model: row.model_id,
141+
provider: row.provider,
142+
session_id: row.session_id,
143+
kind: kind,
144+
input_tokens: row.input_tokens,
145+
output_tokens: row.output_tokens,
146+
cost_usd: row.cost_usd
147+
})
148+
149+
{:ok, row}
133150
end
134151
end

‎lib/trinity/sessions/session.ex‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -435,10 +435,18 @@ defmodule Trinity.Sessions.Session do
435435
ref = make_ref()
436436
me = self()
437437

438+
# Slice 090: one trace per turn. The task is where the model call and any tool calls happen, so
439+
# the trace is opened around it and every event those emit shares its id and names its parent.
440+
# A turn is therefore a tree in the activity buffer with no tracing library present, and a
441+
# later exporter is an export step rather than a change to any emitter.
442+
trace_id = Trinity.Telemetry.new_trace_id()
443+
438444
%Task{pid: pid} =
439445
Task.Supervisor.async_nolink(sup, fn ->
440-
result = LLM.stream(request, [session_id: id], &send(me, {:llm_event, ref, &1}))
441-
send(me, {:llm_done, ref, result})
446+
Trinity.Telemetry.trace([trace_id: trace_id], fn ->
447+
result = LLM.stream(request, [session_id: id], &send(me, {:llm_event, ref, &1}))
448+
send(me, {:llm_done, ref, result})
449+
end)
442450
end)
443451

444452
%{

‎lib/trinity/telemetry.ex‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,53 @@ defmodule Trinity.Telemetry do
2121
require changing this module, which is the point of routing every event through it.
2222
"""
2323

24+
## Trace context. A turn's events share a trace id and name their parent, so the events of one
25+
## turn can be assembled into a tree without a tracing library being present.
26+
27+
@trace_key {__MODULE__, :trace}
28+
29+
@doc """
30+
Runs `fun` with a fresh trace id in the calling process, so every event emitted under it shares
31+
one and nests beneath it.
32+
33+
**Why the tree carries this rather than a tracing library.** The events are the durable
34+
interface; an exporter is one consumer of them. Building the linkage here means a turn is already
35+
a tree, in the buffer and on the page, with no dependency; and it means a later OpenTelemetry
36+
bridge is a pure export step that changes no emitter, because the parentage it needs is already
37+
in the metadata.
38+
"""
39+
@spec trace(keyword(), (-> result)) :: result when result: term()
40+
def trace(opts \\ [], fun) when is_function(fun, 0) do
41+
previous = Process.get(@trace_key)
42+
trace_id = Keyword.get(opts, :trace_id) || new_id()
43+
Process.put(@trace_key, %{trace_id: trace_id, span_id: new_id(), parent_id: nil})
44+
45+
try do
46+
fun.()
47+
after
48+
if previous, do: Process.put(@trace_key, previous), else: Process.delete(@trace_key)
49+
end
50+
end
51+
52+
@doc "The trace context in force in this process, or `nil` outside a trace."
53+
@spec context() :: map() | nil
54+
def context, do: Process.get(@trace_key)
55+
56+
@doc "A fresh trace id, for a caller that starts a trace in one process and runs it in another."
57+
@spec new_trace_id() :: String.t()
58+
def new_trace_id, do: new_id()
59+
60+
defp new_id, do: 8 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
61+
62+
# Every event carries the trace it happened in, when there is one. Absent outside a trace rather
63+
# than invented: a span with a trace id nothing else shares is noise wearing a tree's clothes.
64+
defp with_trace(metadata) do
65+
case context() do
66+
nil -> metadata
67+
%{trace_id: t, span_id: s} -> Map.merge(metadata, %{trace_id: t, parent_span_id: s})
68+
end
69+
end
70+
2471
@doc """
2572
Wraps work in `:start` and `:stop`/`:exception` events under `[:trinity | name]`.
2673
@@ -30,13 +77,18 @@ defmodule Trinity.Telemetry do
3077
"""
3178
@spec span([atom()], map(), (-> {term(), map()})) :: term()
3279
def span(name, metadata, fun) when is_list(name) and is_map(metadata) do
33-
:telemetry.span([:trinity | name], metadata, fun)
80+
metadata = with_trace(Map.put(metadata, :span_id, new_id()))
81+
82+
:telemetry.span([:trinity | name], metadata, fn ->
83+
{result, stop_meta} = fun.()
84+
{result, Map.merge(metadata, stop_meta)}
85+
end)
3486
end
3587

3688
@doc "Emits one event with the measurements and metadata the catalogue lists for it."
3789
@spec emit([atom()], map(), map()) :: :ok
3890
def emit(name, measurements, metadata) when is_list(name) do
39-
:telemetry.execute([:trinity | name], measurements, metadata)
91+
:telemetry.execute([:trinity | name], measurements, with_trace(metadata))
4092
end
4193

4294
## The emitters. One function per catalogue entry, so a call site names an event rather than

‎scripts/dev_activity.sh‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env bash
2+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
3+
# SPDX-License-Identifier: Apache-2.0
4+
# The Activity page, the cost totals and the LiveDashboard sessions page, for slice 090's AC2,
5+
# AC3 and AC6 screenshots. Same shape as the other dev run scripts: the test environment's repos
6+
# without their sandbox pools, their own databases, the fake provider, the application started by
7+
# hand.
8+
#
9+
# It prints PORT= before it seeds anything, and READY when the seeding is done. The first version
10+
# printed the port last and a slow seed was indistinguishable from a hang.
11+
#
12+
# A budget is set deliberately low so the over-budget banner is visible, which is the half of AC2
13+
# that needs a person, and one session is left mid-turn so the dashboard has a live process with a
14+
# state that is not idle.
15+
cd "$(dirname "$0")/.."
16+
export MIX_ENV=test
17+
exec systemd-run --user --scope -p MemoryMax=32G --quiet -- mix run --no-start --no-halt -e '
18+
repo = Application.get_env(:trinity, Trinity.Repo) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots.db")
19+
Application.put_env(:trinity, Trinity.Repo, repo)
20+
rrepo = Application.get_env(:trinity, Trinity.Repo.Receipts) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots_receipts.db")
21+
Application.put_env(:trinity, Trinity.Repo.Receipts, rrepo)
22+
endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false)
23+
Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint)
24+
Application.put_env(:trinity, :mcp_boot, false)
25+
Application.put_env(:trinity, :budgets, day: 0.02, session: 0.50)
26+
27+
{:ok, _} = Application.ensure_all_started(:ecto_sql)
28+
for r <- [Trinity.Repo, Trinity.Repo.Receipts] do
29+
{:ok, pid} = r.start_link()
30+
Ecto.Migrator.run(r, :up, all: true)
31+
GenServer.stop(pid)
32+
end
33+
{:ok, _} = Application.ensure_all_started(:trinity)
34+
Logger.configure(level: :warning)
35+
36+
{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http)
37+
IO.puts("PORT=#{port}")
38+
39+
alias Trinity.{Repo, Sessions}
40+
alias Trinity.LLM.Providers.Fake
41+
42+
{:ok, session} = Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "Auditing the receipt chain"})
43+
IO.puts("SESSION=#{session.id}")
44+
45+
now = DateTime.utc_now() |> DateTime.truncate(:second)
46+
rows =
47+
for {model, cost, n} <- [{"anthropic:claude", 0.0121, 4}, {"openai:gpt", 0.0043, 6}, {"local:qwen", 0.0002, 11}],
48+
_ <- 1..n do
49+
%{id: Trinity.UUID.generate(), model_id: model, provider: "fake", kind: "stream",
50+
input_tokens: 900, output_tokens: 420, cached_tokens: 0, reasoning_tokens: 0,
51+
cost_usd: cost, session_id: session.id, provider_meta: JSON.encode!(%{}), inserted_at: now}
52+
end
53+
Repo.insert_all("usage_events", rows)
54+
55+
Trinity.Telemetry.approval_requested("write_note", :write, session.id)
56+
Trinity.Telemetry.approval_decided("write_note", :write, session.id, decision: :once, basis: :user, waited_ms: 3400)
57+
Trinity.Telemetry.gateway_inbound("console", :placed)
58+
Trinity.Telemetry.gateway_outbound("console", 412)
59+
60+
# One real turn, so the feed carries llm and session events rather than only hand-emitted ones.
61+
Fake.scripts([Enum.flat_map(String.split("The chain checkpoints every 200 receipts and on terminate."), &[{:text_delta, &1 <> " "}, {:sleep, 8}]) ++ [{:usage, %{input_tokens: 900, output_tokens: 420}}, {:done, :stop}]])
62+
{:ok, _} = Sessions.ensure_started(session.id)
63+
{:ok, _} = Sessions.send_user_message(session.id, "When does the chain checkpoint?")
64+
Process.sleep(1200)
65+
66+
# A session left mid-turn, so the dashboard lists a live process whose state is not idle.
67+
{:ok, busy} = Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "Tracing a long migration history"})
68+
Fake.scripts([[{:text_delta, "Reading migrations "}, {:sleep, 600000}, {:done, :stop}]])
69+
{:ok, _} = Sessions.ensure_started(busy.id)
70+
spawn(fn -> Sessions.send_user_message(busy.id, "Trace every migration that touched the receipts table") end)
71+
Process.sleep(1500)
72+
73+
IO.puts("READY")
74+
'

‎test/trinity/telemetry/telemetry_test.exs‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,28 @@ defmodule Trinity.TelemetryTest do
6969
refute inspect(stop_meta) =~ secret
7070
end
7171

72+
test "a completed model call emits llm.call.stop with its tokens and its cost", %{ref: ref} do
73+
{:ok, session} =
74+
Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "t"})
75+
76+
Trinity.LLM.Providers.Fake.scripts([script_deltas(2, "hello ")])
77+
{:ok, _pid} = Sessions.ensure_started(session.id)
78+
{:ok, _} = Sessions.send_user_message(session.id, "say hello")
79+
80+
assert_receive {^ref, [:trinity, :llm, :call, :stop], measurements, meta}, 5_000
81+
82+
assert is_integer(measurements.input_tokens)
83+
assert is_number(measurements.cost_usd)
84+
assert meta.session_id == session.id
85+
assert is_binary(meta.model)
86+
87+
# The event describes the call the ledger row describes, so a dashboard total and a bill
88+
# cannot disagree by construction.
89+
assert_in_delta measurements.cost_usd,
90+
Trinity.Telemetry.Costs.for_session(session.id),
91+
0.0001
92+
end
93+
7294
test "a session transition names where it went", %{ref: ref} do
7395
{:ok, session} =
7496
Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "t"})
@@ -122,6 +144,87 @@ defmodule Trinity.TelemetryTest do
122144
end
123145
end
124146

147+
describe "AC5: a turn is a trace, without a tracing library" do
148+
test "the events of one turn share a trace id and name a parent", %{ref: ref} do
149+
{:ok, session} =
150+
Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "t"})
151+
152+
Trinity.LLM.Providers.Fake.scripts([script_deltas(1, "hi ")])
153+
{:ok, _pid} = Sessions.ensure_started(session.id)
154+
{:ok, _} = Sessions.send_user_message(session.id, "hello")
155+
156+
assert_receive {^ref, [:trinity, :llm, :call, :stop], _, meta}, 5_000
157+
158+
assert is_binary(meta.trace_id)
159+
assert byte_size(meta.trace_id) == 16
160+
assert is_binary(meta.parent_span_id)
161+
end
162+
163+
test "two turns are two traces, so a tree is one turn rather than all of them", %{ref: ref} do
164+
{:ok, session} =
165+
Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "t"})
166+
167+
Trinity.LLM.Providers.Fake.scripts([script_deltas(1, "a "), script_deltas(1, "b ")])
168+
{:ok, _pid} = Sessions.ensure_started(session.id)
169+
170+
{:ok, _} = Sessions.send_user_message(session.id, "first")
171+
assert_receive {^ref, [:trinity, :llm, :call, :stop], _, first}, 5_000
172+
173+
{:ok, _} = Sessions.send_user_message(session.id, "second")
174+
assert_receive {^ref, [:trinity, :llm, :call, :stop], _, second}, 5_000
175+
176+
refute first.trace_id == second.trace_id
177+
end
178+
179+
test "outside a trace an event carries no trace id, rather than an invented one" do
180+
ref = make_ref()
181+
me = self()
182+
handler = "t3-#{System.unique_integer([:positive])}"
183+
184+
:telemetry.attach(
185+
handler,
186+
[:trinity, :approval, :requested],
187+
fn _, m, md, _ -> send(me, {ref, m, md}) end,
188+
nil
189+
)
190+
191+
on_exit(fn -> :telemetry.detach(handler) end)
192+
193+
Telemetry.approval_requested("write_note", :write, "sess-1")
194+
195+
assert_receive {^ref, _, meta}
196+
# A span with a trace id nothing else shares is noise wearing a tree's clothes.
197+
refute Map.has_key?(meta, :trace_id)
198+
end
199+
200+
test "a nested span names the enclosing one as its parent" do
201+
ref = make_ref()
202+
me = self()
203+
handler = "t4-#{System.unique_integer([:positive])}"
204+
205+
:telemetry.attach_many(
206+
handler,
207+
Telemetry.catalogue(),
208+
fn n, m, md, _ -> send(me, {ref, n, m, md}) end,
209+
nil
210+
)
211+
212+
on_exit(fn -> :telemetry.detach(handler) end)
213+
214+
Telemetry.trace(fn ->
215+
outer = Telemetry.context()
216+
217+
Telemetry.span([:tool, :call], %{tool: "echo"}, fn -> {:ok, %{result: :ok}} end)
218+
219+
assert_receive {^ref, [:trinity, :tool, :call, :stop], _, meta}
220+
assert meta.trace_id == outer.trace_id
221+
assert meta.parent_span_id == outer.span_id
222+
assert is_binary(meta.span_id)
223+
refute meta.span_id == outer.span_id
224+
end)
225+
end
226+
end
227+
125228
describe "the catalogue and the code agree" do
126229
test "every event the catalogue lists appears in docs/telemetry.md" do
127230
doc = File.read!("docs/telemetry.md")

0 commit comments

Comments
 (0)