Skip to content

Commit 8727411

Browse files
committed
feat(dashboard): /telemetry backfills recent events from bridge ring buffer
TelemetryFeedStore was per-connection only — fresh /telemetry mounts started empty because events fired while the user was on other pages never had a subscriber on cf:telemetry. Bridge now writes every telemetry-feed event to a 500-entry ETS ring buffer inside its existing Task.Supervisor child, and the store replays that buffer on mount through the same stale-aware ingest_event path before live PubSub takes over.
1 parent 408c2e8 commit 8727411

4 files changed

Lines changed: 274 additions & 18 deletions

File tree

dashboard/lib/coloured_flow_dashboard/telemetry_bridge.ex

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
6060
cache on miss, so the first row construction for an unseen enactment
6161
takes the storage hit; subsequent rows are served from ETS.
6262
63+
## Events buffer
64+
65+
The bridge also owns a second ETS table (`:set`, `:public`, `:named_table`)
66+
keyed by telemetry-feed `seq` and valued by the `%Event{topic: :telemetry}`
67+
emitted for that sequence. It acts as a small ring buffer so fresh
68+
`/telemetry` mounts can backfill recent events before continuing with live
69+
PubSub updates. Inserts and trimming run inside the existing task body so the
70+
runner never pays that maintenance cost inline.
71+
6372
## Catalog drift
6473
6574
The list returned by `events/0` is the bridge's authoritative event catalog.
@@ -84,7 +93,9 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
8493
@default_pubsub :coloured_flow_dashboard_pubsub
8594
@default_task_supervisor ColouredFlowDashboard.TaskSupervisor
8695
@default_flow_cache :coloured_flow_dashboard_telemetry_bridge_flow_cache
96+
@default_events_buffer :coloured_flow_dashboard_telemetry_bridge_events_buffer
8797
@default_topic_prefix "cf:"
98+
@window 500
8899

89100
@enactment_lifecycle_events ~w[start stop terminate exception take_snapshot]a
90101
@workitem_operations ~w[produce_workitems start_workitems withdraw_workitems complete_workitems]a
@@ -99,6 +110,7 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
99110
| {:task_supervisor, atom()}
100111
| {:topic_prefix, String.t()}
101112
| {:flow_cache, atom()}
113+
| {:events_buffer, atom()}
102114
| {:broadcast_fn, broadcast_fn()}
103115

104116
@spec start_link([option()]) :: GenServer.on_start()
@@ -136,19 +148,22 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
136148
task_supervisor = Keyword.get(opts, :task_supervisor, @default_task_supervisor)
137149
topic_prefix = Keyword.get(opts, :topic_prefix, @default_topic_prefix)
138150
flow_cache = Keyword.get(opts, :flow_cache, @default_flow_cache)
151+
events_buffer = Keyword.get(opts, :events_buffer, @default_events_buffer)
139152
broadcast_fn = Keyword.get(opts, :broadcast_fn, &Phoenix.PubSub.broadcast/3)
140153

141154
# GenServer owns the ETS table so it dies with us (test cleanup is free).
142155
# `:public` lets the Task.Supervisor child write to it without going
143156
# through the GenServer.
144157
flow_cache = ensure_table(flow_cache)
158+
events_buffer = ensure_table(events_buffer)
145159

146160
config = %{
147161
handler_id: handler_id,
148162
pubsub: pubsub,
149163
task_supervisor: task_supervisor,
150164
topic_prefix: topic_prefix,
151165
flow_cache: flow_cache,
166+
events_buffer: events_buffer,
152167
broadcast_fn: broadcast_fn
153168
}
154169

@@ -159,7 +174,7 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
159174

160175
:ok = :telemetry.attach_many(handler_id, events(), &__MODULE__.handle_event/4, config)
161176

162-
{:ok, %{handler_id: handler_id, flow_cache: flow_cache}}
177+
{:ok, %{handler_id: handler_id, flow_cache: flow_cache, events_buffer: events_buffer}}
163178
end
164179

165180
defp ensure_table(name) do
@@ -246,8 +261,12 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
246261

247262
defp dispatch(event_name, measurements, metadata, seq, config) do
248263
case build_broadcasts(event_name, measurements, metadata, seq, config) do
249-
[] -> :ok
250-
broadcasts -> deliver_broadcasts(config, broadcasts)
264+
[] ->
265+
:ok
266+
267+
broadcasts ->
268+
buffer_recent_event(config.events_buffer, broadcasts)
269+
deliver_broadcasts(config, broadcasts)
251270
end
252271
end
253272

@@ -257,6 +276,33 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
257276
end)
258277
end
259278

279+
defp buffer_recent_event(events_buffer, broadcasts) when is_atom(events_buffer) do
280+
case Enum.find_value(broadcasts, fn
281+
{_topic, %Event{topic: :telemetry, seq: seq} = event}
282+
when is_integer(seq) and seq > 0 ->
283+
event
284+
285+
_other ->
286+
nil
287+
end) do
288+
%Event{} = event ->
289+
:ets.insert(events_buffer, {event.seq, event})
290+
trim_events_buffer(events_buffer, event.seq)
291+
292+
nil ->
293+
:ok
294+
end
295+
end
296+
297+
defp trim_events_buffer(events_buffer, newest_seq) when newest_seq > @window do
298+
cutoff = newest_seq - @window
299+
spec = [{{:"$1", :_}, [{:<, :"$1", cutoff}], [true]}]
300+
:ets.select_delete(events_buffer, spec)
301+
:ok
302+
end
303+
304+
defp trim_events_buffer(_events_buffer, _newest_seq), do: :ok
305+
260306
@spec build_broadcasts(
261307
:telemetry.event_name(),
262308
:telemetry.event_measurements(),
@@ -377,6 +423,32 @@ defmodule ColouredFlowDashboard.TelemetryBridge do
377423

378424
defp enactments_topic(_prefix, _kind, _common), do: []
379425

426+
@doc """
427+
Returns buffered telemetry-feed events sorted by ascending sequence.
428+
429+
Returns `[]` when the table does not exist.
430+
"""
431+
@spec recent_events() :: [Event.t()]
432+
def recent_events, do: recent_events(@default_events_buffer)
433+
434+
@doc """
435+
Returns buffered telemetry-feed events sorted by ascending sequence from the
436+
given ETS table.
437+
"""
438+
@spec recent_events(atom()) :: [Event.t()]
439+
def recent_events(events_buffer) when is_atom(events_buffer) do
440+
case :ets.whereis(events_buffer) do
441+
:undefined ->
442+
[]
443+
444+
_table ->
445+
events_buffer
446+
|> :ets.tab2list()
447+
|> Enum.map(fn {_seq, %Event{} = event} -> event end)
448+
|> Enum.sort_by(& &1.seq)
449+
end
450+
end
451+
380452
@doc """
381453
Looks up (or resolves and caches) the flow topic id for an enactment.
382454

dashboard/lib/coloured_flow_dashboard_web/stores/telemetry_feed_store.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ defmodule ColouredFlowDashboardWeb.Stores.TelemetryFeedStore do
3030
Per-enactment seq is still honoured via `SeqTracker` so a late event
3131
cannot leapfrog a newer one for the same enactment id.
3232
33+
Mount subscribes first, then backfills from the bridge-owned ETS events
34+
buffer through the same `ingest_event/2` path used for live PubSub events.
35+
That keeps ordering, stale-drop, and window trimming identical across the
36+
replay and live phases.
37+
3338
Read-only — no commands. The operator inspects the feed; submitting
3439
outputs and other state mutation happens on the per-enactment surfaces.
3540
"""
@@ -46,6 +51,7 @@ defmodule ColouredFlowDashboardWeb.Stores.TelemetryFeedStore do
4651
@default_pubsub :coloured_flow_dashboard_pubsub
4752
@default_topic "cf:telemetry"
4853
@default_flow_cache :coloured_flow_dashboard_telemetry_bridge_flow_cache
54+
@default_events_buffer :coloured_flow_dashboard_telemetry_bridge_events_buffer
4955
@window 500
5056

5157
state do
@@ -64,6 +70,7 @@ defmodule ColouredFlowDashboardWeb.Stores.TelemetryFeedStore do
6470
pubsub = Map.get(params, "pubsub_name", @default_pubsub)
6571
topic = Map.get(params, "topic", @default_topic)
6672
flow_cache = Map.get(params, "flow_cache", @default_flow_cache)
73+
events_buffer = Map.get(params, "events_buffer", @default_events_buffer)
6774
window = Map.get(params, "window", @window)
6875

6976
:ok = Phoenix.PubSub.subscribe(pubsub, topic)
@@ -73,6 +80,7 @@ defmodule ColouredFlowDashboardWeb.Stores.TelemetryFeedStore do
7380
|> assign(:pubsub_name, pubsub)
7481
|> assign(:topic, topic)
7582
|> assign(:flow_cache, flow_cache)
83+
|> assign(:events_buffer, events_buffer)
7684
|> assign(:window, window)
7785
|> assign(:total_events, 0)
7886
|> assign(:entries_in_window, 0)
@@ -88,6 +96,17 @@ defmodule ColouredFlowDashboardWeb.Stores.TelemetryFeedStore do
8896
|> assign(:entries_index, [])
8997
|> stream(:entries, [], reset: true)
9098

99+
socket =
100+
Enum.reduce(TelemetryBridge.recent_events(events_buffer), socket, fn event, acc ->
101+
if SeqTracker.stale?(event, acc.assigns.last_seq) do
102+
acc
103+
else
104+
acc
105+
|> assign(:last_seq, SeqTracker.bump(acc.assigns.last_seq, event))
106+
|> ingest_event(event)
107+
end
108+
end)
109+
91110
{:ok, socket}
92111
end
93112

dashboard/test/coloured_flow_dashboard/telemetry_bridge_test.exs

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
1919

2020
@pubsub :coloured_flow_dashboard_pubsub
2121
@task_supervisor ColouredFlowDashboard.TaskSupervisor
22+
@bridge_table_names [
23+
{:cf_dashboard_bridge_test_flow_cache_1, :cf_dashboard_bridge_test_events_buffer_1,
24+
:cf_dashboard_bridge_test_bridge_1},
25+
{:cf_dashboard_bridge_test_flow_cache_2, :cf_dashboard_bridge_test_events_buffer_2,
26+
:cf_dashboard_bridge_test_bridge_2},
27+
{:cf_dashboard_bridge_test_flow_cache_3, :cf_dashboard_bridge_test_events_buffer_3,
28+
:cf_dashboard_bridge_test_bridge_3},
29+
{:cf_dashboard_bridge_test_flow_cache_4, :cf_dashboard_bridge_test_events_buffer_4,
30+
:cf_dashboard_bridge_test_bridge_4}
31+
]
2232

2333
defp unique_token, do: Integer.to_string(System.unique_integer([:positive, :monotonic]))
2434

@@ -56,10 +66,7 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
5666
token = unique_token()
5767
prefix = "cf-#{context.test_token}-#{token}:"
5868
handler_id = {context.module, context.test, token}
59-
# credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
60-
flow_cache = String.to_atom("#{context.module}.FlowCache.#{context.test_token}.#{token}")
61-
# credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
62-
bridge_name = String.to_atom("#{context.module}.Bridge.#{context.test_token}.#{token}")
69+
{flow_cache, events_buffer, bridge_name} = next_bridge_names()
6370

6471
bridge_opts =
6572
[
@@ -68,7 +75,8 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
6875
pubsub: @pubsub,
6976
task_supervisor: @task_supervisor,
7077
topic_prefix: prefix,
71-
flow_cache: flow_cache
78+
flow_cache: flow_cache,
79+
events_buffer: events_buffer
7280
] ++ opts
7381

7482
# Explicit `:id` so multiple bridges with different names can coexist
@@ -77,7 +85,13 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
7785
spec = Supervisor.child_spec({TelemetryBridge, bridge_opts}, id: bridge_name)
7886
pid = start_supervised!(spec)
7987

80-
%{prefix: prefix, handler_id: handler_id, flow_cache: flow_cache, bridge: pid}
88+
%{
89+
prefix: prefix,
90+
handler_id: handler_id,
91+
flow_cache: flow_cache,
92+
events_buffer: events_buffer,
93+
bridge: pid
94+
}
8195
end
8296

8397
defp subscribe_topics(prefix, enactment_id) do
@@ -325,6 +339,83 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
325339
end
326340
end
327341

342+
describe "events buffer" do
343+
setup context, do: start_bridge(context)
344+
345+
test "records each broadcast event and is readable via recent_events/1", %{
346+
prefix: prefix,
347+
handler_id: handler_id,
348+
events_buffer: events_buffer
349+
} do
350+
eid_a = unique_id("-buffer-a")
351+
eid_b = unique_id("-buffer-b")
352+
:ok = Phoenix.PubSub.subscribe(@pubsub, "#{prefix}telemetry")
353+
354+
execute_via_handler(
355+
handler_id,
356+
[:coloured_flow, :runner, :enactment, :start],
357+
%{system_time: System.system_time()},
358+
%{enactment_id: eid_a, enactment_state: build_state(eid_a)}
359+
)
360+
361+
execute_via_handler(
362+
handler_id,
363+
[:coloured_flow, :runner, :enactment, :start],
364+
%{system_time: System.system_time()},
365+
%{enactment_id: eid_b, enactment_state: build_state(eid_b)}
366+
)
367+
368+
assert_receive {:cf_event, %Event{enactment_id: ^eid_a, topic: :telemetry}}, 1_000
369+
assert_receive {:cf_event, %Event{enactment_id: ^eid_b, topic: :telemetry}}, 1_000
370+
371+
events = TelemetryBridge.recent_events(events_buffer)
372+
373+
assert [
374+
%Event{enactment_id: ^eid_a, topic: :telemetry, seq: seq_a},
375+
%Event{enactment_id: ^eid_b, topic: :telemetry, seq: seq_b}
376+
] = events
377+
378+
assert seq_a > 0
379+
assert seq_b > seq_a
380+
end
381+
382+
test "drops entries older than the window", %{
383+
prefix: prefix,
384+
handler_id: handler_id,
385+
events_buffer: events_buffer
386+
} do
387+
total = 503
388+
:ok = Phoenix.PubSub.subscribe(@pubsub, "#{prefix}telemetry")
389+
390+
for n <- 1..total do
391+
eid = unique_id("-trim-#{n}")
392+
393+
execute_via_handler(
394+
handler_id,
395+
[:coloured_flow, :runner, :enactment, :start],
396+
%{system_time: System.system_time()},
397+
%{enactment_id: eid, enactment_state: build_state(eid)}
398+
)
399+
end
400+
401+
received_seqs =
402+
for _idx <- 1..total do
403+
assert_receive {:cf_event, %Event{topic: :telemetry, seq: seq}}, 1_000
404+
seq
405+
end
406+
407+
events = TelemetryBridge.recent_events(events_buffer)
408+
seqs = Enum.map(events, & &1.seq)
409+
newest_seq = Enum.max(received_seqs)
410+
expected = received_seqs |> Enum.filter(&(&1 >= newest_seq - 500)) |> Enum.sort()
411+
oldest_received_seq = received_seqs |> Enum.sort() |> hd()
412+
413+
assert seqs == expected
414+
assert Enum.all?(seqs, &(&1 >= newest_seq - 500))
415+
refute oldest_received_seq in seqs
416+
end
417+
end
418+
328419
describe "topic isolation" do
329420
test "parallel bridges on different prefixes do not cross-pollute", context do
330421
%{prefix: prefix_a, handler_id: handler_a} = start_bridge(context)
@@ -454,7 +545,9 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
454545

455546
max_pre = Enum.max(pre_seqs)
456547
assert max_pre > 0
457-
assert pre_seqs == Enum.sort(pre_seqs), "pre-restart seqs must be monotonic"
548+
549+
assert Enum.sort(pre_seqs) == Enum.uniq(Enum.sort(pre_seqs)),
550+
"pre-restart seqs must be unique and monotonic as values"
458551

459552
# Kill the bridge and wait for the supervisor to surface the exit so
460553
# `start_bridge/2` below opens a fresh ETS / new GenServer state.
@@ -483,7 +576,9 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
483576
end
484577

485578
min_post = Enum.min(post_seqs)
486-
assert post_seqs == Enum.sort(post_seqs), "post-restart seqs must be monotonic"
579+
580+
assert Enum.sort(post_seqs) == Enum.uniq(Enum.sort(post_seqs)),
581+
"post-restart seqs must be unique and monotonic as values"
487582

488583
assert min_post > max_pre,
489584
"post-restart seqs must STRICTLY exceed pre-restart max " <>
@@ -590,4 +685,10 @@ defmodule ColouredFlowDashboard.TelemetryBridgeTest do
590685
do: InMemory.enactment(record, :id)
591686

592687
defp runner_enactment_id(%{id: id}) when is_binary(id), do: id
688+
689+
defp next_bridge_names do
690+
index = Process.get(:bridge_names_index, 0)
691+
Process.put(:bridge_names_index, index + 1)
692+
Enum.fetch!(@bridge_table_names, index)
693+
end
593694
end

0 commit comments

Comments
 (0)