Skip to content

Commit b425e08

Browse files
committed
feat(s090): the Activity page, its bounded buffer, and a LiveDashboard sessions page
Trinity.Telemetry.Activity keeps the last 200 events and drops the oldest. Bounded because an unbounded event buffer is a memory leak with a nice name, and in memory only because what must survive a restart is a receipt or a usage row, both written to disk by the code that caused them rather than by a listener. Its handler does one send and nothing else. A telemetry handler runs in the process that emitted the event, so work done there is charged to whatever turn happened to emit it. The handler is attached in init and detached in terminate so its lifetime is the buffer's: a handler that outlives its target sends to a dead pid on every event for the rest of the run. The Activity page shows those events beside the cost totals, because what happened and what it cost are the same question asked twice and splitting them across two pages makes a person do the joining. It reads and never writes. The test that matters there is a negative one. Events carry no prompt text, no completion text and no tool arguments, so a test asserting rows appear would pass equally well on a page that had quietly started showing conversations. A secret is passed as a tool argument and the page is asserted not to contain it. The LiveDashboard page answers what LiveDashboard cannot: which processes are Trinity sessions and what state each machine is in, which is the question someone asks when a turn looks stuck. It asks the process rather than reading the row, because a row reads active long after its process has gone: slice 080 found that in a panel that promised which are running and delivered which are not archived. 6 more tests. mix gate exit 0; 671 passed; coverage 80.31%. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 19b2ac4 commit b425e08

7 files changed

Lines changed: 490 additions & 1 deletion

File tree

‎lib/trinity.ex‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ defmodule Trinity do
3030
# redaction filter is exported because the application installs it as a primary Logger
3131
# filter at boot.
3232
Telemetry,
33+
Telemetry.Activity,
34+
Telemetry.Costs,
3335
Telemetry.Redaction,
3436
Repo,
3537
Repo.Receipts,

‎lib/trinity/application.ex‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ defmodule Trinity.Application do
3333
desktop_children() ++
3434
[
3535
TrinityWeb.Telemetry,
36+
# Slice 090: the Activity page's bounded buffer. Early, so it is listening before
37+
# anything it would want to have recorded has happened.
38+
Trinity.Telemetry.Activity,
3639
# Slice 010: one node per data directory. Before the Repo, so a refused boot has
3740
# opened no database file; the reason names the holder's OS pid and mode.
3841
{Trinity.DataDir.Lock, dir: lock_dir(), mode: mode()},

‎lib/trinity/telemetry/activity.ex‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Telemetry.Activity do
4+
@moduledoc """
5+
A bounded, in-memory record of recent events, for the Activity page (slice 090).
6+
7+
**Bounded, because an unbounded event buffer is a memory leak with a nice name.** It keeps the
8+
last `limit/0` events and drops the oldest; nothing here is durable and nothing here is meant to
9+
be. What must survive a restart is a receipt (slice 024) or a usage row (slice 011), and both are
10+
written to disk by the code that caused them rather than by a listener.
11+
12+
**It holds what the catalogue allows and nothing else.** The events themselves carry no prompt
13+
text, no completion text and no tool arguments (`docs/telemetry.md`), so neither does this. That
14+
is worth stating because an activity feed is exactly the feature that tempts someone to enrich a
15+
record with "just the first line of the message", and the place to refuse that is here, where the
16+
refusal is visible, rather than in review.
17+
18+
It is a `GenServer` rather than an ETS table so that the handler's work is bounded by a mailbox
19+
it cannot outrun: a telemetry handler runs **in the process that emitted the event**, and doing
20+
real work there would put this module's cost on the turn's latency.
21+
"""
22+
23+
use GenServer
24+
25+
alias Trinity.Telemetry
26+
27+
@handler "trinity-activity"
28+
@limit 200
29+
30+
@type entry :: %{
31+
at: DateTime.t(),
32+
event: [atom()],
33+
measurements: map(),
34+
metadata: map()
35+
}
36+
37+
@doc "How many events are kept."
38+
@spec limit() :: pos_integer()
39+
def limit, do: @limit
40+
41+
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
42+
43+
@doc """
44+
The recent events, newest first.
45+
46+
Options: `:session_id` and `:event` (a prefix, so `[:trinity, :tool]` matches both the start and
47+
the stop).
48+
"""
49+
@spec recent(keyword()) :: [entry()]
50+
def recent(opts \\ []) do
51+
GenServer.call(__MODULE__, :recent)
52+
|> filter_session(opts[:session_id])
53+
|> filter_event(opts[:event])
54+
catch
55+
:exit, _ -> []
56+
end
57+
58+
@doc "Empties the buffer. For tests, and for a person who wants the page to start again."
59+
@spec clear() :: :ok
60+
def clear do
61+
GenServer.call(__MODULE__, :clear)
62+
catch
63+
:exit, _ -> :ok
64+
end
65+
66+
@impl true
67+
def init(_opts) do
68+
# Attached here rather than in the application so the handler's lifetime is this process's: a
69+
# handler that outlives its target sends to a dead pid on every event for the rest of the run.
70+
:telemetry.attach_many(@handler, Telemetry.catalogue(), &__MODULE__.handle/4, nil)
71+
{:ok, %{entries: []}}
72+
end
73+
74+
@impl true
75+
def terminate(_reason, _state) do
76+
:telemetry.detach(@handler)
77+
:ok
78+
end
79+
80+
@doc false
81+
# Runs in the emitting process. It does one `send` and nothing else, on purpose: a handler that
82+
# did real work here would charge it to whatever turn happened to emit the event.
83+
def handle(event, measurements, metadata, _config) do
84+
send(__MODULE__, {:event, event, measurements, metadata, DateTime.utc_now()})
85+
:ok
86+
rescue
87+
# A send to a name that is not registered raises; an unstarted buffer must not break a turn.
88+
_ -> :ok
89+
end
90+
91+
@impl true
92+
def handle_info({:event, event, measurements, metadata, at}, state) do
93+
entry = %{at: at, event: event, measurements: measurements, metadata: metadata}
94+
{:noreply, %{state | entries: Enum.take([entry | state.entries], @limit)}}
95+
end
96+
97+
def handle_info(_other, state), do: {:noreply, state}
98+
99+
@impl true
100+
def handle_call(:recent, _from, state), do: {:reply, state.entries, state}
101+
def handle_call(:clear, _from, state), do: {:reply, :ok, %{state | entries: []}}
102+
103+
defp filter_session(entries, nil), do: entries
104+
105+
defp filter_session(entries, session_id),
106+
do: Enum.filter(entries, &(&1.metadata[:session_id] == session_id))
107+
108+
defp filter_event(entries, nil), do: entries
109+
110+
defp filter_event(entries, prefix) when is_list(prefix),
111+
do: Enum.filter(entries, &List.starts_with?(&1.event, prefix))
112+
end
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule TrinityWeb.Dashboard.SessionsPage do
4+
@moduledoc """
5+
A LiveDashboard page listing the session processes that are alive, with their `gen_statem` state
6+
(slice 090, AC3).
7+
8+
LiveDashboard already lists every process in the VM. What it cannot say is which of them is a
9+
Trinity session and what state that session's machine is in, which is the question someone asks
10+
when a turn appears stuck: waiting on a model, waiting on an approval, or idle and the page did
11+
not notice.
12+
13+
It reads the registry and asks each process, so it shows the running system rather than the
14+
database's opinion of it. A session row reads `active` long after its process has gone; slice 080
15+
found that the hard way, in a panel that promised "which are running" and delivered "which are
16+
not archived".
17+
"""
18+
19+
use Phoenix.LiveDashboard.PageBuilder
20+
21+
alias Trinity.Sessions
22+
23+
@impl true
24+
def menu_link(_, _), do: {:ok, "Trinity sessions"}
25+
26+
@impl true
27+
def render(assigns) do
28+
~H"""
29+
<.live_table
30+
id="trinity-sessions"
31+
dom_id="trinity-sessions"
32+
page={@page}
33+
title="Trinity sessions"
34+
row_fetcher={&fetch_rows/2}
35+
rows_name="sessions"
36+
>
37+
<:col field={:id} header="Session" />
38+
<:col field={:state} header="State" />
39+
<:col field={:title} header="Title" />
40+
<:col field={:origin} header="Origin" />
41+
<:col field={:pid} header="Process" />
42+
<:col field={:memory} header="Memory (KB)" sortable={:desc} />
43+
</.live_table>
44+
"""
45+
end
46+
47+
@doc false
48+
def fetch_rows(params, _node) do
49+
rows = rows()
50+
{sort_rows(rows, params), length(rows)}
51+
end
52+
53+
defp rows do
54+
for session <- Sessions.list_sessions(limit: 200),
55+
pid = Sessions.whereis(session.id),
56+
is_pid(pid) do
57+
%{
58+
id: short(session.id),
59+
state: state_of(session.id),
60+
title: session.title || "(untitled)",
61+
origin: session.origin,
62+
pid: inspect(pid),
63+
memory: memory_kb(pid)
64+
}
65+
end
66+
end
67+
68+
# Asked of the process, never read from the row: a row's status is the session's lifecycle and
69+
# says nothing about whether its machine is doing anything.
70+
defp state_of(session_id) do
71+
case Sessions.state(session_id) do
72+
%{state: state} -> to_string(state)
73+
_ -> "-"
74+
end
75+
catch
76+
:exit, _ -> "-"
77+
end
78+
79+
defp memory_kb(pid) do
80+
case Process.info(pid, :memory) do
81+
{:memory, bytes} -> div(bytes, 1024)
82+
_ -> 0
83+
end
84+
end
85+
86+
defp short(id), do: String.slice(id, -8, 8)
87+
88+
defp sort_rows(rows, %{sort_by: by, sort_dir: dir}) when is_atom(by),
89+
do: Enum.sort_by(rows, &Map.get(&1, by), dir)
90+
91+
defp sort_rows(rows, _params), do: rows
92+
end

0 commit comments

Comments
 (0)