Skip to content

Commit 0c219f0

Browse files
committed
feat(admin): add stats dashboard observability
1 parent 4aa675c commit 0c219f0

9 files changed

Lines changed: 784 additions & 40 deletions

File tree

lib/codex_pooler/admin/activity_read_model.ex

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ defmodule CodexPooler.Admin.ActivityReadModel do
88
alias CodexPooler.Audit.AuditEvent
99
alias CodexPooler.Repo
1010

11+
@type activity_source_counts :: %{
12+
required(:audit_events) => non_neg_integer(),
13+
required(:jobs) => non_neg_integer()
14+
}
15+
@type activity_summary :: %{
16+
required(:recent_activity) => [map()],
17+
required(:source_counts) => activity_source_counts()
18+
}
19+
20+
@spec activity_summary_for_pool_ids([Ecto.UUID.t()], DateTime.t(), DateTime.t()) ::
21+
activity_summary()
22+
def activity_summary_for_pool_ids([], _started_at, _ended_at) do
23+
%{recent_activity: [], source_counts: %{audit_events: 0, jobs: 0}}
24+
end
25+
26+
def activity_summary_for_pool_ids(pool_ids, started_at, ended_at) do
27+
audit_activity = audit_events_for(pool_ids, started_at, ended_at)
28+
job_activity = jobs_for(pool_ids, started_at, ended_at)
29+
30+
%{
31+
recent_activity: recent_activity(audit_activity, job_activity),
32+
source_counts: %{
33+
audit_events: source_total(audit_activity),
34+
jobs: source_total(job_activity)
35+
}
36+
}
37+
end
38+
1139
@spec recent_activity_for_pool_ids([Ecto.UUID.t()], DateTime.t(), DateTime.t()) :: [map()]
1240
def recent_activity_for_pool_ids([], _started_at, _ended_at), do: []
1341

@@ -17,13 +45,14 @@ defmodule CodexPooler.Admin.ActivityReadModel do
1745
|> recent_activity(jobs_for(pool_ids, started_at, ended_at))
1846
end
1947

20-
@spec activity_source_counts([Ecto.UUID.t()], DateTime.t(), DateTime.t()) :: map()
48+
@spec activity_source_counts([Ecto.UUID.t()], DateTime.t(), DateTime.t()) ::
49+
activity_source_counts()
2150
def activity_source_counts([], _started_at, _ended_at), do: %{audit_events: 0, jobs: 0}
2251

2352
def activity_source_counts(pool_ids, started_at, ended_at) do
2453
%{
25-
audit_events: length(audit_events_for(pool_ids, started_at, ended_at)),
26-
jobs: length(jobs_for(pool_ids, started_at, ended_at))
54+
audit_events: source_total(audit_events_for(pool_ids, started_at, ended_at)),
55+
jobs: source_total(jobs_for(pool_ids, started_at, ended_at))
2756
}
2857
end
2958

@@ -33,16 +62,20 @@ defmodule CodexPooler.Admin.ActivityReadModel do
3362
where:
3463
event.pool_id in ^pool_ids and event.occurred_at >= ^started_at and
3564
event.occurred_at <= ^ended_at,
65+
windows: [all: []],
3666
order_by: [desc: event.occurred_at, desc: event.id],
37-
limit: 5,
67+
limit: 10,
3868
select: %{
3969
type: :audit_event,
4070
id: event.id,
4171
occurred_at: event.occurred_at,
4272
pool_id: event.pool_id,
4373
action: event.action,
4474
target_type: event.target_type,
45-
outcome: event.outcome
75+
outcome: event.outcome,
76+
source_rank: 0,
77+
source_order_id: event.id,
78+
source_total: over(count(event.id), :all)
4679
}
4780
)
4881
end
@@ -53,22 +86,50 @@ defmodule CodexPooler.Admin.ActivityReadModel do
5386
where:
5487
fragment("?->>?", job.args, "pool_id") in ^pool_ids and
5588
job.inserted_at >= ^started_at and job.inserted_at <= ^ended_at,
89+
windows: [all: []],
5690
order_by: [desc: job.inserted_at, desc: job.id],
57-
limit: 5,
91+
limit: 10,
5892
select: %{
5993
type: :job,
6094
id: job.id,
6195
occurred_at: job.inserted_at,
6296
state: job.state,
6397
worker: job.worker,
64-
queue: job.queue
98+
queue: job.queue,
99+
source_rank: 1,
100+
source_order_id: fragment("lpad(?::text, 20, '0')", job.id),
101+
source_total: over(count(job.id), :all)
65102
}
66103
)
67104
end
68105

69106
defp recent_activity(audit_activity, job_activity) do
70107
(audit_activity ++ job_activity)
71-
|> Enum.sort_by(& &1.occurred_at, {:desc, DateTime})
108+
|> Enum.sort(&activity_before?/2)
72109
|> Enum.take(10)
110+
|> Enum.map(&strip_internal_fields/1)
111+
end
112+
113+
defp activity_before?(left, right) do
114+
case DateTime.compare(left.occurred_at, right.occurred_at) do
115+
:gt -> true
116+
:lt -> false
117+
:eq -> activity_tie_before?(left, right)
118+
end
119+
end
120+
121+
defp activity_tie_before?(left, right) do
122+
cond do
123+
left.source_rank < right.source_rank -> true
124+
left.source_rank > right.source_rank -> false
125+
true -> left.source_order_id > right.source_order_id
126+
end
127+
end
128+
129+
defp source_total([]), do: 0
130+
defp source_total([%{source_total: source_total} | _rows]), do: source_total
131+
132+
defp strip_internal_fields(row) do
133+
Map.drop(row, [:source_rank, :source_order_id, :source_total])
73134
end
74135
end

lib/codex_pooler/admin/stats.ex

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,15 @@ defmodule CodexPooler.Admin.Stats do
157157
turns =
158158
GatewayReadModel.turns_for_pool_ids(pool_ids, normalized.started_at, normalized.ended_at)
159159

160-
recent_activity =
161-
ActivityReadModel.recent_activity_for_pool_ids(
160+
activity_summary =
161+
ActivityReadModel.activity_summary_for_pool_ids(
162162
pool_ids,
163163
normalized.started_at,
164164
normalized.ended_at
165165
)
166166

167-
activity_counts =
168-
ActivityReadModel.activity_source_counts(
169-
pool_ids,
170-
normalized.started_at,
171-
normalized.ended_at
172-
)
167+
recent_activity = activity_summary.recent_activity
168+
activity_counts = activity_summary.source_counts
173169

174170
quota_accounts =
175171
Quota.ReadModel.account_summaries_for_pool_ids(pool_ids, normalized.ended_at)

lib/codex_pooler_web/live/admin/pages/stats_live.ex

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
1111

1212
@stats_reload_debounce_ms 1_000
1313
@stats_event_topics ~w(request_logs usage upstreams job_status model_sync)
14+
@reload_telemetry_event [:codex_pooler, :admin, :stats_live, :reload]
15+
@dashboard_build_telemetry_event [:codex_pooler, :admin, :stats, :dashboard, :build]
16+
@telemetry_windows ~w(1h 5h 24h 7d)
1417

1518
@window_options [
1619
{"Last 1 hour", "1h"},
@@ -64,7 +67,7 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
6467
end
6568

6669
def handle_info(:reload_stats_dashboard, socket) do
67-
notify_stats_reload(:reloaded)
70+
notify_stats_reload(:executed, socket.assigns.current_params)
6871

6972
{:noreply,
7073
socket
@@ -179,9 +182,10 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
179182
defp load_dashboard(socket, params) do
180183
filters = stats_filters(params)
181184

182-
case Stats.build_dashboard(socket.assigns.current_scope, filters) do
185+
case build_dashboard_with_telemetry(socket.assigns.current_scope, filters) do
183186
{:ok, dashboard} ->
184187
socket
188+
|> assign(:current_params, params)
185189
|> reconcile_pool_subscriptions(dashboard)
186190
|> assign(
187191
dashboard: dashboard,
@@ -193,6 +197,7 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
193197

194198
{:error, error} ->
195199
socket
200+
|> assign(:current_params, params)
196201
|> reconcile_pool_subscriptions(nil)
197202
|> assign(
198203
dashboard: nil,
@@ -204,6 +209,16 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
204209
end
205210
end
206211

212+
defp build_dashboard_with_telemetry(scope, filters) do
213+
started_at = System.monotonic_time()
214+
result = Stats.build_dashboard(scope, filters)
215+
duration = System.monotonic_time() - started_at
216+
217+
emit_dashboard_build_telemetry(result, filters, duration)
218+
219+
result
220+
end
221+
207222
defp reconcile_pool_subscriptions(socket, dashboard) do
208223
if connected?(socket) do
209224
{socket, stale_pool_ids} =
@@ -232,6 +247,7 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
232247

233248
defp schedule_stats_reload(socket) do
234249
if is_reference(socket.assigns[:stats_reload_timer]) do
250+
notify_stats_reload(:coalesced, socket.assigns.current_params)
235251
socket
236252
else
237253
schedule_new_stats_reload(socket)
@@ -240,13 +256,14 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
240256

241257
defp schedule_new_stats_reload(socket) do
242258
timer = Process.send_after(self(), :reload_stats_dashboard, @stats_reload_debounce_ms)
243-
notify_stats_reload(:scheduled)
259+
notify_stats_reload(:scheduled, socket.assigns.current_params)
244260
assign(socket, :stats_reload_timer, timer)
245261
end
246262

247263
defp cancel_stats_reload_timer(socket) do
248264
if is_reference(socket.assigns[:stats_reload_timer]) do
249265
Process.cancel_timer(socket.assigns.stats_reload_timer, async: false, info: false)
266+
notify_stats_reload(:cancelled, socket.assigns.current_params)
250267
end
251268

252269
assign(socket, :stats_reload_timer, nil)
@@ -257,14 +274,50 @@ defmodule CodexPoolerWeb.Admin.StatsLive do
257274

258275
defp stats_event?(_topics), do: false
259276

260-
defp notify_stats_reload(stage) do
277+
defp notify_stats_reload(stage, params) do
278+
filters = stats_filters(params || %{})
279+
280+
:telemetry.execute(
281+
@reload_telemetry_event,
282+
%{count: 1},
283+
%{stage: stage, window: telemetry_window(filters), scope: telemetry_scope(filters)}
284+
)
285+
end
286+
287+
defp emit_dashboard_build_telemetry({:ok, _dashboard}, filters, duration) do
288+
:telemetry.execute(
289+
@dashboard_build_telemetry_event,
290+
%{count: 1, duration: duration},
291+
%{outcome: :ok, window: telemetry_window(filters), scope: telemetry_scope(filters)}
292+
)
293+
end
294+
295+
defp emit_dashboard_build_telemetry({:error, error}, filters, duration) do
261296
:telemetry.execute(
262-
[:codex_pooler, :admin, :stats_live, :reload],
263-
%{},
264-
%{stage: stage, pid: self()}
297+
@dashboard_build_telemetry_event,
298+
%{count: 1, duration: duration},
299+
%{
300+
outcome: :error,
301+
window: telemetry_window(filters),
302+
scope: telemetry_scope(filters),
303+
error_code: telemetry_error_code(error)
304+
}
265305
)
266306
end
267307

308+
defp telemetry_window(%{"window" => window}), do: telemetry_window(window)
309+
defp telemetry_window(%{window: window}), do: telemetry_window(window)
310+
defp telemetry_window(window) when window in @telemetry_windows, do: window
311+
defp telemetry_window(_window), do: "unknown"
312+
313+
defp telemetry_scope(%{"pool_id" => pool_id}), do: telemetry_scope(pool_id)
314+
defp telemetry_scope(%{pool_id: pool_id}), do: telemetry_scope(pool_id)
315+
defp telemetry_scope(nil), do: "all_visible_pools"
316+
defp telemetry_scope(pool_id) when is_binary(pool_id) and pool_id != "", do: "selected_pool"
317+
defp telemetry_scope(_pool_id), do: "unknown"
318+
319+
defp telemetry_error_code(%{code: code}), do: code
320+
268321
defp stats_filters(params) do
269322
params = Map.new(params)
270323

lib/codex_pooler_web/telemetry.ex

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,16 @@ defmodule CodexPoolerWeb.Telemetry do
99
@type http_tags :: %{method: String.t(), status_class: String.t()}
1010
@type http_route_tags :: %{method: String.t(), route: String.t(), status_class: String.t()}
1111
@type admission_tags :: %{route_class: String.t(), transport: String.t()}
12+
@type admin_stats_reload_tags :: %{stage: String.t(), window: String.t(), scope: String.t()}
13+
@type admin_stats_build_tags :: %{outcome: String.t(), window: String.t(), scope: String.t()}
1214

1315
@repo_query_buckets [0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5]
1416
@admission_queue_buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5]
17+
@admin_stats_duration_buckets [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5]
18+
@admin_stats_windows ~w(1h 5h 24h 7d)
19+
@admin_stats_scopes ~w(selected_pool all_visible_pools)
20+
@admin_stats_reload_stages ~w(scheduled coalesced cancelled executed)
21+
@admin_stats_build_outcomes ~w(ok error)
1522
@repo_source_pattern ~r/\A[a-zA-Z0-9_.-]+\z/
1623
@safe_route_pattern ~r/\A[a-zA-Z0-9_.*:\/{}-]+\z/
1724
@safe_tag_pattern ~r/\A[a-zA-Z0-9_.:-]+\z/
@@ -149,6 +156,29 @@ defmodule CodexPoolerWeb.Telemetry do
149156
tag_values: &repo_query_tag_values/1,
150157
description: "Total Ecto repository queries by source and SQL command."
151158
),
159+
counter("codex_pooler.admin.stats.reload.count",
160+
event_name: [:codex_pooler, :admin, :stats_live, :reload],
161+
measurement: :count,
162+
tags: [:stage, :window, :scope],
163+
tag_values: &admin_stats_reload_tag_values/1,
164+
description: "Total admin stats dashboard reload events by stage, window, and scope."
165+
),
166+
counter("codex_pooler.admin.stats.dashboard.build.count",
167+
event_name: [:codex_pooler, :admin, :stats, :dashboard, :build],
168+
measurement: :count,
169+
tags: [:outcome, :window, :scope],
170+
tag_values: &admin_stats_build_tag_values/1,
171+
description: "Total admin stats dashboard builds by outcome, window, and scope."
172+
),
173+
distribution("codex_pooler.admin.stats.dashboard.build.duration.seconds",
174+
event_name: [:codex_pooler, :admin, :stats, :dashboard, :build],
175+
measurement: :duration,
176+
unit: {:native, :second},
177+
tags: [:outcome, :window, :scope],
178+
tag_values: &admin_stats_build_tag_values/1,
179+
description: "Admin stats dashboard build duration by outcome, window, and scope.",
180+
reporter_options: [buckets: @admin_stats_duration_buckets]
181+
),
152182
distribution("codex_pooler.repo.query.total_time.seconds",
153183
event_name: [:codex_pooler, :repo, :query],
154184
measurement: :total_time,
@@ -446,6 +476,57 @@ defmodule CodexPoolerWeb.Telemetry do
446476
}
447477
end
448478

479+
@spec admin_stats_reload_tag_values(map()) :: admin_stats_reload_tags()
480+
defp admin_stats_reload_tag_values(metadata) do
481+
%{
482+
stage: admin_stats_reload_stage(metadata[:stage]),
483+
window: admin_stats_window(metadata[:window]),
484+
scope: admin_stats_scope(metadata[:scope])
485+
}
486+
end
487+
488+
@spec admin_stats_build_tag_values(map()) :: admin_stats_build_tags()
489+
defp admin_stats_build_tag_values(metadata) do
490+
%{
491+
outcome: admin_stats_build_outcome(metadata[:outcome]),
492+
window: admin_stats_window(metadata[:window]),
493+
scope: admin_stats_scope(metadata[:scope])
494+
}
495+
end
496+
497+
@spec admin_stats_window(term()) :: String.t()
498+
defp admin_stats_window(value), do: admin_stats_enum_value(value, @admin_stats_windows)
499+
500+
@spec admin_stats_scope(term()) :: String.t()
501+
defp admin_stats_scope(value), do: admin_stats_enum_value(value, @admin_stats_scopes)
502+
503+
@spec admin_stats_reload_stage(term()) :: String.t()
504+
defp admin_stats_reload_stage(value),
505+
do: admin_stats_enum_value(value, @admin_stats_reload_stages)
506+
507+
@spec admin_stats_build_outcome(term()) :: String.t()
508+
defp admin_stats_build_outcome(value),
509+
do: admin_stats_enum_value(value, @admin_stats_build_outcomes)
510+
511+
@spec admin_stats_enum_value(term(), [String.t()]) :: String.t()
512+
defp admin_stats_enum_value(value, allowed_values) when is_atom(value) do
513+
value
514+
|> Atom.to_string()
515+
|> admin_stats_enum_value(allowed_values)
516+
end
517+
518+
defp admin_stats_enum_value(value, allowed_values) when is_binary(value) do
519+
value = String.trim(value)
520+
521+
if value in allowed_values do
522+
value
523+
else
524+
"unknown"
525+
end
526+
end
527+
528+
defp admin_stats_enum_value(_value, _allowed_values), do: "unknown"
529+
449530
@spec conn_value(term(), atom()) :: term()
450531
defp conn_value(%{method: method}, :method), do: method
451532
defp conn_value(%{status: status}, :status), do: status

0 commit comments

Comments
 (0)