@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.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/apps/storefront/astro.config.mjs +17 -1
- package/apps/storefront/integrations/materialize-guard.mjs +56 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/layouts/Layout.astro +48 -3
- package/apps/storefront/src/lib/activity/alerts.ts +426 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
- package/apps/storefront/src/lib/activity/store.ts +120 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +4 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
- package/apps/storefront/src/lib/apps/registryService.ts +6 -6
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +313 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +5 -0
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/d1/catalog.ts +2 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/edgeCache.ts +17 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/the-build/provider.ts +27 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +130 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +52 -1206
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +96 -16
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +74 -12
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1080 -0
- package/apps/storefront/tsconfig.json +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +553 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +125 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant.ts +36 -8
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +2 -4
- package/scripts/dev/file-writer.mjs +1 -1
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +6 -9
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operational-activity ALERT conditions — the detection layer on top of
|
|
3
|
+
* the activity query surface.
|
|
4
|
+
*
|
|
5
|
+
* The query surface makes operational activity queryable; this turns four specific,
|
|
6
|
+
* high-value conditions in that stream into ALERTS. It follows the same shape as
|
|
7
|
+
* this repo's other alert contract, `analytics/rumAlert.ts`: a set of
|
|
8
|
+
* PURE, transport-agnostic detectors that take a window of activity and return a
|
|
9
|
+
* structured `ActivityAlert[]`. WHERE the alerts go (a log line, a
|
|
10
|
+
* PagerDuty/Slack sink, a polled endpoint) is the sink's job — there is NO outbound
|
|
11
|
+
* notification transport in this repo (rumAlert is likewise detection-only),
|
|
12
|
+
* so this layer does not invent one. It also does NOT add a cron: the checker is on-demand
|
|
13
|
+
* (computed when `/admin/ops-timeline` loads, or when the read-only alerts endpoint
|
|
14
|
+
* is polled).
|
|
15
|
+
*
|
|
16
|
+
* THE FOUR CONDITIONS (and how each avoids false-positive noise):
|
|
17
|
+
*
|
|
18
|
+
* 1. silent-terminal — a `reconcile.terminal` with status `failed` (errorClass
|
|
19
|
+
* `reconcile_fault` = an unexpected crash → critical, or `reconcile_blocked` =
|
|
20
|
+
* a safety gate refused the candidate → warning). A single failure is worth an
|
|
21
|
+
* alert (a reviewer's PR silently didn't materialize), so there is no count
|
|
22
|
+
* threshold; noise is bounded by (a) status = `failed` only, never `succeeded`,
|
|
23
|
+
* and (b) collapsing repeat failures for one changeId into one alert.
|
|
24
|
+
*
|
|
25
|
+
* 2. recurring-preview-miss — a REPEATED `preview.serve`/`failed`/`miss_no_env`
|
|
26
|
+
* for the SAME changeId. One miss is an expected race (a reviewer opened the
|
|
27
|
+
* preview before its ReviewEnvironment finished materializing), so a single
|
|
28
|
+
* miss NEVER alerts; only `threshold`+ misses (default 3) for one changeId
|
|
29
|
+
* inside a short `window` (default 15m) do — that pattern means a LIVE PR whose
|
|
30
|
+
* preview never came up, not a transient.
|
|
31
|
+
*
|
|
32
|
+
* 3. reconcile-hang — a `webhook.reconcile.received` for a changeId with NO
|
|
33
|
+
* `reconcile.terminal` (any status) at-or-after it, once a `grace` period
|
|
34
|
+
* (default 10m) has elapsed. This is an ABSENCE detector (architecturally
|
|
35
|
+
* unlike 1/2, which fire on a bad event's PRESENCE): it correlates two action
|
|
36
|
+
* types by changeId + time. Noise is bounded by the grace window — a reconcile
|
|
37
|
+
* still legitimately in flight (received < grace ago) never fires; a terminal
|
|
38
|
+
* after the received (any status) clears it.
|
|
39
|
+
*
|
|
40
|
+
* 4. deploy-skew — promotes the visibility-only `deriveDeploySkew()` into an
|
|
41
|
+
* alert. `skewDetected` is true on EVERY normal rollout (two isolates coexist
|
|
42
|
+
* briefly), so alerting on it directly would page on every deploy. Instead this
|
|
43
|
+
* fires ONLY when the build serving this request differs from the newest
|
|
44
|
+
* observed build AND that newest build has been latest for longer than a `grace`
|
|
45
|
+
* period (default 15m) — i.e. THIS isolate failed to roll to a deploy that
|
|
46
|
+
* finished long ago (a persistent stale isolate), not a rollout in progress.
|
|
47
|
+
*
|
|
48
|
+
* DATA DEPENDENCY. The detectors read the D1 `activity_events` timeline through the
|
|
49
|
+
* `queryActivity` seam, so they alert on whatever is in that timeline. Getting
|
|
50
|
+
* server-emitted lifecycle events into the timeline is the fan-out wiring's job, not
|
|
51
|
+
* this layer's. `preview.serve` stays `analytics`-sink by design (too
|
|
52
|
+
* high-volume to timeline wholesale); detector 2 is source-agnostic (the rumAlert
|
|
53
|
+
* precedent) and fires against an Analytics-Engine-sourced or seeded feed. See
|
|
54
|
+
* `ALERT_CONDITIONS` for the per-condition source + wiring note.
|
|
55
|
+
*/
|
|
56
|
+
import {
|
|
57
|
+
queryActivity,
|
|
58
|
+
deriveDeploySkew,
|
|
59
|
+
type ActivityTimelineRow,
|
|
60
|
+
type DeploySkewSummary,
|
|
61
|
+
} from "./query.js";
|
|
62
|
+
import type { Queryable } from "../d1/catalog.js";
|
|
63
|
+
|
|
64
|
+
/** Severity, mirroring `rumAlert.AlertSeverity` / `manifest.Severity`. */
|
|
65
|
+
export type AlertSeverity = "page" | "critical" | "warning" | "info";
|
|
66
|
+
|
|
67
|
+
/** The four conditions. Stable ids — a downstream sink/dashboard keys on them. */
|
|
68
|
+
export type ActivityAlertType =
|
|
69
|
+
| "silent-terminal"
|
|
70
|
+
| "recurring-preview-miss"
|
|
71
|
+
| "reconcile-hang"
|
|
72
|
+
| "deploy-skew";
|
|
73
|
+
|
|
74
|
+
/** One active alert. `details` is low-cardinality + already-redacted-safe (it is
|
|
75
|
+
* built only from CORE fields — action/status/errorClass/changeId/at/version —
|
|
76
|
+
* never from the payload zone), so it is safe to log or serialize verbatim. */
|
|
77
|
+
export interface ActivityAlert {
|
|
78
|
+
type: ActivityAlertType;
|
|
79
|
+
severity: AlertSeverity;
|
|
80
|
+
/** Stable human title for the condition. */
|
|
81
|
+
title: string;
|
|
82
|
+
/** One-line human summary for a banner / log line / notification body. */
|
|
83
|
+
summary: string;
|
|
84
|
+
/** The change this alert concerns, when it is change-scoped. */
|
|
85
|
+
changeId?: string;
|
|
86
|
+
/** Structured, low-cardinality specifics (safe to serialize). */
|
|
87
|
+
details: Record<string, string | number>;
|
|
88
|
+
/** ISO-8601 instant the condition has been active SINCE (earliest evidence). */
|
|
89
|
+
since: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Thresholds (the launch defaults; every one is overridable per call) ──────
|
|
93
|
+
export const DEFAULTS = {
|
|
94
|
+
/** How far back the on-demand checker scans the timeline. */
|
|
95
|
+
lookbackMs: 24 * 60 * 60 * 1000,
|
|
96
|
+
/** recurring-preview-miss: misses of the SAME changeId before it alerts. */
|
|
97
|
+
previewMissThreshold: 3,
|
|
98
|
+
/** recurring-preview-miss: the window those misses must fall inside. */
|
|
99
|
+
previewMissWindowMs: 15 * 60 * 1000,
|
|
100
|
+
/** reconcile-hang: how long a `received` may sit un-terminated before alerting. */
|
|
101
|
+
reconcileHangGraceMs: 10 * 60 * 1000,
|
|
102
|
+
/** deploy-skew: how long the newest build must have been latest before a
|
|
103
|
+
* still-stale serving isolate is a persistent (not rollout-transient) skew. */
|
|
104
|
+
deploySkewGraceMs: 15 * 60 * 1000,
|
|
105
|
+
} as const;
|
|
106
|
+
|
|
107
|
+
const SEVERITY_RANK: Record<AlertSeverity, number> = { page: 0, critical: 1, warning: 2, info: 3 };
|
|
108
|
+
|
|
109
|
+
/** Deterministic ordering: most-severe first, then most-recently-active first. */
|
|
110
|
+
export function sortAlerts(alerts: ActivityAlert[]): ActivityAlert[] {
|
|
111
|
+
return [...alerts].sort(
|
|
112
|
+
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.since < b.since ? 1 : a.since > b.since ? -1 : 0),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Detector 1: silent-terminal ─────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Alert on `reconcile.terminal`/`failed` rows. Pass ONLY failed terminals (the
|
|
120
|
+
* orchestrator queries `status: "failed"`). Repeat failures for one changeId
|
|
121
|
+
* collapse into a single alert carrying the count, so a flapping PR does not spam.
|
|
122
|
+
*/
|
|
123
|
+
export function detectSilentTerminals(failedTerminals: readonly ActivityTimelineRow[]): ActivityAlert[] {
|
|
124
|
+
const byChange = new Map<string, ActivityTimelineRow[]>();
|
|
125
|
+
for (const r of failedTerminals) {
|
|
126
|
+
if (r.status !== "failed") continue; // defensive: only failures
|
|
127
|
+
const key = r.changeId ?? r.id;
|
|
128
|
+
(byChange.get(key) ?? byChange.set(key, []).get(key)!).push(r);
|
|
129
|
+
}
|
|
130
|
+
const out: ActivityAlert[] = [];
|
|
131
|
+
for (const [key, group] of byChange) {
|
|
132
|
+
const sorted = [...group].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
133
|
+
const latest = sorted[sorted.length - 1]!;
|
|
134
|
+
const errorClass = latest.errorClass ?? "unknown";
|
|
135
|
+
// reconcile_fault = an UNEXPECTED crash of the reconcile → critical; a
|
|
136
|
+
// reconcile_blocked = a safety gate correctly refused the candidate, an
|
|
137
|
+
// operator should look but nothing crashed → warning.
|
|
138
|
+
const severity: AlertSeverity = errorClass === "reconcile_fault" ? "critical" : "warning";
|
|
139
|
+
out.push({
|
|
140
|
+
type: "silent-terminal",
|
|
141
|
+
severity,
|
|
142
|
+
title: "Reconcile terminated in failure",
|
|
143
|
+
summary:
|
|
144
|
+
`Candidate ${latest.changeId ?? key} reconcile ${errorClass}` +
|
|
145
|
+
(group.length > 1 ? ` (${group.length}× in window)` : "") +
|
|
146
|
+
` — its preview did not materialize.`,
|
|
147
|
+
...(latest.changeId ? { changeId: latest.changeId } : {}),
|
|
148
|
+
details: { errorClass, failures: group.length, lastAt: latest.at },
|
|
149
|
+
since: sorted[0]!.at,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Detector 2: recurring preview.serve miss_no_env ─────────────────────────
|
|
156
|
+
|
|
157
|
+
export interface PreviewMissOptions {
|
|
158
|
+
/** "now" as epoch ms (injected for testing). */
|
|
159
|
+
now: number;
|
|
160
|
+
threshold?: number;
|
|
161
|
+
windowMs?: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Alert when the SAME changeId has `threshold`+ `preview.serve`/`failed`/
|
|
166
|
+
* `miss_no_env` events inside `windowMs`. Pass `preview.serve`/`failed` rows (the
|
|
167
|
+
* orchestrator queries them); this filters to `miss_no_env` and groups by changeId.
|
|
168
|
+
* A single miss (an expected pre-materialization race) is below threshold → silent.
|
|
169
|
+
*/
|
|
170
|
+
export function detectRecurringPreviewMisses(
|
|
171
|
+
serveFailures: readonly ActivityTimelineRow[],
|
|
172
|
+
opts: PreviewMissOptions,
|
|
173
|
+
): ActivityAlert[] {
|
|
174
|
+
const threshold = opts.threshold ?? DEFAULTS.previewMissThreshold;
|
|
175
|
+
const windowMs = opts.windowMs ?? DEFAULTS.previewMissWindowMs;
|
|
176
|
+
const floor = opts.now - windowMs;
|
|
177
|
+
|
|
178
|
+
const byChange = new Map<string, ActivityTimelineRow[]>();
|
|
179
|
+
for (const r of serveFailures) {
|
|
180
|
+
if (r.errorClass !== "miss_no_env" || !r.changeId) continue;
|
|
181
|
+
const t = Date.parse(r.at);
|
|
182
|
+
if (Number.isNaN(t) || t < floor || t > opts.now) continue; // only misses inside the window
|
|
183
|
+
(byChange.get(r.changeId) ?? byChange.set(r.changeId, []).get(r.changeId)!).push(r);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const out: ActivityAlert[] = [];
|
|
187
|
+
for (const [changeId, misses] of byChange) {
|
|
188
|
+
if (misses.length < threshold) continue; // below threshold ⇒ transient, not an alert
|
|
189
|
+
const sorted = [...misses].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
190
|
+
out.push({
|
|
191
|
+
type: "recurring-preview-miss",
|
|
192
|
+
severity: "warning",
|
|
193
|
+
title: "Preview repeatedly missing for a live change",
|
|
194
|
+
summary:
|
|
195
|
+
`Change ${changeId} has ${misses.length} preview.serve misses (miss_no_env) in ` +
|
|
196
|
+
`${Math.round(windowMs / 60000)}m — reviewers are hitting a preview that never came up.`,
|
|
197
|
+
changeId,
|
|
198
|
+
details: { misses: misses.length, windowMinutes: Math.round(windowMs / 60000), lastAt: sorted[sorted.length - 1]!.at },
|
|
199
|
+
since: sorted[0]!.at,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Detector 3: reconcile-hang watchdog (ABSENCE correlation) ───────────────
|
|
206
|
+
|
|
207
|
+
export interface ReconcileHangOptions {
|
|
208
|
+
/** "now" as epoch ms (injected for testing). */
|
|
209
|
+
now: number;
|
|
210
|
+
graceMs?: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Alert on a `webhook.reconcile.received` for a changeId that has NO
|
|
215
|
+
* `reconcile.terminal` at-or-after it, once `graceMs` has elapsed. Correlates the
|
|
216
|
+
* two action streams by changeId + time and reports the ABSENCE of a terminal.
|
|
217
|
+
*
|
|
218
|
+
* Per changeId we take the LATEST received (a PR pushed N times → only the newest
|
|
219
|
+
* cycle matters) and look for ANY terminal (succeeded/failed/blocked) with
|
|
220
|
+
* `at >= received.at`. None + past-grace ⇒ hang. A terminal after the received, OR
|
|
221
|
+
* a received still inside the grace window, clears it (the true-negative cases).
|
|
222
|
+
*/
|
|
223
|
+
export function detectReconcileHangs(
|
|
224
|
+
received: readonly ActivityTimelineRow[],
|
|
225
|
+
terminals: readonly ActivityTimelineRow[],
|
|
226
|
+
opts: ReconcileHangOptions,
|
|
227
|
+
): ActivityAlert[] {
|
|
228
|
+
const graceMs = opts.graceMs ?? DEFAULTS.reconcileHangGraceMs;
|
|
229
|
+
|
|
230
|
+
// Latest `received` per changeId.
|
|
231
|
+
const latestReceived = new Map<string, ActivityTimelineRow>();
|
|
232
|
+
for (const r of received) {
|
|
233
|
+
if (!r.changeId) continue;
|
|
234
|
+
const prev = latestReceived.get(r.changeId);
|
|
235
|
+
if (!prev || r.at > prev.at) latestReceived.set(r.changeId, r);
|
|
236
|
+
}
|
|
237
|
+
// Latest terminal `at` per changeId (any status).
|
|
238
|
+
const latestTerminalAt = new Map<string, string>();
|
|
239
|
+
for (const t of terminals) {
|
|
240
|
+
if (!t.changeId) continue;
|
|
241
|
+
const prev = latestTerminalAt.get(t.changeId);
|
|
242
|
+
if (!prev || t.at > prev) latestTerminalAt.set(t.changeId, t.at);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const out: ActivityAlert[] = [];
|
|
246
|
+
for (const [changeId, r] of latestReceived) {
|
|
247
|
+
const terminalAt = latestTerminalAt.get(changeId);
|
|
248
|
+
if (terminalAt && terminalAt >= r.at) continue; // terminated after this receive ⇒ not hung
|
|
249
|
+
const receivedMs = Date.parse(r.at);
|
|
250
|
+
if (Number.isNaN(receivedMs)) continue;
|
|
251
|
+
const elapsedMs = opts.now - receivedMs;
|
|
252
|
+
if (elapsedMs <= graceMs) continue; // still legitimately in flight ⇒ no alert
|
|
253
|
+
out.push({
|
|
254
|
+
type: "reconcile-hang",
|
|
255
|
+
severity: "critical",
|
|
256
|
+
title: "Reconcile received but never terminated",
|
|
257
|
+
summary:
|
|
258
|
+
`Change ${changeId} got a reconcile webhook ${Math.round(elapsedMs / 60000)}m ago with no ` +
|
|
259
|
+
`terminal state — the review loop is stuck.`,
|
|
260
|
+
changeId,
|
|
261
|
+
details: { receivedAt: r.at, minutesElapsed: Math.round(elapsedMs / 60000), graceMinutes: Math.round(graceMs / 60000) },
|
|
262
|
+
since: r.at,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── Detector 4: deploy-skew (persistent, not rollout-transient) ─────────────
|
|
269
|
+
|
|
270
|
+
export interface DeploySkewAlertOptions {
|
|
271
|
+
/** "now" as epoch ms (injected for testing). */
|
|
272
|
+
now: number;
|
|
273
|
+
graceMs?: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Promote the visibility-only `DeploySkewSummary` into an alert, WITHOUT firing on
|
|
278
|
+
* every normal rollout. Fires only when the build serving this request differs from
|
|
279
|
+
* the newest observed build AND that newest build has been latest for longer than
|
|
280
|
+
* `graceMs` — i.e. THIS isolate never rolled to a deploy that finished long ago.
|
|
281
|
+
* A fresh rollout (newest observed < grace old) is the expected transient → silent.
|
|
282
|
+
*/
|
|
283
|
+
export function detectDeploySkew(skew: DeploySkewSummary, opts: DeploySkewAlertOptions): ActivityAlert[] {
|
|
284
|
+
const graceMs = opts.graceMs ?? DEFAULTS.deploySkewGraceMs;
|
|
285
|
+
const { runningVersion, latestObservedVersion } = skew;
|
|
286
|
+
// Need to know BOTH the serving build and the newest observed build to judge skew.
|
|
287
|
+
if (!runningVersion || !latestObservedVersion) return [];
|
|
288
|
+
if (runningVersion === latestObservedVersion) return []; // this isolate is current ⇒ fine
|
|
289
|
+
const latest = skew.observedVersions[0];
|
|
290
|
+
if (!latest) return [];
|
|
291
|
+
const latestAgeMs = opts.now - Date.parse(latest.lastObservedAt);
|
|
292
|
+
if (Number.isNaN(latestAgeMs) || latestAgeMs <= graceMs) return []; // rollout still in its grace window
|
|
293
|
+
return [
|
|
294
|
+
{
|
|
295
|
+
type: "deploy-skew",
|
|
296
|
+
severity: "warning",
|
|
297
|
+
title: "Stale build still serving after rollout",
|
|
298
|
+
summary:
|
|
299
|
+
`This isolate serves build ${runningVersion} but ${latestObservedVersion} has been the ` +
|
|
300
|
+
`latest for ${Math.round(latestAgeMs / 60000)}m — a stale isolate never rolled forward.`,
|
|
301
|
+
details: {
|
|
302
|
+
runningVersion,
|
|
303
|
+
latestObservedVersion,
|
|
304
|
+
minutesStale: Math.round(latestAgeMs / 60000),
|
|
305
|
+
graceMinutes: Math.round(graceMs / 60000),
|
|
306
|
+
},
|
|
307
|
+
since: latest.lastObservedAt,
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── The on-demand orchestrator ──────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export interface CheckAlertsOptions {
|
|
315
|
+
/** Injected clock (tests). Defaults to `new Date()`. */
|
|
316
|
+
now?: Date;
|
|
317
|
+
lookbackMs?: number;
|
|
318
|
+
previewMissThreshold?: number;
|
|
319
|
+
previewMissWindowMs?: number;
|
|
320
|
+
reconcileHangGraceMs?: number;
|
|
321
|
+
deploySkewGraceMs?: number;
|
|
322
|
+
/** Pin the serving build for deploy-skew (tests); real callers omit it. */
|
|
323
|
+
runningOverride?: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Run all four detectors for a tenant against the D1 timeline and return the active
|
|
328
|
+
* alerts, most-severe-first. Tenant-scoped through `queryActivity`/`deriveDeploySkew`
|
|
329
|
+
* (the same isolation invariant covers the whole check). On-demand: called when the
|
|
330
|
+
* ops-timeline page loads and by the read-only alerts endpoint — no cron.
|
|
331
|
+
*/
|
|
332
|
+
export async function checkActivityAlerts(
|
|
333
|
+
db: Queryable,
|
|
334
|
+
tenantId: string,
|
|
335
|
+
opts: CheckAlertsOptions = {},
|
|
336
|
+
): Promise<ActivityAlert[]> {
|
|
337
|
+
const now = opts.now ?? new Date();
|
|
338
|
+
const nowMs = now.getTime();
|
|
339
|
+
const lookbackMs = opts.lookbackMs ?? DEFAULTS.lookbackMs;
|
|
340
|
+
const since = new Date(nowMs - lookbackMs).toISOString();
|
|
341
|
+
|
|
342
|
+
const [failedTerminals, allTerminals, received, serveFailures, skew] = await Promise.all([
|
|
343
|
+
queryActivity(db, { tenantId, action: "reconcile.terminal", status: "failed", since, order: "desc" }),
|
|
344
|
+
queryActivity(db, { tenantId, action: "reconcile.terminal", since, order: "desc" }),
|
|
345
|
+
queryActivity(db, { tenantId, action: "webhook.reconcile.received", since, order: "desc" }),
|
|
346
|
+
queryActivity(db, { tenantId, action: "preview.serve", status: "failed", since, order: "desc" }),
|
|
347
|
+
deriveDeploySkew(db, tenantId, { since, ...(opts.runningOverride ? { runningOverride: opts.runningOverride } : {}) }),
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
return sortAlerts([
|
|
351
|
+
...detectSilentTerminals(failedTerminals),
|
|
352
|
+
...detectRecurringPreviewMisses(serveFailures, {
|
|
353
|
+
now: nowMs,
|
|
354
|
+
...(opts.previewMissThreshold != null ? { threshold: opts.previewMissThreshold } : {}),
|
|
355
|
+
...(opts.previewMissWindowMs != null ? { windowMs: opts.previewMissWindowMs } : {}),
|
|
356
|
+
}),
|
|
357
|
+
...detectReconcileHangs(received, allTerminals, {
|
|
358
|
+
now: nowMs,
|
|
359
|
+
...(opts.reconcileHangGraceMs != null ? { graceMs: opts.reconcileHangGraceMs } : {}),
|
|
360
|
+
}),
|
|
361
|
+
...detectDeploySkew(skew, {
|
|
362
|
+
now: nowMs,
|
|
363
|
+
...(opts.deploySkewGraceMs != null ? { graceMs: opts.deploySkewGraceMs } : {}),
|
|
364
|
+
}),
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** One-line alert summary for a log line / notification body (rumAlert parity). */
|
|
369
|
+
export function formatActivityAlert(a: ActivityAlert): string {
|
|
370
|
+
return `[${a.severity}] ${a.type}: ${a.summary} (since ${a.since})`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Coherent surface descriptor (F16 `MONITORS` parity) ─────────────────────
|
|
374
|
+
|
|
375
|
+
/** Honest per-condition source + wiring note — the audit trail for "is this
|
|
376
|
+
* actually alerting?", mirroring F16's `WiringStatus`. */
|
|
377
|
+
export interface AlertCondition {
|
|
378
|
+
type: ActivityAlertType;
|
|
379
|
+
title: string;
|
|
380
|
+
defaultSeverity: AlertSeverity;
|
|
381
|
+
threshold: string;
|
|
382
|
+
/** Which timeline signal(s) it reads. */
|
|
383
|
+
source: string;
|
|
384
|
+
/** Honest wiring status. */
|
|
385
|
+
wiring: "in-code" | "needs-timeline-fanout" | "needs-external";
|
|
386
|
+
notes?: string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export const ALERT_CONDITIONS: readonly AlertCondition[] = [
|
|
390
|
+
{
|
|
391
|
+
type: "silent-terminal",
|
|
392
|
+
title: "Reconcile terminated in failure",
|
|
393
|
+
defaultSeverity: "critical",
|
|
394
|
+
threshold: "any reconcile.terminal/failed (reconcile_fault ⇒ critical, reconcile_blocked ⇒ warning)",
|
|
395
|
+
source: "reconcile.terminal (timeline sink)",
|
|
396
|
+
wiring: "needs-timeline-fanout",
|
|
397
|
+
notes: "Emitted by candidate-reconcile.ts via recordActivity() (logs-only today); reaches D1 once the D1/D4 server→timeline fan-out lands.",
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
type: "recurring-preview-miss",
|
|
401
|
+
title: "Preview repeatedly missing for a live change",
|
|
402
|
+
defaultSeverity: "warning",
|
|
403
|
+
threshold: "≥3 preview.serve/failed/miss_no_env for one changeId within 15m",
|
|
404
|
+
source: "preview.serve (analytics sink — high volume)",
|
|
405
|
+
wiring: "needs-external",
|
|
406
|
+
notes: "preview.serve stays analytics-sink by design; detector is source-agnostic (rumAlert precedent) and fires against an Analytics-Engine-sourced or seeded feed.",
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
type: "reconcile-hang",
|
|
410
|
+
title: "Reconcile received but never terminated",
|
|
411
|
+
defaultSeverity: "critical",
|
|
412
|
+
threshold: "webhook.reconcile.received with no reconcile.terminal at-or-after it, past a 10m grace",
|
|
413
|
+
source: "webhook.reconcile.received + reconcile.terminal (both timeline sink)",
|
|
414
|
+
wiring: "needs-timeline-fanout",
|
|
415
|
+
notes: "D7 promoted webhook.reconcile.received to the timeline sink and emits it at reconcile start; reaches D1 once server→timeline fan-out lands.",
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
type: "deploy-skew",
|
|
419
|
+
title: "Stale build still serving after rollout",
|
|
420
|
+
defaultSeverity: "warning",
|
|
421
|
+
threshold: "serving build ≠ newest observed AND newest has been latest > 15m",
|
|
422
|
+
source: "deploy.version_observed (timeline sink) + this build's __GIT_SHA__",
|
|
423
|
+
wiring: "needs-timeline-fanout",
|
|
424
|
+
notes: "Promotes D5's deriveDeploySkew from visibility to an alert; grace period suppresses normal-rollout noise.",
|
|
425
|
+
},
|
|
426
|
+
] as const;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-facing "who did this" attribution for a candidate change or aggregate —
|
|
3
|
+
* DELIBERATELY separate from the anonymized activity spine. `ActivityActor.id`
|
|
4
|
+
* is a salted, non-reversible hash by design (see `recordActivity.ts`'s header:
|
|
5
|
+
* a raw PII id in the core zone would defeat the redaction contract), so it can never
|
|
6
|
+
* answer "who submitted/accepted/rejected/shipped this" for an owner reviewing
|
|
7
|
+
* `/admin#publish`. This persists the real email (never joined into the general
|
|
8
|
+
* `queryActivity()` surface, never cross-tenant) alongside the existing hashed
|
|
9
|
+
* event, so the two concerns stay separate: analytics stays anonymized, and
|
|
10
|
+
* this stays a narrow, owner-only-readable accountability record.
|
|
11
|
+
*
|
|
12
|
+
* `sub` (the tot20-issued, stable user id) would be a better key than email —
|
|
13
|
+
* email is a mutable account attribute, not the identity itself — but `sub`
|
|
14
|
+
* is dropped before `SessionRecord` today (see `auth/session.ts`). Threading
|
|
15
|
+
* it through is a real follow-up, not done here; email is what every call site
|
|
16
|
+
* already has.
|
|
17
|
+
*/
|
|
18
|
+
import { displayNameFromEmail } from "./recordActivity";
|
|
19
|
+
|
|
20
|
+
export type ChangeActorAction = "submit" | "reject" | "resolve" | "accept" | "ship" | "build";
|
|
21
|
+
|
|
22
|
+
export interface ChangeActorRecord {
|
|
23
|
+
email: string;
|
|
24
|
+
/** Best-effort display name — no real name is captured anywhere today, so
|
|
25
|
+
* this is always {@link displayNameFromEmail}'s local-part guess. Swap the
|
|
26
|
+
* source here (not the callers) if a real name ever becomes available. */
|
|
27
|
+
name: string;
|
|
28
|
+
at: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ChangeActorKv {
|
|
32
|
+
get(key: string): Promise<string | null>;
|
|
33
|
+
put(key: string, value: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function attributionKey(tenantId: string, subjectId: string, action: ChangeActorAction): string {
|
|
37
|
+
return `cust:change-actor:${tenantId}:${subjectId}:${action}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Persist who took `action` on `subjectId` (a `changeId` for reject/accept/build,
|
|
42
|
+
* or the aggregate id for ship). Best-effort — never blocks or fails the
|
|
43
|
+
* mutation it describes; a write fault is swallowed, mirroring `recordActivity`.
|
|
44
|
+
*/
|
|
45
|
+
export async function recordChangeActor(
|
|
46
|
+
kv: ChangeActorKv,
|
|
47
|
+
tenantId: string,
|
|
48
|
+
subjectId: string,
|
|
49
|
+
action: ChangeActorAction,
|
|
50
|
+
email: string,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const record: ChangeActorRecord = {
|
|
54
|
+
email,
|
|
55
|
+
name: displayNameFromEmail(email),
|
|
56
|
+
at: new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
await kv.put(attributionKey(tenantId, subjectId, action), JSON.stringify(record));
|
|
59
|
+
} catch {
|
|
60
|
+
/* best-effort — the mutation it describes already succeeded */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read every recorded action's attribution for one subject. Best-effort per
|
|
65
|
+
* action: a missing/corrupt record is simply absent from the result, never
|
|
66
|
+
* a thrown error. */
|
|
67
|
+
export async function readChangeActors(
|
|
68
|
+
kv: ChangeActorKv,
|
|
69
|
+
tenantId: string,
|
|
70
|
+
subjectId: string,
|
|
71
|
+
): Promise<Partial<Record<ChangeActorAction, ChangeActorRecord>>> {
|
|
72
|
+
const actions: ChangeActorAction[] = ["submit", "reject", "resolve", "accept", "ship", "build"];
|
|
73
|
+
const out: Partial<Record<ChangeActorAction, ChangeActorRecord>> = {};
|
|
74
|
+
await Promise.all(
|
|
75
|
+
actions.map(async (action) => {
|
|
76
|
+
try {
|
|
77
|
+
const raw = await kv.get(attributionKey(tenantId, subjectId, action));
|
|
78
|
+
if (raw) out[action] = JSON.parse(raw) as ChangeActorRecord;
|
|
79
|
+
} catch {
|
|
80
|
+
/* a corrupt/missing record just isn't shown */
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy.version_observed — the deploy-provenance / deploy-skew signal.
|
|
3
|
+
*
|
|
4
|
+
* "A deployed worker version/commit was observed." This module makes
|
|
5
|
+
* `worker_commit` reliably present on server events and EMITs this observation at
|
|
6
|
+
* least once per running build. It intentionally does NO comparison — it has no
|
|
7
|
+
* notion of what the umbrella tip is, only what THIS build is; the "== umbrella
|
|
8
|
+
* tip?" deploy-skew comparison and the timeline query live elsewhere.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT PRODUCES. A catalog `deploy.version_observed` event (ACTION_CATALOG:
|
|
11
|
+
* actors [system|dev], source server, sinks logs+analytics+timeline). The FULL
|
|
12
|
+
* 40-char commit rides `scope.workerCommit` — the CORE zone, which createActivityEvent
|
|
13
|
+
* never redacts — because that is where the authoritative, unscrubbed sha belongs.
|
|
14
|
+
*
|
|
15
|
+
* DELIBERATELY the full sha does NOT go in `payload`. The action's argsAllow names
|
|
16
|
+
* `commit`, but a 40-char hex sha trips the `high_entropy` VALUE canary (redaction.ts:
|
|
17
|
+
* ≥28 chars of base64url) — it would be scrubbed to «redacted» AND set
|
|
18
|
+
* `report.canaryTripped`, which alerting reads, firing a FALSE PII alarm on every cold
|
|
19
|
+
* start. So payload carries only the SHORT `version` (7 chars, safe) plus `env` +
|
|
20
|
+
* `observedFrom`; a reader wanting the full sha reads scope.workerCommit (a git sha is
|
|
21
|
+
* public, so the core zone is its correct, alert-free home). Treat scope.workerCommit
|
|
22
|
+
* as the authoritative provenance, payload.version as a
|
|
23
|
+
* human-eyeball convenience.
|
|
24
|
+
*
|
|
25
|
+
* WHERE IT'S EMITTED. observeDeployVersion() fires ONCE PER WORKER ISOLATE (guarded
|
|
26
|
+
* by a module-level flag) from the middleware's front door — a cold start emits one
|
|
27
|
+
* observation, not one per request, so the stream is low-volume and honest. The
|
|
28
|
+
* emission goes to the universal `logs` sink (structured `[activity]` JSON to Workers
|
|
29
|
+
* Logs, already provisioned via `[observability]`).
|
|
30
|
+
*/
|
|
31
|
+
import { createActivityEvent } from "@tot/public-runtime";
|
|
32
|
+
import type { ActivityEvent, RedactionReport } from "@tot/public-runtime";
|
|
33
|
+
import { workerCommit, workerCommitShort, UNKNOWN_COMMIT } from "./workerCommit.js";
|
|
34
|
+
import { isActivityDisabled } from "./killSwitch.js";
|
|
35
|
+
|
|
36
|
+
export interface DeployVersionObservedInput {
|
|
37
|
+
/** Deploy env/target name (e.g. "preview", "production"), when known. Optional. */
|
|
38
|
+
env?: string;
|
|
39
|
+
/** What surfaced the observation (e.g. "startup", "health"). Defaults "startup". */
|
|
40
|
+
observedFrom?: string;
|
|
41
|
+
/** Opaque actor id. Defaults "system" (the platform observed its own build). */
|
|
42
|
+
actorId?: string;
|
|
43
|
+
/** Override the build commit — tests only. Real callers omit (reads __GIT_SHA__). */
|
|
44
|
+
commit?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build (do not emit) the `deploy.version_observed` ActivityEvent for the running
|
|
49
|
+
* build. Pure + side-effect-free — the piece downstream consumers ultimately consume
|
|
50
|
+
* and the piece tests assert against. Returns the safe-constructed event plus its redaction report.
|
|
51
|
+
*/
|
|
52
|
+
export function buildDeployVersionObservedEvent(
|
|
53
|
+
input: DeployVersionObservedInput = {},
|
|
54
|
+
): { event: ActivityEvent; report: RedactionReport } {
|
|
55
|
+
const commit = workerCommit(input.commit);
|
|
56
|
+
const version = workerCommitShort(input.commit);
|
|
57
|
+
return createActivityEvent({
|
|
58
|
+
action: "deploy.version_observed",
|
|
59
|
+
actor: { kind: "system", id: input.actorId ?? "system" },
|
|
60
|
+
source: "server",
|
|
61
|
+
outcome: { status: "succeeded" },
|
|
62
|
+
// worker_commit (full sha) in the CORE zone — never redacted; the authoritative
|
|
63
|
+
// provenance every server event carries and deploy skew is derived from.
|
|
64
|
+
scope: commit !== UNKNOWN_COMMIT ? { workerCommit: commit } : {},
|
|
65
|
+
// Payload carries the SHORT version (safe from the high_entropy canary that would
|
|
66
|
+
// scrub a full sha) + env + observedFrom. Unknown builds omit `version` rather than
|
|
67
|
+
// store the sentinel, but still emit the observation.
|
|
68
|
+
payload: {
|
|
69
|
+
args: {
|
|
70
|
+
...(commit !== UNKNOWN_COMMIT ? { version } : {}),
|
|
71
|
+
...(input.env ? { env: input.env } : {}),
|
|
72
|
+
observedFrom: input.observedFrom ?? "startup",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Serialize an ActivityEvent to the universal `logs` sink (Workers Logs) —
|
|
80
|
+
* structured `[activity]` JSON so one grep finds all activity lines.
|
|
81
|
+
* Best-effort, never throws.
|
|
82
|
+
*/
|
|
83
|
+
function emitToLogs(event: ActivityEvent): void {
|
|
84
|
+
try {
|
|
85
|
+
console.log(`[activity] ${JSON.stringify(event)}`);
|
|
86
|
+
} catch {
|
|
87
|
+
// best-effort only — telemetry must never break a request
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Per-isolate guard: emit the observation once per cold start, not per request. */
|
|
92
|
+
let observedThisIsolate = false;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Emit `deploy.version_observed` ONCE for this worker isolate. Idempotent within an
|
|
96
|
+
* isolate (subsequent calls are no-ops), best-effort, never throws — safe to call on
|
|
97
|
+
* every request from the middleware front door. Returns the event it emitted (or
|
|
98
|
+
* `undefined` if it already fired this isolate) so callers/tests can inspect it.
|
|
99
|
+
*/
|
|
100
|
+
export function observeDeployVersion(
|
|
101
|
+
input: DeployVersionObservedInput = {},
|
|
102
|
+
): ActivityEvent | undefined {
|
|
103
|
+
if (observedThisIsolate) return undefined;
|
|
104
|
+
// Kill switch (D8): telemetry off ⇒ skip the observation entirely (fail-open). Do
|
|
105
|
+
// NOT set the per-isolate guard, so re-enabling emits the observation on the next call.
|
|
106
|
+
if (isActivityDisabled()) return undefined;
|
|
107
|
+
observedThisIsolate = true;
|
|
108
|
+
try {
|
|
109
|
+
const { event } = buildDeployVersionObservedEvent(input);
|
|
110
|
+
emitToLogs(event);
|
|
111
|
+
return event;
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Test-only: reset the per-isolate guard so a test can re-exercise the first-fire path. */
|
|
118
|
+
export function __resetDeployVersionObservedForTest(): void {
|
|
119
|
+
observedThisIsolate = false;
|
|
120
|
+
}
|