@catalyst-cloud/schema 0.1.38 → 0.1.39

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.38",
3
+ "version": "0.1.39",
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",
@@ -37,6 +37,12 @@ export interface PhaseLifecycleFields {
37
37
  phase: string;
38
38
  nonce: number;
39
39
  executor: string;
40
+ /** CTC-1417 — `owner/name`, when the caller's dispatch data names one. Absent, never guessed: a
41
+ * refusal that never resolved to a repo must not claim one. */
42
+ repo?: string | null;
43
+ /** CTC-1417 — the Linear team key the work unit belongs to, when the caller knows it. */
44
+ team?: string | null;
45
+ projectId?: string | null;
40
46
  substrate?: string;
41
47
  provider?: string;
42
48
  branch?: string;
@@ -47,16 +53,30 @@ export interface PhaseLifecycleFields {
47
53
  reason?: string;
48
54
  }
49
55
 
56
+ /** A ticket identifier's team key IS its prefix — `CTC-1417` belongs to team `CTC`. That is not a
57
+ * guess: it is how Linear constructs the identifier and how this repo's own dispatch queue
58
+ * partitions (`listPublishedTeams`, `explainEligibleWork`). Restated here (rather than imported from
59
+ * ci-run-telemetry.ts's `TICKET_SHAPE`, which tests a whole different input) so the derivation is
60
+ * ANCHORED: a value that is not identifier-shaped yields null and the dimension stays absent, rather
61
+ * than a leading substring of something arbitrary being reported as a team. */
62
+ const TICKET_IDENTIFIER = /^([A-Z][A-Z0-9]{1,9})-\d+$/;
63
+
64
+ export function teamFromTicket(ticket: string): string | null {
65
+ return TICKET_IDENTIFIER.exec(ticket)?.[1] ?? null;
66
+ }
67
+
50
68
  /**
51
69
  * Build one `phase.lifecycle` TelemetryPoint. Throws (never a falsy sentinel) on an unrecognized
52
70
  * `status` — the same posture as `assertEmittable` for the event name.
53
71
  */
54
72
  export function buildPhaseLifecyclePoint(fields: PhaseLifecycleFields): TelemetryPoint {
55
73
  assertKnownStatus(fields.status);
74
+ // CTC-1417 — `ticket` and `phase` are TYPED COLUMNS now (see the point's own dimensions), so they
75
+ // are no longer repeated here as free text. Nothing is lost: `traceId` still spells
76
+ // `ticket:phase:nonce`, and the columns are what an AE query can actually filter and group on.
77
+ // What stays in `detail` is exactly what has no column of its own.
56
78
  const detailParts = [
57
79
  `status=${fields.status}`,
58
- `ticket=${fields.ticket}`,
59
- `phase=${fields.phase}`,
60
80
  `nonce=${fields.nonce}`,
61
81
  `executor=${fields.executor}`,
62
82
  ...(fields.substrate !== undefined ? [`substrate=${fields.substrate}`] : []),
@@ -75,5 +95,12 @@ export function buildPhaseLifecyclePoint(fields: PhaseLifecycleFields): Telemetr
75
95
  traceId: `${fields.ticket}:${fields.phase}:${fields.nonce}`,
76
96
  durationMs: fields.durationMs ?? null,
77
97
  count: 1,
98
+ ticket: fields.ticket,
99
+ phase: fields.phase,
100
+ repo: fields.repo ?? null,
101
+ // An explicit `team` from the caller wins; otherwise derive it from the ticket, which every one
102
+ // of these call sites already has. Null when the ticket is not identifier-shaped.
103
+ team: fields.team ?? teamFromTicket(fields.ticket),
104
+ projectId: fields.projectId ?? null,
78
105
  };
79
106
  }
@@ -18,6 +18,10 @@ export const MAX_POINTS_PER_BATCH = 25;
18
18
  export const MAX_INDEX_BYTES = 96;
19
19
  /** ClickHouse/AE per-blob byte cap. The bounded excerpt (`detail`) is budgeted against this. */
20
20
  export const MAX_EXCERPT_BYTES = 1_024;
21
+ /** Per-DIMENSION blob cap (CTC-1417). A dimension is an identifier — an `owner/name`, a team key, a
22
+ * `CTC-NNN`, a phase name — never prose, so it is budgeted far below `MAX_EXCERPT_BYTES`. The cap is
23
+ * a guard against a malformed caller, not an expected truncation point. */
24
+ export const MAX_DIMENSION_BYTES = 256;
21
25
 
22
26
  function byteLen(s: string): number {
23
27
  return new TextEncoder().encode(s).length;
@@ -32,12 +36,18 @@ function byteLen(s: string): number {
32
36
  * of carrying its own — one truncator, not two that could drift.
33
37
  */
34
38
  export function boundExcerpt(raw: string): string {
39
+ return boundToBytes(raw, MAX_EXCERPT_BYTES);
40
+ }
41
+
42
+ /** The byte-budgeted, UTF-8-boundary-safe truncator {@link boundExcerpt} and the CTC-1417 dimension
43
+ * bounder both use — one implementation so the two budgets can never drift into two truncators. */
44
+ function boundToBytes(raw: string, cap: number): string {
35
45
  const bytes = byteLen(raw);
36
- if (bytes <= MAX_EXCERPT_BYTES) return raw;
46
+ if (bytes <= cap) return raw;
37
47
  // 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.
48
+ // never longer, so the final excerpt is guaranteed ≤ cap.
39
49
  const markerReserve = byteLen(`…[+${bytes} bytes]`);
40
- const keepBudget = Math.max(0, MAX_EXCERPT_BYTES - markerReserve);
50
+ const keepBudget = Math.max(0, cap - markerReserve);
41
51
  const kept = new TextEncoder().encode(raw).slice(0, keepBudget);
42
52
  // Lenient decode → a split trailing multi-byte becomes U+FFFD; strip it so output is valid + smaller.
43
53
  let decoded = new TextDecoder().decode(kept);
@@ -58,11 +68,82 @@ export function resolveSampling(name: string): SamplingClass {
58
68
  return spec?.sampling ?? DEFAULT_SAMPLING;
59
69
  }
60
70
 
71
+ /**
72
+ * The uniform tenant dimensions every telemetry point carries (CTC-1417), on top of the `account_id`
73
+ * that is already the AE index. These are the keys an operator segments by — "which tenant, which
74
+ * repo, which team, which unit of work" — so they are TYPED COLUMNS, not text packed into `detail`:
75
+ * Analytics Engine cannot filter or GROUP BY on a substring of a free-text blob, which is exactly why
76
+ * `phase.lifecycle`/`ci.run.completed`/`work.backlog.sampled` were unqueryable by ticket or team.
77
+ *
78
+ * Every field is optional and every ABSENT field means "not applicable to this event", never a
79
+ * default. A platform-level event (fleet-wide polling, a webhook that never resolved to a tenant work
80
+ * unit) leaves them unset rather than inventing a repo — a fabricated dimension is worse than a
81
+ * missing one, because it is indistinguishable from a real measurement downstream.
82
+ *
83
+ * ⚠️ `ticket` and `phase` are deliberately in this set even though they are HIGH-cardinality relative
84
+ * to `repo`/`team`. That is safe HERE and only here: AE columns and Iceberg columns are not label
85
+ * sets, so a per-ticket value costs a column value, not a new time series. The same two identifiers
86
+ * must stay BODY-level on the OTLP path (Loki promotes resource attributes to labels — see
87
+ * docs/observability.md), which is why THEY live on this sink envelope and not in
88
+ * `@catalyst-cloud/observability`'s `Dimensions` (ADR-0011), which is the label-safe vocabulary.
89
+ * `repo`, `team` and `project_id` belong to BOTH — they are low-cardinality, so they are canonical
90
+ * dimensions over there (`team` added by CTC-1417, once OTL-96 made the collector index it) and
91
+ * columns here. `ticket` and `phase` are the two that are columns here ONLY.
92
+ */
93
+ export interface TelemetryDimensions {
94
+ /** `owner/name` — the same spelling `normalize/github.ts` derives from `repository.full_name`. */
95
+ readonly repo?: string | null;
96
+ /** Linear team key (e.g. `CTC`), the dispatch-queue partition. */
97
+ readonly team?: string | null;
98
+ readonly projectId?: string | null;
99
+ /** Ticket identifier (e.g. `CTC-1417`) — set only where a unit of work exists. */
100
+ readonly ticket?: string | null;
101
+ /** Relay phase (e.g. `implement`) — set only where a unit of work exists. */
102
+ readonly phase?: string | null;
103
+ }
104
+
105
+ /**
106
+ * The AE hot leg's positional blob layout, declared once (CTC-1417). A positional wire format has no
107
+ * names on it: inserting or reordering a column silently rewrites the meaning of every row already
108
+ * written, and nothing fails. So this array is APPEND-ONLY, and
109
+ * `packages/schema/test/telemetry/point-dimensions.test.ts` pins it as a drift oracle — a change has
110
+ * to be a deliberate edit to that expectation, never a side effect.
111
+ *
112
+ * Readers index by these positions: `apps/mirror/src/telemetry-sink/sql-proxy.ts` (`blob1`..`blob10`).
113
+ */
114
+ export const TELEMETRY_BLOB_COLUMNS = [
115
+ "service",
116
+ "name",
117
+ "outcome",
118
+ "trace_id",
119
+ "detail",
120
+ "repo",
121
+ "team",
122
+ "project_id",
123
+ "ticket",
124
+ "phase",
125
+ ] as const;
126
+
127
+ /** An absent dimension on the AE leg. AE blobs are positional, so a dimension cannot simply be
128
+ * omitted without shifting its neighbours; `""` is the encoding of "not applicable". None of the
129
+ * five dimensions is ever legitimately the empty string, so this is unambiguous. (The Iceberg leg is
130
+ * named rather than positional and uses a real `null` instead — see `toTelemetryStreamRecord`.) */
131
+ function dimensionBlob(value: string | null | undefined): string {
132
+ if (value == null || value === "") return "";
133
+ return boundToBytes(value, MAX_DIMENSION_BYTES);
134
+ }
135
+
136
+ /** An absent dimension on the named cold leg: a real SQL `null`, distinguishable from a value. */
137
+ function dimensionColumn(value: string | null | undefined): string | null {
138
+ if (value == null || value === "") return null;
139
+ return boundToBytes(value, MAX_DIMENSION_BYTES);
140
+ }
141
+
61
142
  /**
62
143
  * One telemetry point — the shared envelope both the AE hot leg and the Iceberg cold leg derive
63
144
  * their layout from (D3: same vocabulary, different volume).
64
145
  */
65
- export interface TelemetryPoint {
146
+ export interface TelemetryPoint extends TelemetryDimensions {
66
147
  /** Closed-registry name, e.g. "mirror.webhook.received". Low cardinality by construction. */
67
148
  readonly name: TelemetryName;
68
149
  /** service.name — "catalyst-cloud.mirror" today. */
@@ -121,7 +202,21 @@ export function toTelemetryDataPoint(p: TelemetryPoint, accountId: string): Tele
121
202
  }
122
203
  return {
123
204
  indexes: [accountId],
124
- blobs: [p.service, p.name, p.outcome, p.traceId, boundExcerpt(p.detail)],
205
+ blobs: [
206
+ p.service,
207
+ p.name,
208
+ p.outcome,
209
+ p.traceId,
210
+ boundExcerpt(p.detail),
211
+ // CTC-1417 — appended at fixed positions 6-10 (TELEMETRY_BLOB_COLUMNS). Always emitted, even
212
+ // when empty, so the array length is constant and a future append can never land on a position
213
+ // an older reader already gives a different meaning to.
214
+ dimensionBlob(p.repo),
215
+ dimensionBlob(p.team),
216
+ dimensionBlob(p.projectId),
217
+ dimensionBlob(p.ticket),
218
+ dimensionBlob(p.phase),
219
+ ],
125
220
  doubles: p.durationMs == null ? [p.count] : [p.count, p.durationMs],
126
221
  };
127
222
  }
@@ -142,6 +237,14 @@ export interface TelemetryStreamRecord {
142
237
  readonly detail: string | null;
143
238
  readonly duration_ms: number | null;
144
239
  readonly count: number;
240
+ // CTC-1417 — the uniform tenant dimensions, named (the cold leg is not positional). ADR-0048's
241
+ // binding condition 1 holds unchanged: these are IDENTIFIERS, never payload bodies, and the cold
242
+ // leg is permanent, so nothing that could carry tenant content may join them.
243
+ readonly repo: string | null;
244
+ readonly team: string | null;
245
+ readonly project_id: string | null;
246
+ readonly ticket: string | null;
247
+ readonly phase: string | null;
145
248
  readonly [key: string]: unknown;
146
249
  }
147
250
 
@@ -162,21 +265,41 @@ export function toTelemetryStreamRecord(
162
265
  detail: p.detail ? boundExcerpt(p.detail) : null,
163
266
  duration_ms: p.durationMs,
164
267
  count: p.count,
268
+ repo: dimensionColumn(p.repo),
269
+ team: dimensionColumn(p.team),
270
+ project_id: dimensionColumn(p.projectId),
271
+ ticket: dimensionColumn(p.ticket),
272
+ phase: dimensionColumn(p.phase),
165
273
  };
166
274
  }
167
275
 
168
276
  /**
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`.
277
+ * Fold points sharing an identical (name, outcome, dimensions) tuple into a single point carrying
278
+ * the summed count and the FIRST traceId seen for that tuple — the direct answer to ADR-0039's
279
+ * measurement (one fact logged 14,256 times). Order-preserving on first occurrence. Applies
280
+ * identically regardless of sampling class, to BOTH legs (D3/D4) — a `counted` name's "never stored
281
+ * individually" guarantee comes from this stage, not from `applySampling`.
282
+ *
283
+ * ⚠️ CTC-1417 widened the fold key from (name, outcome) to include the five tenant dimensions. It had
284
+ * to: with the dimensions as columns, folding two points that differ only by `team` would emit ONE
285
+ * row whose `team` column names whichever point arrived first and whose `count` is the sum of both —
286
+ * a wrong value, not a missing one. `work-backlog-telemetry.ts` documented this hazard as a
287
+ * caller-side rule ("one emitTelemetry call per point"); the key now enforces it here instead, so a
288
+ * caller that batches distinct dimensions gets correct rows rather than silently merged ones.
174
289
  */
175
290
  export function collapse(points: readonly TelemetryPoint[]): TelemetryPoint[] {
176
291
  const order: string[] = [];
177
292
  const byKey = new Map<string, TelemetryPoint>();
178
293
  for (const p of points) {
179
- const key = `${p.name}\u0000${p.outcome}`;
294
+ const key = [
295
+ p.name,
296
+ p.outcome,
297
+ p.repo ?? "",
298
+ p.team ?? "",
299
+ p.projectId ?? "",
300
+ p.ticket ?? "",
301
+ p.phase ?? "",
302
+ ].join("\u0000");
180
303
  const existing = byKey.get(key);
181
304
  if (existing) {
182
305
  byKey.set(key, { ...existing, count: existing.count + p.count });