@zerotal/monitor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/panel.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The monitor panel's contribution surface — how other packages add what *they*
3
+ * want monitored.
4
+ *
5
+ * The monitor owns the shell, the time-range selector, the storage and the
6
+ * retention policy; a package owns the knowledge of what is worth watching about
7
+ * itself. So the monitor is a host: it publishes the write surface below, binds
8
+ * it into the container as `monitor.panel`, and a package pushes a section into
9
+ * it at boot:
10
+ *
11
+ * // packages/scheduler/src/monitor.ts
12
+ * interface MonitorHost { // declared locally — no monitor dependency
13
+ * enabled(id: string): boolean;
14
+ * section(s: { id: string; label: string; resolve(range: string): unknown }): void;
15
+ * }
16
+ *
17
+ * export function installSchedulerMonitor(app: Application): void {
18
+ * const monitor = app.container.tryMake("monitor.panel") as MonitorHost | undefined;
19
+ * if (!monitor?.enabled("scheduler")) return;
20
+ * monitor.section({ id: "scheduler", label: "Scheduled tasks", resolve });
21
+ * }
22
+ *
23
+ * A section is *described*, not rendered: the contributing package returns stats
24
+ * and tables, and the monitor draws them. That keeps the panel coherent no matter
25
+ * who contributed a section, and means a package needs no JSX and no dependency
26
+ * on this one.
27
+ *
28
+ * This mirrors how the recorders already work. A satellite writes its measurements
29
+ * into `monitor.store` through its own `observability.ts`; this is the other half —
30
+ * saying what those measurements should look like on screen.
31
+ */
32
+ import type { MonitorRange } from "./store/types.ts";
33
+
34
+ /** Semantic tone for a stat or cell — the panel maps it to its own palette. */
35
+ export type MonitorTone = "default" | "good" | "warn" | "bad";
36
+
37
+ /** A row in a contributed table. */
38
+ export type MonitorRow = Record<string, unknown>;
39
+
40
+ /** A headline figure at the top of a section. */
41
+ export interface MonitorStat {
42
+ label: string;
43
+ value: string | number;
44
+ /** Secondary line under the value — a total, a rate, a comparison. */
45
+ detail?: string;
46
+ tone?: MonitorTone;
47
+ /** Draw a 0–100 progress bar under the value. */
48
+ percent?: number;
49
+ }
50
+
51
+ /** One column of a contributed table. */
52
+ export interface MonitorTableColumn {
53
+ key: string;
54
+ label: string;
55
+ align?: "start" | "end";
56
+ /** Render in a monospace face — ids, keys, class names. */
57
+ mono?: boolean;
58
+ /** Turn the raw value into display text. Defaults to `String(value)`. */
59
+ format?: (value: unknown, row: MonitorRow) => string;
60
+ /** Tint the cell. */
61
+ tone?: (value: unknown, row: MonitorRow) => MonitorTone | null;
62
+ }
63
+
64
+ /** A table beneath a section's stats. */
65
+ export interface MonitorTable {
66
+ title: string;
67
+ columns: MonitorTableColumn[];
68
+ rows: MonitorRow[];
69
+ empty?: string;
70
+ }
71
+
72
+ /** What a section shows for the selected range. */
73
+ export interface MonitorSectionData {
74
+ stats?: MonitorStat[];
75
+ tables?: MonitorTable[];
76
+ }
77
+
78
+ /** A section contributed to the monitor's navigation. */
79
+ export interface MonitorSection {
80
+ /** Stable id — the URL segment (`/monitor/<id>`) and the nav key. */
81
+ id: string;
82
+ label: string;
83
+ /** Sidebar group heading. Contributed groups sort after the built-in ones. */
84
+ group?: string;
85
+ /** Raw SVG markup for the nav icon. Falls back to a generic glyph. */
86
+ icon?: string;
87
+ sort?: number;
88
+ /**
89
+ * Resolve the section's content for the selected time range.
90
+ *
91
+ * Called on every render and every auto-refresh, so it should read from
92
+ * whatever the package already records rather than doing expensive work.
93
+ */
94
+ resolve(range: MonitorRange): Promise<MonitorSectionData> | MonitorSectionData;
95
+ }
96
+
97
+ /**
98
+ * The monitor's write surface, bound into the container as `monitor.panel`.
99
+ *
100
+ * Contributors should declare their own minimal copy of the members they use
101
+ * rather than importing this type, so they depend on this package not at all.
102
+ */
103
+ export interface MonitorPanelHost {
104
+ /**
105
+ * Whether the app has left this contributor switched on. Check it first and
106
+ * return early — `sections: { scheduler: false }` in `config/monitor.ts` turns
107
+ * a contributor off without uninstalling its provider.
108
+ */
109
+ enabled(id: string): boolean;
110
+ section(section: MonitorSection): void;
111
+ }
112
+
113
+ /** Registered contributed sections, in registration order. */
114
+ const _sections: MonitorSection[] = [];
115
+
116
+ /** Contributors the app has switched off, from `config/monitor.ts`. */
117
+ let _disabled: Record<string, boolean> = {};
118
+
119
+ /**
120
+ * The monitor's contribution registry.
121
+ *
122
+ * Module-level rather than instance state because the panel is a single
123
+ * process-wide product, matching how the store and config are published.
124
+ */
125
+ export const MonitorPanel = {
126
+ /** Record which contributors the app disabled. Called by the provider on boot. */
127
+ configure(sections: Record<string, boolean> | undefined): void {
128
+ _disabled = sections ?? {};
129
+ },
130
+
131
+ /** The write surface handed to contributors through the container. */
132
+ host(): MonitorPanelHost {
133
+ return {
134
+ enabled: (id) => _disabled[id] !== false,
135
+ section: (section) => {
136
+ if (_sections.some((s) => s.id === section.id)) return;
137
+ _sections.push(section);
138
+ },
139
+ };
140
+ },
141
+
142
+ /** Every contributed section, sorted for display. */
143
+ sections(): MonitorSection[] {
144
+ return [..._sections].sort(
145
+ (a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.label.localeCompare(b.label),
146
+ );
147
+ },
148
+
149
+ /** Resolve a contributed section by id. */
150
+ find(id: string): MonitorSection | undefined {
151
+ return _sections.find((s) => s.id === id);
152
+ },
153
+
154
+ /** Reset the registry (tests). */
155
+ reset(): void {
156
+ _sections.length = 0;
157
+ _disabled = {};
158
+ },
159
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Prometheus text-exposition exporter. Renders a {@link MonitorSnapshot} (plus
3
+ * core `HttpMetrics`) as `text/plain; version=0.0.4` so any Prometheus scraper —
4
+ * and the whole Grafana/Alertmanager ecosystem — can read Zerotal's metrics.
5
+ *
6
+ * HTTP totals are real counters (cumulative since boot, from `HttpMetrics`);
7
+ * everything else is a gauge over the current window.
8
+ */
9
+ import { httpMetrics } from "@zerotal/core/metrics";
10
+ import type { MonitorSnapshot } from "./store/types.ts";
11
+
12
+ function _sanitize(s: string): string {
13
+ return s.toLowerCase().replace(/[^a-z0-9_]/g, "_");
14
+ }
15
+
16
+ function _escapeLabel(s: string): string {
17
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ");
18
+ }
19
+
20
+ export function renderPrometheus(snap: MonitorSnapshot): string {
21
+ const m = httpMetrics();
22
+ const out: string[] = [];
23
+ const metric = (name: string, type: "gauge" | "counter", help: string, value: number): void => {
24
+ out.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`, `${name} ${value}`);
25
+ };
26
+
27
+ // ── HTTP ──────────────────────────────────────────────────────────────────────
28
+ metric("zerotal_http_requests_total", "counter", "HTTP requests since boot", m.total);
29
+ metric("zerotal_http_in_flight", "gauge", "HTTP requests currently being processed", m.inFlight);
30
+ metric("zerotal_http_server_errors_total", "counter", "5xx responses since boot", m.serverErrors);
31
+ metric("zerotal_http_client_errors_total", "counter", "4xx responses since boot", m.clientErrors);
32
+ metric("zerotal_http_request_duration_ms_avg", "gauge", "Mean response time (ms)", m.avgMs);
33
+ metric(
34
+ "zerotal_http_request_duration_ms_p95",
35
+ "gauge",
36
+ "p95 response time over 5m (ms)",
37
+ m.p95Ms,
38
+ );
39
+ metric("zerotal_http_requests_per_minute", "gauge", "Requests per minute over 5m", m.perMinute);
40
+ metric("zerotal_http_success_rate", "gauge", "Success rate (%)", m.successRate);
41
+ metric("zerotal_apdex", "gauge", "Apdex satisfaction score", snap.apdex);
42
+
43
+ // ── Cache ─────────────────────────────────────────────────────────────────────
44
+ metric("zerotal_cache_hit_rate", "gauge", "Cache hit rate (%)", snap.cache.hitRate);
45
+ metric("zerotal_cache_evictions", "gauge", "Cache evictions in window", snap.cache.evictions);
46
+
47
+ // ── Queues ────────────────────────────────────────────────────────────────────
48
+ metric(
49
+ "zerotal_queue_pending",
50
+ "gauge",
51
+ "Pending jobs across queues",
52
+ snap.queues.reduce((a, q) => a + q.pending, 0),
53
+ );
54
+ metric("zerotal_queue_failed", "gauge", "Failed jobs", snap.failedJobs.length);
55
+
56
+ // ── Database ──────────────────────────────────────────────────────────────────
57
+ metric(
58
+ "zerotal_db_transactions_committed",
59
+ "gauge",
60
+ "Committed transactions in window",
61
+ snap.transactions.committed,
62
+ );
63
+ metric(
64
+ "zerotal_db_transactions_rolledback",
65
+ "gauge",
66
+ "Rolled-back transactions in window",
67
+ snap.transactions.rolledBack,
68
+ );
69
+ metric(
70
+ "zerotal_db_nplus_offenders",
71
+ "gauge",
72
+ "Distinct N+1 offenders in window",
73
+ snap.nplusOnes.length,
74
+ );
75
+
76
+ // ── Exceptions ────────────────────────────────────────────────────────────────
77
+ metric(
78
+ "zerotal_exceptions_total",
79
+ "gauge",
80
+ "Exception occurrences in window",
81
+ snap.exceptions.reduce((a, e) => a + e.count, 0),
82
+ );
83
+
84
+ // ── Realtime / WebSocket ──────────────────────────────────────────────────────
85
+ metric(
86
+ "zerotal_ws_connections",
87
+ "gauge",
88
+ "Active WebSocket connections",
89
+ snap.realtime.activeConnections,
90
+ );
91
+ metric(
92
+ "zerotal_ws_actions_per_minute",
93
+ "gauge",
94
+ "WebSocket actions per minute",
95
+ snap.realtime.actionsPerMin,
96
+ );
97
+ metric(
98
+ "zerotal_ws_action_duration_ms_avg",
99
+ "gauge",
100
+ "Average WS action time (ms)",
101
+ snap.realtime.avgActionMs,
102
+ );
103
+
104
+ // ── System gauges (CPU / Memory / Heap) ───────────────────────────────────────
105
+ for (const g of snap.gauges) {
106
+ metric(
107
+ `zerotal_system_${_sanitize(g.label)}_percent`,
108
+ "gauge",
109
+ `${g.label} usage (%)`,
110
+ g.value,
111
+ );
112
+ }
113
+
114
+ // ── Per-route latency (labelled — HELP/TYPE once) ─────────────────────────────
115
+ if (snap.slowRoutes.length) {
116
+ out.push(
117
+ "# HELP zerotal_route_duration_ms_avg Average response time per route (ms)",
118
+ "# TYPE zerotal_route_duration_ms_avg gauge",
119
+ );
120
+ for (const r of snap.slowRoutes) {
121
+ out.push(
122
+ `zerotal_route_duration_ms_avg{method="${_escapeLabel(r.method)}",route="${_escapeLabel(r.path)}"} ${r.ms}`,
123
+ );
124
+ }
125
+ }
126
+
127
+ return out.join("\n") + "\n";
128
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Wires `@zerotal/monitor` into a Zerotal app:
3
+ *
4
+ * 1. Resolves `config/monitor.ts` (with defaults) and binds it as `monitor`.
5
+ * 2. Creates the {@link MonitorStore}, binds it to the IoC container and the
6
+ * live app (so it can read queue/scheduler/health), and publishes it via
7
+ * the process handle used by the recorder and the `Monitor` facade.
8
+ * 3. Installs the event-driven recorder (when `record: true`).
9
+ * 4. Registers the Flow panel route at `config.path` behind the auth guard.
10
+ *
11
+ * The panel is a Flow component, so this provider depends on `FlowProvider`
12
+ * (declared via `dependsOn`) — it is registered and booted automatically, in the
13
+ * right order, even in apps that don't otherwise use Flow:
14
+ *
15
+ * import { MonitorProvider } from "@zerotal/monitor";
16
+ * const providers = [MonitorProvider]; // FlowProvider comes along via dependsOn
17
+ */
18
+ import { ServiceProvider, Router } from "@zerotal/core";
19
+ import { FlowProvider } from "@zerotal/flow";
20
+ import type { AppEnvironment, HttpContext } from "@zerotal/core";
21
+ import type { ConfigManager } from "@zerotal/core/config";
22
+ import type { MonitorRange } from "../store/types.ts";
23
+ import { LogManager, frameworkLog } from "@zerotal/core/logger";
24
+ import { MonitorConfig, type ResolvedMonitorConfig } from "../config.ts";
25
+ import { MonitorStore } from "../MonitorStore.ts";
26
+ import { _setStore } from "../instance.ts";
27
+ import { installMonitorEventBridge } from "../recorder/MonitorEventBridge.ts";
28
+ import { renderPrometheus } from "../prometheus.ts";
29
+ import { evaluateAlerts, alertsToFire, _dispatchAlert, onAlert } from "../alerting.ts";
30
+ import { detectDeploy } from "../sources/system.ts";
31
+ import { MonitorAuthMiddleware } from "../middleware/MonitorAuthMiddleware.ts";
32
+ import { MonitorPanel } from "../panel.ts";
33
+ import type { MonitorPanelHost } from "../panel.ts";
34
+ import { MonitorPayloadMiddleware } from "../middleware/MonitorPayloadMiddleware.ts";
35
+ import { MonitorPage } from "../ui/MonitorPage.tsx";
36
+
37
+ declare module "@zerotal/core" {
38
+ interface ContainerBindings {
39
+ monitor: ResolvedMonitorConfig;
40
+ "monitor.store": MonitorStore;
41
+ /** The panel's contribution surface — see `panel.ts`. */
42
+ "monitor.panel": MonitorPanelHost;
43
+ }
44
+ }
45
+
46
+ export class MonitorProvider extends ServiceProvider {
47
+ static override provides = ["monitor", "monitor.store", "monitor.panel"] as const;
48
+ // The panel mounts via Flow's `Router.flow()` macro, so FlowProvider must
49
+ // register/boot first to install it. Declaring the dependency wires it in
50
+ // automatically (and in order) even in non-Flow apps.
51
+ static override dependsOn = [FlowProvider];
52
+ // The panel + metrics endpoints mount via Flow's `Router.flow()` macro and the
53
+ // HTTP router, so this only runs where Flow/the server do — not in `console`
54
+ // (CLI) boots, where the macro is absent and there's nothing to serve.
55
+ static override environments: AppEnvironment[] = ["web", "test"];
56
+
57
+ private _disposeBridge: (() => void) | undefined = undefined;
58
+ private _disposeLogTap: (() => void) | undefined = undefined;
59
+ private _pruneTimer: ReturnType<typeof setInterval> | undefined = undefined;
60
+ private _alertTimer: ReturnType<typeof setInterval> | undefined = undefined;
61
+ private _disposeAlertHook: (() => void) | undefined = undefined;
62
+ private readonly _firingAlerts = new Set<string>();
63
+ private readonly _lastFiredAt = new Map<string, number>();
64
+
65
+ override onRegister(): void {
66
+ this.app.container.singleton("monitor", () => {
67
+ const config = this.app.container.makeSync("config") as ConfigManager;
68
+ const raw = config.get<ResolvedMonitorConfig>("monitor");
69
+ // Apply defaults even if the app didn't author config/monitor.ts.
70
+ return raw ? MonitorConfig(raw) : MonitorConfig();
71
+ });
72
+
73
+ // Publish the contribution surface during registration, so any provider can
74
+ // add a section from its own `onBooting` regardless of boot order —
75
+ // contributors deliberately do not (and must not) depend on this package.
76
+ //
77
+ // The factory body runs on first resolution, not now: reading config during
78
+ // the registration phase would force it earlier than the rest of the provider
79
+ // needs it, and isolated tests bind no config at all.
80
+ this.app.container.singleton("monitor.panel", () => {
81
+ try {
82
+ const cfg = this.app.container.makeSync("monitor") as ResolvedMonitorConfig;
83
+ MonitorPanel.configure(cfg.sections);
84
+ } catch {
85
+ // No config bound — every contributor stays enabled.
86
+ }
87
+ return MonitorPanel.host();
88
+ });
89
+
90
+ this.app.container.singleton("monitor.store", () => {
91
+ const cfg = this.app.container.makeSync("monitor") as ResolvedMonitorConfig;
92
+ const deploy = cfg.deploy ?? detectDeploy();
93
+ const store = new MonitorStore({
94
+ apdexTargetMs: cfg.apdexTargetMs,
95
+ slowQueryMs: cfg.slowQueryMs,
96
+ slowRequestMs: cfg.slowRequestMs,
97
+ snapshotCacheMs: cfg.snapshotCacheMs,
98
+ storage: cfg.storage,
99
+ retentionDays: cfg.retentionDays,
100
+ retentionMode: cfg.retentionMode,
101
+ ...(cfg.zerotalVersion ? { zerotalVersion: cfg.zerotalVersion } : {}),
102
+ ...(cfg.region ? { region: cfg.region } : {}),
103
+ ...(deploy ? { deploy } : {}),
104
+ });
105
+ return store;
106
+ });
107
+ }
108
+
109
+ override async onBooting(): Promise<void> {
110
+ const cfg = (await this.app.container.make("monitor")) as ResolvedMonitorConfig;
111
+ const store = (await this.app.container.make("monitor.store")) as MonitorStore;
112
+
113
+ // Let the store reach the live subsystems, and publish it process-wide.
114
+ store.bindApp(this.app as unknown as { container: { tryMake(name: string): unknown } });
115
+ _setStore(store);
116
+
117
+ // Expose branding/refresh/path/retention to the page without a container round-trip.
118
+ (globalThis as { __monitorConfig?: unknown }).__monitorConfig = {
119
+ title: cfg.title,
120
+ subtitle: cfg.subtitle,
121
+ refreshMs: cfg.refreshMs,
122
+ path: cfg.path.replace(/\/$/, "") || "/monitor",
123
+ retentionDays: cfg.retentionDays,
124
+ retentionMode: cfg.retentionMode,
125
+ };
126
+
127
+ // Opt-in: capture request/response headers + bodies for the request trace.
128
+ if (cfg.record && cfg.capturePayloads) {
129
+ this.app.use(MonitorPayloadMiddleware);
130
+ }
131
+
132
+ // Install the recorder: one FrameworkEvents bridge feeds every panel
133
+ // (requests + queries + cache + mail + exceptions + outgoing HTTP + jobs).
134
+ if (cfg.record) {
135
+ this._disposeBridge = installMonitorEventBridge(store);
136
+
137
+ // Surface application logs in the panel via a LogManager tap.
138
+ this._disposeLogTap = LogManager.tap((entry) => {
139
+ const status =
140
+ entry.level === "error" || entry.level === "fatal"
141
+ ? "bad"
142
+ : entry.level === "warn"
143
+ ? "warn"
144
+ : "info";
145
+ store.recordEvent({
146
+ kind: "log",
147
+ label: entry.level,
148
+ status,
149
+ route: entry.requestId ?? null,
150
+ data: {
151
+ detail: entry.message,
152
+ ...(entry.error ? { error: entry.error } : {}),
153
+ ...entry.context,
154
+ },
155
+ });
156
+ });
157
+ }
158
+
159
+ // Enforce retention: prune past-window data now and hourly thereafter.
160
+ store.prune();
161
+ this._pruneTimer = setInterval(() => store.prune(), 60 * 60 * 1000);
162
+ (this._pruneTimer as { unref?: () => void }).unref?.();
163
+
164
+ // Evaluate threshold alerts every 15s (edge-triggered, deduped).
165
+ if (cfg.alerts) {
166
+ this._alertTimer = setInterval(() => void this._runAlerts(store, cfg), 15_000);
167
+ (this._alertTimer as { unref?: () => void }).unref?.();
168
+
169
+ // Ship newly-firing alerts to a webhook so they page someone, not just the panel.
170
+ if (cfg.alertWebhook) {
171
+ const webhook = cfg.alertWebhook;
172
+ this._disposeAlertHook = onAlert(async (a) => {
173
+ try {
174
+ await fetch(webhook, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify({
178
+ text: `[${a.level.toUpperCase()}] ${a.title} — ${a.detail}`,
179
+ level: a.level,
180
+ title: a.title,
181
+ detail: a.detail,
182
+ }),
183
+ });
184
+ } catch {
185
+ /* delivery is best-effort — never throw back into the alert loop */
186
+ }
187
+ });
188
+ }
189
+ }
190
+
191
+ // Mount the panel. The base path renders the Overview; `/:section` deep-links
192
+ // each page (e.g. /monitor/requests, /monitor/exceptions) to the same shell.
193
+ const path = cfg.path.replace(/\/$/, "") || "/monitor";
194
+ Router.flow(path, MonitorPage, [MonitorAuthMiddleware]);
195
+ Router.flow(`${path}/:section`, MonitorPage, [MonitorAuthMiddleware]);
196
+
197
+ // Snapshot export — the full derived snapshot as a JSON download, behind the
198
+ // same auth gate as the panel. `?range=live|1h|24h|7d` selects the window.
199
+ Router.get(
200
+ `${path}/export.json`,
201
+ async (http: HttpContext) => {
202
+ const q = http.query("range", "live") ?? "live";
203
+ const range: MonitorRange = (["live", "1h", "24h", "7d"] as const).includes(
204
+ q as MonitorRange,
205
+ )
206
+ ? (q as MonitorRange)
207
+ : "live";
208
+ const snap = await store.snapshot(range);
209
+ return new Response(JSON.stringify(snap, null, 2), {
210
+ headers: {
211
+ "Content-Type": "application/json; charset=utf-8",
212
+ "Content-Disposition": `attachment; filename="zerotal-monitor-${range}.json"`,
213
+ },
214
+ });
215
+ },
216
+ [MonitorAuthMiddleware],
217
+ );
218
+
219
+ // Prometheus text-exposition endpoint (protect at the network layer).
220
+ if (cfg.metrics) {
221
+ Router.get(cfg.metricsPath, async () => {
222
+ const snap = await store.snapshot("live");
223
+ return new Response(renderPrometheus(snap), {
224
+ headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" },
225
+ });
226
+ });
227
+ }
228
+ }
229
+
230
+ /** Evaluate alerts against a live snapshot; fire newly-breaching ones, reset resolved. */
231
+ private async _runAlerts(store: MonitorStore, cfg: ResolvedMonitorConfig): Promise<void> {
232
+ try {
233
+ const snap = await store.snapshot("live");
234
+ const active = evaluateAlerts(snap, cfg.alertThresholds);
235
+ const activeIds = new Set(active.map((a) => a.id));
236
+
237
+ // Edge-triggered + cooldown: fire each newly-breaching alert once, and don't
238
+ // re-fire within the cooldown even if it recovers and re-breaches.
239
+ const toFire = alertsToFire(
240
+ active,
241
+ this._firingAlerts,
242
+ this._lastFiredAt,
243
+ Date.now(),
244
+ cfg.alertCooldownMs,
245
+ );
246
+ for (const a of toFire) {
247
+ frameworkLog("monitor").warn(`ALERT [${a.level}] ${a.title} — ${a.detail}`, {
248
+ level: a.level,
249
+ });
250
+ store.recordEvent({
251
+ kind: "alert",
252
+ label: a.title,
253
+ status: a.level === "critical" ? "bad" : "warn",
254
+ route: a.id,
255
+ // Capture the metric + a snapshot of the surrounding state so the panel can
256
+ // explain *why* it fired without the operator hunting across tabs.
257
+ data: {
258
+ id: a.id,
259
+ level: a.level,
260
+ detail: a.detail,
261
+ metric: a.metric,
262
+ value: a.value,
263
+ threshold: a.threshold,
264
+ unit: a.unit,
265
+ context: this._alertContext(snap),
266
+ },
267
+ });
268
+ _dispatchAlert(a);
269
+ }
270
+ // Reset alerts that have recovered so they can fire again next time.
271
+ for (const id of [...this._firingAlerts]) {
272
+ if (!activeIds.has(id)) this._firingAlerts.delete(id);
273
+ }
274
+ } catch {
275
+ /* alerting must never break the app */
276
+ }
277
+ }
278
+
279
+ /** A compact snapshot of the surrounding state, recorded with each alert firing. */
280
+ private _alertContext(
281
+ snap: import("../store/types.ts").MonitorSnapshot,
282
+ ): Record<string, unknown> {
283
+ const errCard = snap.statCards.find((c) => c.label === "Error rate");
284
+ const rpmCard = snap.statCards.find((c) => c.label === "Requests / min");
285
+ const pct = (label: string): number =>
286
+ snap.percentiles.find((p) => p.label === label)?.value ?? 0;
287
+ return {
288
+ errorRate: errCard ? parseFloat(errCard.value) || 0 : 0,
289
+ rpm: rpmCard ? parseFloat(rpmCard.value.replace(/,/g, "")) || 0 : 0,
290
+ p50: pct("p50"),
291
+ p95: pct("p95"),
292
+ p99: pct("p99"),
293
+ apdex: snap.apdex,
294
+ pending: snap.queues.reduce((acc, q) => acc + q.pending, 0),
295
+ rolledBack: snap.transactions.rolledBack,
296
+ slowRoutes: snap.slowRoutes
297
+ .slice(0, 3)
298
+ .map((r) => ({ method: r.method, path: r.path, ms: r.ms })),
299
+ topException: snap.exceptions[0]?.type ?? null,
300
+ };
301
+ }
302
+
303
+ override async onStopping(): Promise<void> {
304
+ if (this._pruneTimer) clearInterval(this._pruneTimer);
305
+ this._pruneTimer = undefined;
306
+ if (this._alertTimer) clearInterval(this._alertTimer);
307
+ this._alertTimer = undefined;
308
+ this._disposeAlertHook?.();
309
+ this._disposeAlertHook = undefined;
310
+ this._disposeBridge?.();
311
+ this._disposeBridge = undefined;
312
+ this._disposeLogTap?.();
313
+ this._disposeLogTap = undefined;
314
+ const store = this.app.container.tryMake("monitor.store") as MonitorStore | null;
315
+ store?.dispose();
316
+ _setStore(undefined);
317
+ }
318
+ }