Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pm-stack/aggregator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,13 @@ alerts-intake-token`, resolved by name at runtime
`notification_sends` row per attempt. An unacked critical older
than `ALERT_ACK_WINDOW_MIN` (default 15) escalates with exactly
one more push (`escalated_at`). Warnings never push in v1.
- **Measurement**: `/notifications` (#3319) is the read-only weekly
sent-vs-acted-on roll-up over `notification_sends`: per class and
rolling week, only `outcome = sent` rows count as sends, and a send
counts as acted-on when its `acted_on_at` (stamped by Ack) falls
within 24 hours. A class under 10% acted-on over the rolling
four-week window renders as a default-off *proposal* — display
only; the page never changes a preference or a default.
- **Ack surface**: `/alerts` lists open incidents with Ack/Close.
Ack is exactly-once: only the open→acked transition writes an
event (and stamps the sends' `acted_on_at`); a repeat ack returns
Expand Down
22 changes: 22 additions & 0 deletions pm-stack/aggregator/src/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ export interface PushSubscriptionRow {
gone_count: number;
}

/** One alerts.notification_sends ledger row (#3319): one per send
* attempt, written by logSend; acted_on_at is stamped by the
* exactly-once Ack transition. The insert names no timestamp column,
* so the send time is the row's defaulted created_at; sent_at is
* read first when a row carries one. */
export interface NotificationSendRow {
class: string;
object_ref: string;
incident_id: string | null;
outcome: string;
sent_at?: string | null;
created_at?: string | null;
acted_on_at: string | null;
}

export interface QuietPrefs {
start: string;
end: string;
Expand Down Expand Up @@ -528,6 +543,13 @@ export class AlertsStore {
}, ${sqlStr(entry.outcome)})`,
);
}

/** Every send-ledger row (#3319 roll-up input). The ledger is one
* row per attempt, so a full read stays small; the rolling window
* is applied by the pure roll-up in src/notifications.ts. */
async listSends(): Promise<NotificationSendRow[]> {
return this.db.query<NotificationSendRow>(`select * from alerts.notification_sends`);
}
}

/** The test alert (#3676 follow-up, Drew 2026-11-01): the /alerts
Expand Down
130 changes: 130 additions & 0 deletions pm-stack/aggregator/src/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Notification measurement (#3319, slice (e) of the #1254 design):
// the weekly sent-vs-acted-on roll-up over the alert throat's send
// ledger (alerts.notification_sends, written by AlertsStore.logSend).
//
// The roll-up is a pure derivation over ledger rows so it can be
// pinned by fixture tests without a database; the /notifications page
// and its /api twin render exactly this shape.
//
// Counting rules (the page states them in plain words too):
// - Only outcome = "sent" rows are sends. suppressed-quiet,
// suppressed-dedupe, gone, and error rows are attempts that never
// reached a device; they never enter the denominator, and a send
// is never inferred from delivered/escalated incident events.
// - A send counts as acted-on when its acted_on_at (stamped by the
// exactly-once Ack transition in AlertsStore.ackIncident) falls
// within 24 hours of the send. Ack is the durable v1 acted-on
// signal; acting through any other surface is not attributable
// to a specific send (see the page's attribution note).
// - Weeks are rolling 7-day buckets ending at `now`, four of them:
// the window is the last 28 days, not calendar weeks.
// - A class whose rolling-window acted-on ratio is under 10% (with
// at least one send) is flagged as a default-off PROPOSAL. The
// flag is display-only: nothing here mutates a preference or a
// default; default changes are Drew's call (design Q1).
import type { NotificationSendRow } from "./alerts.ts";

export const ACTED_WINDOW_MS = 24 * 60 * 60 * 1000;
export const ROLLING_WEEKS = 4;
export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export const PROPOSAL_THRESHOLD = 0.1;

/** The send's timestamp: the ledger's insert-time column. Rows the
* store writes carry no explicit timestamp, so the row's defaulted
* created_at is the send time; sent_at wins when a row carries one. */
export function sendTimestamp(row: NotificationSendRow): string | null {
return row.sent_at ?? row.created_at ?? null;
}

export interface NotificationWeekRollup {
/** Inclusive bucket start (ISO); the bucket is [start, start + 7d). */
start: string;
sent: number;
acted: number;
/** acted / sent, or null when nothing was sent that week. */
ratio: number | null;
}

export interface NotificationClassRollup {
class: string;
/** Oldest week first; always ROLLING_WEEKS entries. */
weeks: NotificationWeekRollup[];
sent: number;
acted: number;
/** acted / sent over the whole rolling window; null when sent = 0. */
ratio: number | null;
/** Display-only default-off proposal (ratio under the threshold). */
proposal: boolean;
}

export interface NotificationRollup {
/** The instant the rolling window ends (ISO). */
asOf: string;
/** Inclusive window start (ISO): asOf - ROLLING_WEEKS weeks. */
windowStart: string;
classes: NotificationClassRollup[];
/** Ledger rows skipped because they carry no usable send timestamp. */
undated: number;
}

export function rollupNotificationSends(
rows: NotificationSendRow[],
now: Date,
weeks: number = ROLLING_WEEKS,
): NotificationRollup {
const asOfMs = now.getTime();
const windowStartMs = asOfMs - weeks * WEEK_MS;
const byClass = new Map<string, { sent: number[]; acted: number[] }>();
let undated = 0;
for (const row of rows) {
if (row.outcome !== "sent") continue;
const tsRaw = sendTimestamp(row);
const ts = tsRaw ? Date.parse(tsRaw) : Number.NaN;
if (!Number.isFinite(ts)) {
undated += 1;
continue;
}
if (ts < windowStartMs || ts > asOfMs) continue;
// Bucket 0 is the oldest week in the window.
const bucket = Math.min(weeks - 1, Math.floor((ts - windowStartMs) / WEEK_MS));
let entry = byClass.get(row.class);
if (!entry) {
entry = { sent: new Array<number>(weeks).fill(0), acted: new Array<number>(weeks).fill(0) };
byClass.set(row.class, entry);
}
entry.sent[bucket] += 1;
if (row.acted_on_at) {
const actedMs = Date.parse(row.acted_on_at);
if (Number.isFinite(actedMs) && actedMs >= ts && actedMs - ts <= ACTED_WINDOW_MS) {
entry.acted[bucket] += 1;
}
}
}
const classes: NotificationClassRollup[] = [...byClass.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([cls, entry]) => {
const weekRollups: NotificationWeekRollup[] = entry.sent.map((sent, i) => ({
start: new Date(windowStartMs + i * WEEK_MS).toISOString(),
sent,
acted: entry.acted[i],
ratio: sent > 0 ? entry.acted[i] / sent : null,
}));
const sent = entry.sent.reduce((a, b) => a + b, 0);
const acted = entry.acted.reduce((a, b) => a + b, 0);
const ratio = sent > 0 ? acted / sent : null;
return {
class: cls,
weeks: weekRollups,
sent,
acted,
ratio,
proposal: ratio !== null && ratio < PROPOSAL_THRESHOLD,
};
});
return {
asOf: now.toISOString(),
windowStart: new Date(windowStartMs).toISOString(),
classes,
undated,
};
}
10 changes: 10 additions & 0 deletions pm-stack/aggregator/src/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ export const PAGES: PageDef[] = [
// post-#1497 dropdown placement recorded in curation-1497.test).
nav: "primary",
},
{
id: "notifications",
title: "Notifications",
route: "/notifications",
api: "/api/notifications.json",
blurb:
"Notification measurement (#3319): the weekly sent-vs-acted-on roll-up over the alert throat's send ledger, with display-only default-off proposals under 10%.",
group: "ops",
nav: "dropdown",
},
{
id: "pages",
title: "All pages",
Expand Down
57 changes: 57 additions & 0 deletions pm-stack/aggregator/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
import { summarizeDns } from "./dns.ts";
import type { NeedsDrewCensus } from "./needs-drew.ts";
import type { Incident, IncidentEvent, PushSubscriptionRow } from "./alerts.ts";
import type { NotificationRollup } from "./notifications.ts";
import { ageMsText, feedAgeText } from "./feed.ts";
import type { QueueView, QueueRow } from "./queue-view.ts";
import type { HealthView, PageHealth } from "./health.ts";
Expand Down Expand Up @@ -7053,3 +7054,59 @@ document.getElementById("push-test").addEventListener("click", async () => {
</script>`;
return layout("Alerts", body, provenance, "/alerts", script);
}

// --- Notifications page (#3319, notification measurement) -------------------

export interface NotificationsPageView {
available: boolean;
rollup: NotificationRollup | null;
}

function pct(ratio: number | null): string {
return ratio === null ? "—" : `${Math.round(ratio * 100)}%`;
}

export function renderNotifications(view: NotificationsPageView, provenance: Provenance): string {
if (!view.available || !view.rollup) {
return layout(
"Notifications",
`<h1>Notifications</h1><p class="muted">The notification send ledger (Supabase <code>alerts.notification_sends</code>) is unreachable from this process right now — the roll-up is paused with it. The rest of the dashboard is unaffected.</p>`,
provenance,
"/notifications",
);
}
const rollup = view.rollup;
const proposals = rollup.classes.filter((c) => c.proposal);
const rows = rollup.classes
.map((c) => {
const cells = c.weeks
.map((w) => `<td>${w.sent} sent · ${w.acted} acted (${pct(w.ratio)})</td>`)
.join("");
return `<tr><td><strong>${esc(c.class)}</strong></td>${cells}<td>${c.sent} sent · ${c.acted} acted (${pct(c.ratio)})</td></tr>`;
})
.join("");
const weekHeads = rollup.classes[0]
? rollup.classes[0].weeks.map((w) => `<th scope="col">week of ${esc(fmtDate(w.start))}</th>`).join("")
: "";
const proposalHtml = proposals.length
? `<h2>Default-off proposals</h2>${proposals
.map(
(c) =>
`<div class="card"><strong>Proposal: default class <code>${esc(c.class)}</code> off</strong> — acted-on ratio ${pct(c.ratio)} over the rolling window (${c.acted} of ${c.sent} sends acted on within 24h), under the 10% threshold.<br><span class="muted">Proposal only — this page never changes a preference or a default. Turning a class off by default is Drew's call, made on the Alerts page's subscription preferences.</span></div>`,
)
.join("")}`
: `<h2>Default-off proposals</h2><p class="muted">No class is under the 10% acted-on threshold over the rolling window.</p>`;
const body = `<h1>Notifications</h1>
<p class="muted">Weekly sent-vs-acted-on roll-up over the alert throat's send ledger (<code>alerts.notification_sends</code>), rolling four weeks ending ${esc(fmtDate(rollup.asOf))}. Read-only: this page measures; it never changes a notification preference or default. Machine-first: <a href="/api/notifications.json">/api/notifications.json</a>.</p>
<h2>How acted-on is counted (attribution)</h2>
<p class="muted">A send counts as acted-on when the incident's Ack lands within 24 hours of the send: Ack is the exactly-once transition that stamps the send's <code>acted_on_at</code>, so it is the durable acted-on signal. Only ledger rows with outcome <code>sent</code> count as sends — suppressed (quiet hours / dedupe), gone, and error attempts never reached a device and never enter the denominator, and no send is inferred from delivered/escalated incident events. What this cannot attribute: reading a notification without acking, acting on the incident through another surface (GitHub, chat, a later dashboard visit that does not ack), and sends to subscriptions that were later removed — those count as sent-but-not-acted even when a human did see them. Test sends (class <code>test</code>) are real ledger rows and are reported as their own class.</p>
${rollup.undated ? `<p class="muted">${rollup.undated} ledger row(s) carried no usable send timestamp and were excluded from the roll-up.</p>` : ""}
<h2>Per class, per week</h2>
${
rollup.classes.length
? `<div class="tblwrap"><table><caption>Sent vs acted-on by class and rolling week</caption><tr><th scope="col">class</th>${weekHeads}<th scope="col">rolling 4 weeks</th></tr>${rows}</table></div>`
: `<p class="muted">No sends in the rolling four-week window.</p>`
}
${proposalHtml}`;
return layout("Notifications", body, provenance, "/notifications");
}
24 changes: 23 additions & 1 deletion pm-stack/aggregator/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ import {
import { recordSwProbe, swProbeSummary, VAPID_PUBLIC_KEY } from "./push.ts";
import { resolveBwsSecret } from "./secrets.ts";
import { SW_SOURCE } from "./sw-source.ts";
import { renderAlerts, esc } from "./render.ts";
import { renderAlerts, renderNotifications, esc } from "./render.ts";
import { rollupNotificationSends } from "./notifications.ts";
import { readJsonBody } from "./request-body.ts";
import { parseRegistryQuery, queryRegistry } from "./registry-view.ts";
import { searchWork } from "./search-view.ts";
Expand Down Expand Up @@ -1784,6 +1785,27 @@ async function handler(req: Request): Promise<Response> {
: json({ error: "alerts store unavailable" }, 503);
}
}
if (path === "/notifications" || path === "/api/notifications.json") {
// #3319: the weekly sent-vs-acted roll-up over the send ledger.
// Same reachability as every dashboard page (the tailnet origin);
// the only alerts write surface stays the bearer-gated intake.
const store = await getAlertsStore();
if (!store) {
return path === "/notifications"
? html(renderNotifications({ available: false, rollup: null }, prov()))
: json({ error: "alerts store unavailable" }, 503);
}
try {
const sends = await store.listSends();
const rollup = rollupNotificationSends(sends, new Date());
if (path === "/api/notifications.json") return json(rollup);
return html(renderNotifications({ available: true, rollup }, prov()));
} catch {
return path === "/notifications"
? html(renderNotifications({ available: false, rollup: null }, prov()))
: json({ error: "alerts store unavailable" }, 503);
}
}
if (path === "/api/health") {
// #1267: prime first — health's answer must not depend on
// request order. `sections` carries the structured diagnosis
Expand Down
4 changes: 4 additions & 0 deletions pm-stack/aggregator/test/curation-1497.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const DECISIONS: Record<string, "primary" | "dropdown"> = {
// Post-#1497 addition (#1282, 2026-11-03): the source-conflict inbox
// enters at dropdown tier — a triage queue, not a primary surface.
conflicts: "dropdown",
// Post-#1497 addition (#3319, 2026-11-03): the notifications
// measurement page enters at dropdown tier — no curation ruling
// promotes it, and new pages do not take primary slots without one.
notifications: "dropdown",
};

describe("the #1497 decision table is applied to the manifest", () => {
Expand Down
Loading