@catalyst-cloud/schema 0.1.24 → 0.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/schema",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "Typed Drizzle schema = single source of truth for the per-tenant Mirror DO SQLite store (CTC-13 / ADR-0002). Shared by the mirror Worker, the host-sync replica, and the browser OPFS replica.",
6
6
  "license": "MIT",
@@ -1767,19 +1767,35 @@ export const currentStateEventTypes = {
1767
1767
  },
1768
1768
  } as const satisfies Record<string, CurrentStateEventSpec>;
1769
1769
 
1770
+ /**
1771
+ * CTC-962 (ADR-0048) — how much of a telemetry name is STORED by the cloud-owned sink. Absent from a
1772
+ * spec ⇒ `DEFAULT_SAMPLING` (packages/schema/src/telemetry/point.ts) — never "unbounded". Collapse
1773
+ * (repeat-folding, both legs) applies regardless of class; head sampling (`sampled`) applies to the
1774
+ * Analytics Engine hot leg ONLY — the Pipelines→R2 Iceberg cold leg is unsampled by design (D3): a
1775
+ * durable RECORD you sampled is not a record.
1776
+ */
1777
+ export type SamplingClass =
1778
+ | { kind: "always" } // rare, high-value: errors, boundary events — never dropped by sampling.
1779
+ | { kind: "sampled"; oneIn: number } // head sampling for high-volume names (hot leg only).
1780
+ | { kind: "counted" }; // no individual value; the collapse stage's rollup point IS the record.
1781
+
1770
1782
  /** Why a type is excluded from the durable contract — the fact a consumer would lose if this reason
1771
1783
  * is wrong, per the phase-3 producer-inventory bar ("names the failure someone would notice"). */
1772
1784
  export interface TelemetryEventSpec {
1773
1785
  reason: string;
1786
+ /** CTC-962 (ADR-0048) — the sampling class the cloud-owned telemetry sink applies to this name. */
1787
+ sampling?: SamplingClass;
1774
1788
  }
1775
1789
 
1776
1790
  /**
1777
- * ⛔ EXILED from the durable contract (ADR-0039 binding condition 1). 73 types, the volume-dominant
1778
- * side of Finding 3's split (92.3% of August's named lines were telemetry-shaped in the plan's
1779
- * measurement) — ticks, gauges, process samples, read traces, forwarding lag, "would-*" dry-run
1780
- * counterfactuals, and retry-storm noise that collapses to a single durable boundary fact elsewhere
1781
- * in this registry (e.g. linear.write.proxy.failed's 140k+ lines/mo collapses to the ONE durable
1782
- * linear.label.retry-exhausted terminal fact).
1791
+ * ⛔ EXILED from the durable contract (ADR-0039 binding condition 1). 70 fleet/orchestrator-origin
1792
+ * types (this doc comment previously said 73 — stale; measured, not re-derived, so it does not drift
1793
+ * again), the volume-dominant side of Finding 3's split (92.3% of August's named lines were
1794
+ * telemetry-shaped in the plan's measurement) — ticks, gauges, process samples, read traces,
1795
+ * forwarding lag, "would-*" dry-run counterfactuals, and retry-storm noise that collapses to a single
1796
+ * durable boundary fact elsewhere in this registry (e.g. linear.write.proxy.failed's 140k+ lines/mo
1797
+ * collapses to the ONE durable linear.label.retry-exhausted terminal fact). CTC-962 (ADR-0048) added
1798
+ * 5 mirror-ingress names on top of those 70 (measured total: 75) — see the `mirror.*` entries below.
1783
1799
  */
1784
1800
  export const telemetryEventTypes = {
1785
1801
  "account.ratelimit.sampled": {
@@ -1874,6 +1890,35 @@ export const telemetryEventTypes = {
1874
1890
  reason:
1875
1891
  "A producer bug: 14 lines shipped with an empty `event.name`. Not a real type — flagged in the phase-3 producer inventory as a defect for the sibling repo to fix (a JSONL writer must never emit an unnamed line), not something the durable registry can carry forward as a name.",
1876
1892
  },
1893
+ // CTC-962 (ADR-0048) — the mirror-ingress signals the cloud-owned telemetry sink's first producer
1894
+ // emits. `rejected` / `unroutable` / `dead-lettered` are boundary facts that could argue for
1895
+ // DURABLE classification instead (see the plan's D4 flagged question) — classified telemetry here
1896
+ // deliberately, with an ADR-0039 registry change filed as a follow-up rather than decided silently.
1897
+ "mirror.ingest.committed": {
1898
+ reason:
1899
+ "A successfully-committed ingest envelope — high volume by construction (one per accepted webhook), and the durable fact it corresponds to (the event_log row itself) already exists; this is a volume signal for the sink, not a second copy of the durable record. Launched UNSAMPLED (Ryan, 2026-08-28): measured volume (~6.7K/day) sits well inside AE's included allowance; re-introduce sampled(N) here when volume warrants — the machinery stays.",
1900
+ sampling: { kind: "always" },
1901
+ },
1902
+ "mirror.ingest.dead-lettered": {
1903
+ reason:
1904
+ "An accepted-then-lost ingest envelope (CTC-294's silent-hole alarm) — rare and high-value; every occurrence is a delivery that reached neither change_log nor event_log and must be individually visible, not sampled away.",
1905
+ sampling: { kind: "always" },
1906
+ },
1907
+ "mirror.webhook.received": {
1908
+ reason:
1909
+ "Every accepted provider webhook delivery — the highest-volume mirror-ingress signal by construction, one per delivery from two providers across every connected tenant. Launched UNSAMPLED (Ryan, 2026-08-28): measured volume (~6.7K/day) sits well inside AE's included allowance; re-introduce sampled(N) here when volume warrants — the machinery stays.",
1910
+ sampling: { kind: "always" },
1911
+ },
1912
+ "mirror.webhook.rejected": {
1913
+ reason:
1914
+ "A webhook that failed signature/timestamp/JSON verification — each occurrence is independently actionable (which gate, which tenant), so it is not sampled. ⚠️ NOT rare: measured at 11,027/24h on staging against 6,710 `received` over the same window (CTC-962 Phase 6 — docs/observability.md's noise-budget table). The `always` class here is a deliberately-paid cold-tier cost justified by per-occurrence diagnostic value, NOT an assumption that this name is low-volume.",
1915
+ sampling: { kind: "always" },
1916
+ },
1917
+ "mirror.webhook.unroutable": {
1918
+ reason:
1919
+ "A verified webhook naming no connected account — carries the routing key that failed, the entire diagnostic value of the signal; sampling it away would hide which workspace is mis-bound.",
1920
+ sampling: { kind: "always" },
1921
+ },
1877
1922
  "orphans.reap-requested": {
1878
1923
  reason:
1879
1924
  "Phase-agent worker-directory garbage collection. phase.terminal.reap-requested alone is 10,015 lines/mo — a periodic sweep re-requesting cleanup of already-terminal workers, not a discrete coordination fact worth replay.",
package/src/index.ts CHANGED
@@ -82,6 +82,11 @@ export {
82
82
  // packages/schema/src/events/index.ts's own header for what phase 2+ adds to this barrel.
83
83
  export * from "./events/index.js";
84
84
 
85
+ // CTC-962 (ADR-0048) — the cloud-owned telemetry sink's shared envelope: TelemetryPoint, the
86
+ // admission gate over the events registry above, and the pure AE/Iceberg record builders both the
87
+ // mirror Worker and any future out-of-Worker emitter import.
88
+ export * from "./telemetry/point.js";
89
+
85
90
  /** Every table in the Mirror DO store — pass as `drizzle(storage, { schema: mirrorSchema })`. */
86
91
  export const mirrorSchema = {
87
92
  issues,
@@ -0,0 +1,209 @@
1
+ // point.ts — CTC-962 (ADR-0048). The telemetry envelope the cloud-owned sink's two-leg producer
2
+ // emits, and the pure functions that turn a batch of them into an Analytics Engine data point (hot,
3
+ // sampled) or a Pipelines/Iceberg stream record (cold, unsampled). Zero Workers-runtime dependency —
4
+ // same discipline as ../events/type-grammar.ts — so the mirror Worker and any future out-of-Worker
5
+ // emitter (runner, index-host) both import this from a plain Node/browser-safe module.
6
+
7
+ import { telemetryEventTypes, classifyEventType, type SamplingClass } from "../events/registry.js";
8
+
9
+ export type { SamplingClass } from "../events/registry.js";
10
+
11
+ /** The wire `name` of a telemetry point — the keys of telemetryEventTypes, closed by construction. */
12
+ export type TelemetryName = keyof typeof telemetryEventTypes;
13
+
14
+ // AE platform caps — the SAME values as apps/mirror/src/event-stream/validate.ts, which re-exports
15
+ // them (and `boundExcerpt` below) from this module rather than carrying a second definition, so the
16
+ // event-stream and telemetry excerpt truncators can never drift apart (CTC-962 plan D-notes).
17
+ export const MAX_POINTS_PER_BATCH = 25;
18
+ export const MAX_INDEX_BYTES = 96;
19
+ /** ClickHouse/AE per-blob byte cap. The bounded excerpt (`detail`) is budgeted against this. */
20
+ export const MAX_EXCERPT_BYTES = 1_024;
21
+
22
+ function byteLen(s: string): number {
23
+ return new TextEncoder().encode(s).length;
24
+ }
25
+
26
+ /**
27
+ * Bound a would-be-blob string to the AE per-blob cap, appending a stable elision marker that names
28
+ * how many bytes were dropped. Truncation is on a UTF-8 CHARACTER boundary: a naive byte slice can
29
+ * split a multi-byte sequence, so the kept bytes are decoded leniently and a trailing partial
30
+ * (U+FFFD) is dropped so the excerpt is always valid text AND stays under the byte budget. Lifted
31
+ * here (CTC-962) from apps/mirror/src/event-stream/validate.ts, which now imports this copy instead
32
+ * of carrying its own — one truncator, not two that could drift.
33
+ */
34
+ export function boundExcerpt(raw: string): string {
35
+ const bytes = byteLen(raw);
36
+ if (bytes <= MAX_EXCERPT_BYTES) return raw;
37
+ // Reserve marker room using the WORST-CASE omitted count (the whole payload); the real marker is
38
+ // never longer, so the final excerpt is guaranteed ≤ MAX_EXCERPT_BYTES.
39
+ const markerReserve = byteLen(`…[+${bytes} bytes]`);
40
+ const keepBudget = Math.max(0, MAX_EXCERPT_BYTES - markerReserve);
41
+ const kept = new TextEncoder().encode(raw).slice(0, keepBudget);
42
+ // Lenient decode → a split trailing multi-byte becomes U+FFFD; strip it so output is valid + smaller.
43
+ let decoded = new TextDecoder().decode(kept);
44
+ if (decoded.endsWith("�")) decoded = decoded.slice(0, -1);
45
+ const omitted = bytes - byteLen(decoded);
46
+ return `${decoded}…[+${omitted} bytes]`;
47
+ }
48
+
49
+ /** Conservative default for a telemetry name with no explicit `sampling` spec — never "unbounded".
50
+ * 70 pre-existing fleet/orchestrator names carry no per-name budget yet (see registry.ts); defaulting
51
+ * them to `always` would import their full volume into the hot tier the moment anything emits them. */
52
+ export const DEFAULT_SAMPLING: SamplingClass = { kind: "sampled", oneIn: 100 };
53
+
54
+ /** Resolve a telemetry name's sampling class — its own spec's, or DEFAULT_SAMPLING. Never undefined,
55
+ * so a name added to the registry without a `sampling` field still resolves conservatively. */
56
+ export function resolveSampling(name: string): SamplingClass {
57
+ const spec = (telemetryEventTypes as Record<string, { sampling?: SamplingClass }>)[name];
58
+ return spec?.sampling ?? DEFAULT_SAMPLING;
59
+ }
60
+
61
+ /**
62
+ * One telemetry point — the shared envelope both the AE hot leg and the Iceberg cold leg derive
63
+ * their layout from (D3: same vocabulary, different volume).
64
+ */
65
+ export interface TelemetryPoint {
66
+ /** Closed-registry name, e.g. "mirror.webhook.received". Low cardinality by construction. */
67
+ readonly name: TelemetryName;
68
+ /** service.name — "catalyst-cloud.mirror" today. */
69
+ readonly service: string;
70
+ readonly outcome: "ok" | "error" | "skip";
71
+ /** BOUNDED ≤1024-byte excerpt. Dimensions and identifiers only — never tenant content (ADR-0048
72
+ * binding condition 1). */
73
+ readonly detail: string;
74
+ /** ADR-0011 trace_id, so a cloud row joins the same correlation key as every other signal. */
75
+ readonly traceId: string;
76
+ readonly durationMs: number | null;
77
+ /** >1 when this point is a collapsed repeat (see `collapse`). */
78
+ readonly count: number;
79
+ }
80
+
81
+ /**
82
+ * Refuse to emit a name that is not `classifyEventType(name) === "telemetry"` — the D4 admission
83
+ * gate. A `durable` or `current-state` name must never be downgraded into this sampled, truncating,
84
+ * ~90-day-hot tier; an `unclassified` name has never been reviewed for either bucket. Throws rather
85
+ * than returning a falsy sentinel (AGENTS.md: a helper that degrades to "fine" hides the defect).
86
+ */
87
+ export function assertEmittable(name: string): void {
88
+ const cls = classifyEventType(name);
89
+ if (cls !== "telemetry") {
90
+ throw new Error(
91
+ `telemetry sink refuses "${name}": classifyEventType() says "${cls}", not "telemetry" ` +
92
+ `(packages/schema/src/events/registry.ts is the one registry — see ADR-0048 binding condition 2)`,
93
+ );
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Structurally matches Cloudflare's `AnalyticsEngineDataPoint` (`{indexes,blobs,doubles}` of
99
+ * `(ArrayBuffer|string|null)[]`/`number[]`) without importing `@cloudflare/workers-types` — this
100
+ * module stays Workers-free. `env.TELEMETRY.writeDataPoint()` accepts this value structurally.
101
+ */
102
+ export interface TelemetryAeDataPoint {
103
+ readonly indexes: string[];
104
+ readonly blobs: string[];
105
+ readonly doubles: number[];
106
+ }
107
+
108
+ /**
109
+ * The ONE positional AE layout for the hot leg — mirrors event-stream/validate.ts's
110
+ * toEventStreamDataPoint exactly (positional blobs, the same index-byte guard). `doubles` is `[count,
111
+ * durationMs?]`: `count` is ALWAYS present so its position never shifts, and `durationMs` — genuinely
112
+ * unknown for most callers (e.g. `mirror.ingest.dead-lettered` never sets `telemetryDurationMs`) — is
113
+ * appended only when known, the same "drop the optional trailing double when null" convention
114
+ * event-stream/sql-proxy.ts's `doubles:[seq]` already uses. Encoding an unknown duration as `0`
115
+ * (the previous behavior) would have AE and the hot-leg feed report a false zero-latency instead of
116
+ * "no duration recorded", indistinguishable from a real fast operation.
117
+ */
118
+ export function toTelemetryDataPoint(p: TelemetryPoint, accountId: string): TelemetryAeDataPoint {
119
+ if (byteLen(accountId) > MAX_INDEX_BYTES) {
120
+ throw new Error(`accountId exceeds ${MAX_INDEX_BYTES}-byte AE index cap`);
121
+ }
122
+ return {
123
+ indexes: [accountId],
124
+ blobs: [p.service, p.name, p.outcome, p.traceId, boundExcerpt(p.detail)],
125
+ doubles: p.durationMs == null ? [p.count] : [p.count, p.durationMs],
126
+ };
127
+ }
128
+
129
+ /** The named-column Iceberg stream record — the same fields as toTelemetryDataPoint, typed and with
130
+ * `account_id` first (D6: the cold tier's tenant fence is this column, not an R2 key prefix). The
131
+ * index signature is there ONLY so this satisfies `cloudflare:pipelines`' `PipelineRecord` generic
132
+ * constraint (`Record<string, unknown>`) without importing `@cloudflare/workers-types` here — every
133
+ * named field above is still a normal, closed, typed property; `toTelemetryStreamRecord` is the
134
+ * single producer of this shape and never adds an extra key. */
135
+ export interface TelemetryStreamRecord {
136
+ readonly account_id: string;
137
+ readonly ts: string;
138
+ readonly service: string;
139
+ readonly name: string;
140
+ readonly outcome: string;
141
+ readonly trace_id: string | null;
142
+ readonly detail: string | null;
143
+ readonly duration_ms: number | null;
144
+ readonly count: number;
145
+ readonly [key: string]: unknown;
146
+ }
147
+
148
+ /** Build the cold-leg (Iceberg) record for one point. Unsampled by construction — callers pass the
149
+ * full retained batch here, never the AE-sampled subset (D3). */
150
+ export function toTelemetryStreamRecord(
151
+ p: TelemetryPoint,
152
+ accountId: string,
153
+ ts: string,
154
+ ): TelemetryStreamRecord {
155
+ return {
156
+ account_id: accountId,
157
+ ts,
158
+ service: p.service,
159
+ name: p.name,
160
+ outcome: p.outcome,
161
+ trace_id: p.traceId || null,
162
+ detail: p.detail ? boundExcerpt(p.detail) : null,
163
+ duration_ms: p.durationMs,
164
+ count: p.count,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Fold points sharing an identical (name, outcome) pair into a single point carrying the summed
170
+ * count and the FIRST traceId seen for that pair — the direct answer to ADR-0039's measurement (one
171
+ * fact logged 14,256 times). Order-preserving on first occurrence. Applies identically regardless of
172
+ * sampling class, to BOTH legs (D3/D4) — a `counted` name's "never stored individually" guarantee
173
+ * comes from this stage, not from `applySampling`.
174
+ */
175
+ export function collapse(points: readonly TelemetryPoint[]): TelemetryPoint[] {
176
+ const order: string[] = [];
177
+ const byKey = new Map<string, TelemetryPoint>();
178
+ for (const p of points) {
179
+ const key = `${p.name}\u0000${p.outcome}`;
180
+ const existing = byKey.get(key);
181
+ if (existing) {
182
+ byKey.set(key, { ...existing, count: existing.count + p.count });
183
+ } else {
184
+ order.push(key);
185
+ byKey.set(key, { ...p });
186
+ }
187
+ }
188
+ return order.map((k) => byKey.get(k)!);
189
+ }
190
+
191
+ /** Injectable RNG surface — a `() => number` in [0, 1), the same shape as `Math.random`. */
192
+ export type Rng = () => number;
193
+
194
+ /**
195
+ * Apply the AE-only head-sampling policy (D3: sampling applies to the hot leg only — the cold leg
196
+ * gets the full `collapse()`d batch, unsampled). `always` and `counted` names always survive here —
197
+ * `counted`'s "no individual point" guarantee is `collapse`'s job (see its doc), not this function's;
198
+ * only `sampled(N)` is probabilistic, surviving with probability 1/N against the injected `rng`.
199
+ */
200
+ export function applySampling(
201
+ points: readonly TelemetryPoint[],
202
+ rng: Rng = Math.random,
203
+ ): TelemetryPoint[] {
204
+ return points.filter((p) => {
205
+ const cls = resolveSampling(p.name);
206
+ if (cls.kind === "sampled") return rng() < 1 / cls.oneIn;
207
+ return true;
208
+ });
209
+ }