@substrat-run/control-plane-api 0.125.0 → 0.126.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.
Files changed (39) hide show
  1. package/dist/aggregate-reads.d.ts +67 -0
  2. package/dist/aggregate-reads.d.ts.map +1 -0
  3. package/dist/aggregate-reads.js +186 -0
  4. package/dist/aggregate-reads.js.map +1 -0
  5. package/dist/aggregate-source.d.ts +125 -0
  6. package/dist/aggregate-source.d.ts.map +1 -0
  7. package/dist/aggregate-source.js +134 -0
  8. package/dist/aggregate-source.js.map +1 -0
  9. package/dist/api.d.ts.map +1 -1
  10. package/dist/api.js +63 -18
  11. package/dist/api.js.map +1 -1
  12. package/dist/cf-observability.d.ts +7 -0
  13. package/dist/cf-observability.d.ts.map +1 -1
  14. package/dist/cf-observability.js +316 -214
  15. package/dist/cf-observability.js.map +1 -1
  16. package/dist/cube-store-sql.d.ts +34 -0
  17. package/dist/cube-store-sql.d.ts.map +1 -0
  18. package/dist/cube-store-sql.js +74 -0
  19. package/dist/cube-store-sql.js.map +1 -0
  20. package/dist/deploy.d.ts +1 -1
  21. package/dist/deploy.d.ts.map +1 -1
  22. package/dist/deploy.js +1 -1
  23. package/dist/deploy.js.map +1 -1
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/observability.d.ts +16 -0
  29. package/dist/observability.d.ts.map +1 -1
  30. package/dist/observability.js.map +1 -1
  31. package/dist/tenant-request-query.d.ts +21 -0
  32. package/dist/tenant-request-query.d.ts.map +1 -1
  33. package/dist/tenant-request-query.js +27 -0
  34. package/dist/tenant-request-query.js.map +1 -1
  35. package/dist/vertical-client.d.ts +6 -1
  36. package/dist/vertical-client.d.ts.map +1 -1
  37. package/dist/vertical-client.js +17 -3
  38. package/dist/vertical-client.js.map +1 -1
  39. package/package.json +7 -7
@@ -1,3 +1,4 @@
1
+ import { type CubeStore } from './aggregate-source.js';
1
2
  import type { ObservabilityReader } from './observability.js';
2
3
  /**
3
4
  * The Cloudflare implementation of the observability seam (`observability.ts`) —
@@ -10,6 +11,12 @@ import type { ObservabilityReader } from './observability.js';
10
11
  */
11
12
  export interface CfObservabilityOptions {
12
13
  accountId: string;
14
+ /**
15
+ * #1877: where closed blocks of counts are kept. With one, every closed block of an
16
+ * app's script is counted once and read from here after; without one (a test, a dev
17
+ * control plane), every read counts its span live.
18
+ */
19
+ cubeStore?: CubeStore;
13
20
  /**
14
21
  * A Cloudflare API token with Account Analytics read (the GraphQL invocations
15
22
  * dataset) and Workers Observability read (the telemetry query API). Deliberately
@@ -1 +1 @@
1
- {"version":3,"file":"cf-observability.d.ts","sourceRoot":"","sources":["../src/cf-observability.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,mBAAmB,EAapB,MAAM,oBAAoB,CAAC;AAgS5B;;;;;;;;GAQG;AACH,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;;;OAgBG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAgED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,sBAAsB,GAAG,mBAAmB,CAkpC7F"}
1
+ {"version":3,"file":"cf-observability.d.ts","sourceRoot":"","sources":["../src/cf-observability.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsD,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE3G,OAAO,KAAK,EACV,mBAAmB,EASpB,MAAM,oBAAoB,CAAC;AAqa5B;;;;;;;;GAQG;AACH,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;;;OAgBG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAgED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,sBAAsB,GAAG,mBAAmB,CAqnC7F"}
@@ -1,5 +1,8 @@
1
1
  import { resolveObservabilityWindow, observabilityBucketMinutes } from './observability-window.js';
2
- import { TENANT_METRICS_LIMIT, REQUEST_FACET_KEYS, REQUEST_FACET_TOP, LOG_PATTERN_TOP } from './observability.js';
2
+ import { ControlPlaneError } from './client.js';
3
+ import { cachedSource } from './aggregate-source.js';
4
+ import { aggregateReads } from './aggregate-reads.js';
5
+ import { TENANT_METRICS_LIMIT, REQUEST_FACET_KEYS } from './observability.js';
3
6
  /**
4
7
  * The filters for a tenant's stamped invocation lines (#1746), with the facet filters ANDed
5
8
  * on. `omit` leaves one facet's own filter out, which is how a facet is counted "with every
@@ -13,6 +16,7 @@ function requestFilters(scope, omit) {
13
16
  const filters = [
14
17
  { key: 'tenantId', operation: 'eq', type: 'string', value: scope.tenantId },
15
18
  { key: 'substrat', operation: 'eq', type: 'string', value: 'invocation' },
19
+ ...serviceFilter(scope.services),
16
20
  ];
17
21
  if (scope.scopeId)
18
22
  filters.push({ key: 'scopeId', operation: 'eq', type: 'string', value: scope.scopeId });
@@ -31,6 +35,72 @@ function requestFilters(scope, omit) {
31
35
  }
32
36
  return filters;
33
37
  }
38
+ /** A lowercased ULID, as it ends a per-version script name (`deploymentRefFor`). */
39
+ const SCRIPT_ULID = '[0-9a-hjkmnp-tv-z]{26}';
40
+ /**
41
+ * #1877: one vertical's script FAMILY, as a filter on `$metadata.service` — the field
42
+ * Workers Logs indexes every event by. The stem itself is the serving script; `<stem>-<ulid>`
43
+ * is a per-version script (previews, legacy scopes); `<stem>-eu` / `-us` a jurisdictional one
44
+ * (K-30).
45
+ *
46
+ * Two leaves, both required: `starts_with` is the one the store can narrow a scan on, and the
47
+ * anchored regex is what makes it exact — another vertical whose stem merely begins with this
48
+ * one (`ticket0` and `ticket0-crm`) is not in the family.
49
+ */
50
+ function familyFilter(stem) {
51
+ const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ return {
53
+ kind: 'group',
54
+ filterCombination: 'and',
55
+ filters: [
56
+ { key: '$metadata.service', operation: 'starts_with', type: 'string', value: stem },
57
+ { key: '$metadata.service', operation: 'regex', type: 'string', value: `^${escaped}(-${SCRIPT_ULID})?(-(eu|us))?$` },
58
+ ],
59
+ };
60
+ }
61
+ /**
62
+ * #1877: the app's script families, as a filter — the scan boundary (without it a tenant
63
+ * read searched every script in the account for a field inside our own JSON) and a trust
64
+ * boundary (a line counts only if one of the vertical's own scripts wrote it).
65
+ *
66
+ * Absent means the caller did not resolve them, and the read falls back to the tenant filter
67
+ * alone. An EMPTY list is different — it names no script, so it matches no line — and is
68
+ * spelled as a filter that cannot match rather than as no filter at all.
69
+ */
70
+ function serviceFilter(services) {
71
+ if (services === undefined)
72
+ return [];
73
+ if (services.length === 0)
74
+ return [{ key: '$metadata.service', operation: 'eq', type: 'string', value: '\u0000none' }];
75
+ const families = services.map(familyFilter);
76
+ return families.length === 1 ? families : [{ kind: 'group', filterCombination: 'or', filters: families }];
77
+ }
78
+ /**
79
+ * A telemetry answer that is not a success, as the caller should hear it (#1877).
80
+ *
81
+ * Read as TEXT first: a gateway timeout arrives as `error code: 504`, not JSON, and
82
+ * parsing it first turned a timeout into a JSON syntax error behind a blanket 500. A
83
+ * timeout is a 504 and a throttle a 503, each saying what happened, so the dashboard
84
+ * can tell "the log store is slow" from "this is broken".
85
+ */
86
+ function telemetryFailure(status, body) {
87
+ let detail = body.trim().slice(0, 200);
88
+ try {
89
+ const json = JSON.parse(body);
90
+ const messages = json.errors?.map((e) => e.message).filter(Boolean).join('; ');
91
+ if (messages)
92
+ detail = messages;
93
+ }
94
+ catch {
95
+ // Not JSON — the gateway's own page. The status says enough.
96
+ }
97
+ if (status === 504 || status === 524 || status === 408) {
98
+ return new ControlPlaneError(504, 'the log store timed out answering this query — try a shorter window');
99
+ }
100
+ if (status === 429)
101
+ return new ControlPlaneError(503, 'the log store is busy — try again shortly');
102
+ return new ControlPlaneError(502, `the log store refused the query (HTTP ${status})${detail ? `: ${detail}` : ''}`);
103
+ }
34
104
  /**
35
105
  * How many requests a point stands for.
36
106
  *
@@ -45,27 +115,6 @@ function countOf(p) {
45
115
  function sampled(p) {
46
116
  return typeof p.sampleInterval === 'number' && p.sampleInterval > 1;
47
117
  }
48
- /** The value of one group key on a point, or `undefined` when the point has none. */
49
- function groupValue(p, key) {
50
- return p.groups?.find((g) => g.key === key)?.value;
51
- }
52
- /** A stamped line's `level`, or `unrecorded` for a line written before it existed. */
53
- function levelOf(v) {
54
- return v === 'info' || v === 'warn' || v === 'error' ? v : 'unrecorded';
55
- }
56
- /**
57
- * The bucket width a series was cut at, from its own timestamps: the smallest gap between
58
- * two consecutive buckets. The request asks for a bucket COUNT, and the backend may round
59
- * the width it derives from it, so the answer is read back rather than assumed. A series
60
- * with fewer than two buckets says nothing about its width; the nominal one stands in.
61
- */
62
- function bucketWidth(times, nominal) {
63
- const sorted = [...new Set(times)].sort((a, b) => a - b);
64
- let width = Infinity;
65
- for (let i = 1; i < sorted.length; i++)
66
- width = Math.min(width, sorted[i] - sorted[i - 1]);
67
- return Number.isFinite(width) && width > 0 ? width : nominal;
68
- }
69
118
  /** The stamped line of a raw event, as the Requests view lists it. */
70
119
  function requestRecordOf(e) {
71
120
  const source = (e['source'] ?? {});
@@ -105,6 +154,16 @@ function requestRecordOf(e) {
105
154
  */
106
155
  const MAX_CORRELATED_INVOCATIONS = 40;
107
156
  const MAX_LINES_PER_INVOCATION = 20;
157
+ /**
158
+ * #1877: the groups one telemetry answer carries, and how many pages of them a span may
159
+ * take. Past the cap a span is refused rather than cached in part.
160
+ */
161
+ const GROUP_PAGE = 2000;
162
+ const GROUP_PAGE_CAP = 20;
163
+ /** How many times a full span is halved before a single grain is paged instead. */
164
+ const SPLIT_DEPTH_CAP = 6;
165
+ /** A value as text, or null. */
166
+ const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
108
167
  /**
109
168
  * A 501 is an honest refusal, not a failure: the route exists to say "this version does
110
169
  * not declare that capability" (an undeclared owner-seat or configure hook, #1345). The
@@ -221,6 +280,78 @@ function describeModuleLine(e) {
221
280
  trigger: str(source['operation']) ?? e.trigger,
222
281
  };
223
282
  }
283
+ /**
284
+ * Is this the RUNTIME's own record of an invocation — the event Cloudflare writes for every
285
+ * call to a Worker (request, response status, outcome, CPU and wall time) — rather than a
286
+ * line the Worker's code wrote?
287
+ *
288
+ * Identified by the event's own type, and by nothing looser. The fold below HIDES these,
289
+ * so a false positive would hide a line a vertical wrote on purpose, while a false
290
+ * negative only leaves a duplicate row. A predicate built from shape — "has a request and
291
+ * no JSON source" — could match a vertical's plain `console.log`, so it is not used.
292
+ */
293
+ function isRuntimeRecord(e) {
294
+ const metadata = (e['$metadata'] ?? {});
295
+ const source = (e['source'] ?? {});
296
+ return metadata['type'] === 'cf-worker-event' && source['substrat'] === undefined;
297
+ }
298
+ /**
299
+ * Fold the runtime's invocation records into the stamped lines of the same requests.
300
+ *
301
+ * Every request a vertical served writes two events that describe it: the runtime's
302
+ * record and the stamped invocation line (`invocation-log.ts`). The stamped line says
303
+ * everything the runtime's does — method, path, status — and the tenant, operation,
304
+ * problem code and what it emitted besides, so the list showed each request twice. Only
305
+ * the runtime knows the CPU and wall time, so those move onto the stamped row.
306
+ *
307
+ * The runtime's `outcome` moves too — `exception`, `exceededCpu` — so a request the runtime
308
+ * cut short still reads as cut short on its one row.
309
+ *
310
+ * A runtime record whose request has no stamped line on the page is kept, defensively. In
311
+ * the tenant read that cannot currently happen: a request reaches this list only through
312
+ * its stamped line, and one that left none (it crashed before the middleware wrote it)
313
+ * names no tenant and was never attributable to anyone's view in the first place.
314
+ */
315
+ function foldRuntimeRecords(events) {
316
+ const requestOf = (e) => {
317
+ const metadata = (e['$metadata'] ?? {});
318
+ const workers = (e['$workers'] ?? {});
319
+ const id = metadata['requestId'] ?? workers['requestId'];
320
+ return typeof id === 'string' && id !== '' ? id : null;
321
+ };
322
+ const stamped = new Set(events
323
+ .filter((e) => (e['source'] ?? {})['substrat'] === 'invocation')
324
+ .map(requestOf)
325
+ .filter((id) => id !== null));
326
+ const kept = [];
327
+ const byRequest = new Map();
328
+ for (const e of events) {
329
+ const request = requestOf(e);
330
+ if (request !== null && stamped.has(request) && isRuntimeRecord(e)) {
331
+ byRequest.set(request, e);
332
+ continue;
333
+ }
334
+ kept.push(e);
335
+ }
336
+ return { kept, byRequest };
337
+ }
338
+ /** A stamped row, carrying the CPU and wall time of the runtime record folded into it. */
339
+ function withRuntimeTimings(e, byRequest) {
340
+ const source = (e.raw?.['source'] ?? {});
341
+ if (source['substrat'] !== 'invocation' || e.requestId === null)
342
+ return e;
343
+ const runtime = byRequest.get(e.requestId);
344
+ if (!runtime)
345
+ return e;
346
+ const workers = (runtime['$workers'] ?? {});
347
+ const num = (v) => (typeof v === 'number' ? v : null);
348
+ return {
349
+ ...e,
350
+ cpuTimeMs: num(workers['cpuTimeMs']) ?? e.cpuTimeMs,
351
+ wallTimeMs: num(workers['wallTimeMs']) ?? e.wallTimeMs,
352
+ outcome: typeof workers['outcome'] === 'string' ? workers['outcome'] : e.outcome,
353
+ };
354
+ }
224
355
  /**
225
356
  * One backend event → the seam's neutral `RecentLogEvent`.
226
357
  *
@@ -318,6 +449,138 @@ export function createCfObservabilityReader(opts) {
318
449
  },
319
450
  body: JSON.stringify(body),
320
451
  });
452
+ /**
453
+ * #1877: the telemetry half of the aggregate seam — ONE script's lines over one span,
454
+ * grouped by tenant and every facet, cut into the grain. Every tenant the script served
455
+ * is in the answer, which is what lets the cache fill one block for all of them.
456
+ */
457
+ const telemetry = {
458
+ async requests(q) {
459
+ const { rows, estimated, complete } = await countGrouped(q, 'invocation', [
460
+ { type: 'string', value: 'tenantId' },
461
+ { type: 'string', value: 'scopeId' },
462
+ { type: 'string', value: 'level' },
463
+ { type: 'string', value: 'operation' },
464
+ { type: 'string', value: 'problemCode' },
465
+ { type: 'string', value: 'principalKind' },
466
+ { type: 'string', value: 'surface' },
467
+ { type: 'number', value: 'status' },
468
+ ]);
469
+ return {
470
+ estimated,
471
+ complete,
472
+ rows: rows.flatMap(({ bucket, groups, count }) => {
473
+ const tenantId = str(groups.get('tenantId'));
474
+ if (tenantId === null)
475
+ return [];
476
+ const status = groups.get('status');
477
+ return [{
478
+ bucket,
479
+ tenantId,
480
+ scopeId: str(groups.get('scopeId')),
481
+ level: str(groups.get('level')),
482
+ operation: str(groups.get('operation')),
483
+ problemCode: str(groups.get('problemCode')),
484
+ principalKind: str(groups.get('principalKind')),
485
+ surface: str(groups.get('surface')),
486
+ status: typeof status === 'number' ? status : typeof status === 'string' && status !== '' ? Number(status) : null,
487
+ count,
488
+ }];
489
+ }),
490
+ };
491
+ },
492
+ async patterns(q) {
493
+ const { rows, estimated, complete } = await countGrouped(q, 'log', [
494
+ { type: 'string', value: 'tenantId' },
495
+ { type: 'string', value: 'scopeId' },
496
+ { type: 'string', value: 'template' },
497
+ { type: 'string', value: 'level' },
498
+ { type: 'string', value: 'operation' },
499
+ ]);
500
+ return {
501
+ estimated,
502
+ complete,
503
+ rows: rows.flatMap(({ bucket, groups, count }) => {
504
+ const tenantId = str(groups.get('tenantId'));
505
+ const template = str(groups.get('template'));
506
+ if (tenantId === null || template === null || template === '')
507
+ return [];
508
+ return [{ bucket, tenantId, scopeId: str(groups.get('scopeId')), template, level: str(groups.get('level')), operation: str(groups.get('operation')), count }];
509
+ }),
510
+ };
511
+ },
512
+ };
513
+ const source = opts.cubeStore ? cachedSource(telemetry, opts.cubeStore) : telemetry;
514
+ /**
515
+ * One script family's lines of one kind over a span, counted per grain bucket and grouped
516
+ * by `groupBys`.
517
+ *
518
+ * When one answer is full — as many groups as it can carry — the span is SPLIT in two at a
519
+ * grain boundary and each half counted on its own, rather than paged. Paging with
520
+ * `offsetBy` depends on an order the telemetry API does not promise to hold between
521
+ * requests, so pages could overlap or skip groups; a narrower span is a smaller, complete
522
+ * answer with no order to trust. Only a single grain that is still full is paged, and a
523
+ * cube built that way is marked incomplete so the cache never keeps it as final.
524
+ */
525
+ async function countGrouped(q, kind, groupBys) {
526
+ const filters = [
527
+ ...serviceFilter([q.service]),
528
+ { key: 'substrat', operation: 'eq', type: 'string', value: kind },
529
+ ];
530
+ const once = async (from, to, offsetBy) => {
531
+ const calc = await queryCalculation(filters, { from, to }, {
532
+ groupBys,
533
+ granularity: Math.max(1, Math.round((to - from) / q.grainMs)),
534
+ chartType: 'timeseries',
535
+ limit: GROUP_PAGE,
536
+ ...(offsetBy ? { offsetBy } : {}),
537
+ });
538
+ const rows = [];
539
+ const groupsSeen = new Set();
540
+ let estimated = false;
541
+ for (const point of calc.series ?? []) {
542
+ const at = point.time ? Date.parse(point.time) : NaN;
543
+ if (!Number.isFinite(at))
544
+ continue;
545
+ // Floored to the grain: the backend cuts its buckets from the span's start, which
546
+ // is aligned, so this only absorbs a stray millisecond.
547
+ const bucket = Math.floor(at / q.grainMs) * q.grainMs;
548
+ for (const d of point.data ?? []) {
549
+ const count = countOf(d);
550
+ if (count === 0)
551
+ continue;
552
+ estimated ||= sampled(d);
553
+ const groups = new Map((d.groups ?? []).map((g) => [g.key ?? '', g.value]));
554
+ groupsSeen.add(JSON.stringify([...groups.entries()]));
555
+ rows.push({ bucket, groups, count });
556
+ }
557
+ }
558
+ return { rows, estimated, full: groupsSeen.size >= GROUP_PAGE };
559
+ };
560
+ const span = async (from, to, depth) => {
561
+ const first = await once(from, to);
562
+ if (!first.full)
563
+ return { rows: first.rows, estimated: first.estimated, complete: true };
564
+ const grains = Math.round((to - from) / q.grainMs);
565
+ if (grains > 1 && depth < SPLIT_DEPTH_CAP) {
566
+ const mid = from + Math.floor(grains / 2) * q.grainMs;
567
+ const [a, b] = await Promise.all([span(from, mid, depth + 1), span(mid, to, depth + 1)]);
568
+ return { rows: [...a.rows, ...b.rows], estimated: a.estimated || b.estimated, complete: a.complete && b.complete };
569
+ }
570
+ // One grain and still full: page it, and say the answer cannot be trusted as whole.
571
+ const rows = [...first.rows];
572
+ let estimated = first.estimated;
573
+ for (let page = 1; page < GROUP_PAGE_CAP; page++) {
574
+ const next = await once(from, to, page * GROUP_PAGE);
575
+ rows.push(...next.rows);
576
+ estimated ||= next.estimated;
577
+ if (!next.full)
578
+ break;
579
+ }
580
+ return { rows, estimated, complete: false };
581
+ };
582
+ return span(q.from, q.to, 0);
583
+ }
321
584
  return {
322
585
  absoluteTenantWindows: true,
323
586
  async serviceMetrics({ hours }) {
@@ -451,183 +714,9 @@ export function createCfObservabilityReader(opts) {
451
714
  async tenantLogs(input) {
452
715
  return queryTenantLogs(input);
453
716
  },
454
- // #1746: the request histogram. One `calculations` query, grouped by level and cut
455
- // into the asked number of buckets. Every stamped line in the window is counted, not a
456
- // sample of them, which is what makes zooming a narrower re-query rather than a crop.
457
- async tenantRequestVolume(input) {
458
- const timeframe = { from: input.from, to: input.to };
459
- const calc = await queryCalculation(requestFilters(input), timeframe, {
460
- groupBy: { type: 'string', value: 'level' },
461
- granularity: input.buckets,
462
- chartType: 'timeseries',
463
- // The group-by rows: one per level, plus the lines that carry none.
464
- limit: 10,
465
- });
466
- let estimated = false;
467
- const byStart = new Map();
468
- for (const point of calc.series ?? []) {
469
- const start = point.time ? Date.parse(point.time) : NaN;
470
- if (!Number.isFinite(start))
471
- continue;
472
- const bucket = byStart.get(start) ??
473
- { start: new Date(start).toISOString(), info: 0, warn: 0, error: 0, unrecorded: 0 };
474
- for (const d of point.data ?? []) {
475
- bucket[levelOf(groupValue(d, 'level'))] += countOf(d);
476
- estimated ||= sampled(d);
477
- }
478
- byStart.set(start, bucket);
479
- }
480
- const starts = [...byStart.keys()].sort((a, b) => a - b);
481
- return {
482
- bucketMs: bucketWidth(starts, Math.max(1, Math.round((input.to - input.from) / input.buckets))),
483
- buckets: starts
484
- .map((t) => byStart.get(t))
485
- .filter((b) => b.info + b.warn + b.error + b.unrecorded > 0),
486
- estimated,
487
- };
488
- },
489
- // #1746: facet counts. One aggregate query per facet, each with every OTHER filter
490
- // applied, plus one ungrouped count for the total. In parallel: they are independent
491
- // reads of the same window.
492
- async tenantRequestFacets(input) {
493
- const timeframe = { from: input.from, to: input.to };
494
- const keys = input.keys ?? REQUEST_FACET_KEYS;
495
- let estimated = false;
496
- const [totalCalc, ...perKey] = await Promise.all([
497
- queryCalculation(requestFilters(input), timeframe, { chartType: 'aggregate' }),
498
- ...keys.map((key) => queryCalculation(requestFilters(input, key), timeframe, {
499
- groupBy: { type: key === 'status' ? 'number' : 'string', value: key },
500
- chartType: 'aggregate',
501
- // Two over the top ten, because the backend applies the limit BEFORE the loop
502
- // below drops the rows that are not values: the group of lines with no value
503
- // for this key (every line older than #1746 has none) and an empty string.
504
- // Either can rank in the top ten and would otherwise take a real value's slot.
505
- limit: REQUEST_FACET_TOP + 2,
506
- // Ordered by the calculation's alias, so the `limit` keeps the most frequent values.
507
- orderBy: { value: 'requests', order: 'desc' },
508
- })),
509
- ]);
510
- const total = (totalCalc.aggregates ?? []).reduce((n, p) => {
511
- estimated ||= sampled(p);
512
- return n + countOf(p);
513
- }, 0);
514
- const facets = Object.fromEntries(REQUEST_FACET_KEYS.map((k) => [k, []]));
515
- keys.forEach((key, i) => {
516
- const values = [];
517
- for (const p of perKey[i].aggregates ?? []) {
518
- const value = groupValue(p, key);
519
- // A line with no value for this key (an older line, or a route that is not an
520
- // operation) is not a value anybody can filter on; it is left out of the list,
521
- // and the total above still counts it.
522
- if (typeof value !== 'string' && typeof value !== 'number')
523
- continue;
524
- if (value === '')
525
- continue;
526
- values.push({ value, count: countOf(p) });
527
- estimated ||= sampled(p);
528
- }
529
- facets[key] = values.sort((a, b) => b.count - a.count).slice(0, REQUEST_FACET_TOP);
530
- });
531
- return { total, facets, estimated };
532
- },
533
- // #1747: `ctx.log` lines grouped by template. ONE `calculations` query grouped by
534
- // (template, level) answers all three things a pattern shows — its count, its level
535
- // split and its histogram — plus one ungrouped count for the total, so a pattern's
536
- // share is of every matching line and not only of the templates that made the list.
537
- async tenantLogPatterns(input) {
538
- const timeframe = { from: input.from, to: input.to };
539
- const filters = [
540
- { key: 'tenantId', operation: 'eq', type: 'string', value: input.tenantId },
541
- { key: 'substrat', operation: 'eq', type: 'string', value: 'log' },
542
- ];
543
- if (input.scopeId)
544
- filters.push({ key: 'scopeId', operation: 'eq', type: 'string', value: input.scopeId });
545
- for (const [key, values] of [['level', input.level], ['operation', input.operation]]) {
546
- if (!values || values.length === 0)
547
- continue;
548
- const leaves = values.map((v) => ({ key, operation: 'eq', type: 'string', value: v }));
549
- filters.push(leaves.length === 1 ? leaves[0] : { kind: 'group', filterCombination: 'or', filters: leaves });
550
- }
551
- // A (template, level) row per group: at most four levels per template, so this many
552
- // rows always covers the top templates in full.
553
- const rowLimit = LOG_PATTERN_TOP * 4;
554
- const [totalCalc, grouped] = await Promise.all([
555
- queryCalculation(filters, timeframe, { chartType: 'aggregate' }),
556
- queryCalculation(filters, timeframe, {
557
- groupBys: [
558
- { type: 'string', value: 'template' },
559
- { type: 'string', value: 'level' },
560
- ],
561
- granularity: input.buckets,
562
- chartType: 'timeseries_and_aggregate',
563
- limit: rowLimit,
564
- orderBy: { value: 'requests', order: 'desc' },
565
- }),
566
- ]);
567
- let estimated = false;
568
- const total = (totalCalc.aggregates ?? []).reduce((n, p) => {
569
- estimated ||= sampled(p);
570
- return n + countOf(p);
571
- }, 0);
572
- const levelOfLine = (v) => v === 'debug' || v === 'warn' || v === 'error' ? v : 'info';
573
- const byTemplate = new Map();
574
- const entry = (template) => {
575
- let e = byTemplate.get(template);
576
- if (!e) {
577
- e = { levels: { debug: 0, info: 0, warn: 0, error: 0 }, buckets: new Map() };
578
- byTemplate.set(template, e);
579
- }
580
- return e;
581
- };
582
- const aggregates = grouped.aggregates ?? [];
583
- for (const p of aggregates) {
584
- const template = groupValue(p, 'template');
585
- // A line with no template is not a `ctx.log` line; it cannot be one pattern.
586
- if (typeof template !== 'string' || template === '')
587
- continue;
588
- entry(template).levels[levelOfLine(groupValue(p, 'level'))] += countOf(p);
589
- estimated ||= sampled(p);
590
- }
591
- const times = [];
592
- for (const point of grouped.series ?? []) {
593
- const start = point.time ? Date.parse(point.time) : NaN;
594
- if (!Number.isFinite(start))
595
- continue;
596
- times.push(start);
597
- for (const d of point.data ?? []) {
598
- const template = groupValue(d, 'template');
599
- if (typeof template !== 'string' || !byTemplate.has(template))
600
- continue;
601
- const b = byTemplate.get(template).buckets;
602
- b.set(start, (b.get(start) ?? 0) + countOf(d));
603
- }
604
- }
605
- const patterns = [...byTemplate.entries()]
606
- .map(([template, e]) => {
607
- const count = e.levels.debug + e.levels.info + e.levels.warn + e.levels.error;
608
- const dominant = ['error', 'warn', 'info', 'debug'].reduce((best, l) => e.levels[l] > e.levels[best] ? l : best);
609
- return {
610
- template,
611
- count,
612
- share: total > 0 ? count / total : 0,
613
- levels: e.levels,
614
- dominant,
615
- buckets: [...e.buckets.entries()]
616
- .sort(([a], [b]) => a - b)
617
- .map(([t, n]) => ({ start: new Date(t).toISOString(), count: n })),
618
- };
619
- })
620
- .sort((a, b) => b.count - a.count || a.template.localeCompare(b.template))
621
- .slice(0, LOG_PATTERN_TOP);
622
- return {
623
- total,
624
- bucketMs: bucketWidth(times, Math.max(1, Math.round((input.to - input.from) / input.buckets))),
625
- patterns,
626
- // The backend stopped at the row limit, or there were more templates than listed.
627
- truncated: aggregates.length >= rowLimit || byTemplate.size > LOG_PATTERN_TOP,
628
- estimated,
629
- };
630
- },
717
+ // #1746/#1877: the request histogram, facet counts and log patterns — computed once,
718
+ // from cubes, by `aggregateReads` over the (cached) telemetry source.
719
+ ...aggregateReads(source),
631
720
  // #1746: the requests themselves. The stamped lines alone — no correlation walk,
632
721
  // because a request's own line is the whole record and the Requests view has no use
633
722
  // for its siblings until a row is opened (which `tenantLogs` with the invocation id
@@ -989,6 +1078,8 @@ export function createCfObservabilityReader(opts) {
989
1078
  const base = [
990
1079
  { key: 'tenantId', operation: 'eq', type: 'string', value: input.tenantId },
991
1080
  { key: 'substrat', operation: 'eq', type: 'string', value: 'invocation' },
1081
+ // #1877: the app's own scripts — the scan boundary and the trust boundary.
1082
+ ...serviceFilter(input.services),
992
1083
  ];
993
1084
  if (input.scopeId)
994
1085
  base.push({ key: 'scopeId', operation: 'eq', type: 'string', value: input.scopeId });
@@ -1052,7 +1143,9 @@ export function createCfObservabilityReader(opts) {
1052
1143
  // because these lines carry both as their own fields. They carry no `vertical`, so a
1053
1144
  // read narrowed by vertical alone (no scope) leaves them out rather than showing a
1054
1145
  // line from another of the tenant's apps.
1055
- const moduleLines = input.vertical && !input.scopeId ? Promise.resolve([]) : queryRaw(moduleLineFilters(input), timeframe, input.limit);
1146
+ const moduleLines = input.vertical && !input.scopeId
1147
+ ? Promise.resolve([])
1148
+ : queryRaw([...moduleLineFilters(input), ...serviceFilter(input.services)], timeframe, input.limit);
1056
1149
  // A template names `ctx.log` lines only: an invocation line has none.
1057
1150
  if (input.template !== undefined) {
1058
1151
  return (await moduleLines)
@@ -1070,7 +1163,10 @@ export function createCfObservabilityReader(opts) {
1070
1163
  // in this order, and a busy `= 500` page must not crowd out a newer 502 or
1071
1164
  // escape just because its query was listed first.
1072
1165
  ]).then((pages) => pages.flat().sort((a, b) => rawTime(b) - rawTime(a))),
1073
- queryRaw([{ key: '$metadata.level', operation: 'eq', type: 'string', value: 'error' }], timeframe, phaseOneLimit),
1166
+ queryRaw(
1167
+ // #1877: scoped to the app's scripts too, where known — it was account-wide,
1168
+ // and `ownsInvocation` below still admits nothing without a stamped line.
1169
+ [{ key: '$metadata.level', operation: 'eq', type: 'string', value: 'error' }, ...serviceFilter(input.services)], timeframe, phaseOneLimit),
1074
1170
  ])
1075
1171
  : [await queryRaw(base, timeframe, phaseOneLimit), []];
1076
1172
  const trusted = invocationIds(stamped);
@@ -1090,7 +1186,7 @@ export function createCfObservabilityReader(opts) {
1090
1186
  // `limit` (the route bounds that at 200).
1091
1187
  const linesPerInvocation = input.invocationId !== undefined ? Math.min(input.limit, 200) : MAX_LINES_PER_INVOCATION;
1092
1188
  const sibling = await Promise.all(requestIds.map(async (id) => {
1093
- const events = await queryRaw([{ key: '$metadata.requestId', operation: 'eq', type: 'string', value: id }], timeframe, linesPerInvocation);
1189
+ const events = await queryRaw([{ key: '$metadata.requestId', operation: 'eq', type: 'string', value: id }, ...serviceFilter(input.services)], timeframe, linesPerInvocation);
1094
1190
  // An account-wide candidate earns its place only by producing this tenant's
1095
1191
  // stamped line. No stamped line, or somebody else's, and the whole invocation is
1096
1192
  // dropped — a line shown to the wrong tenant is exactly the leak the tenant grain
@@ -1114,10 +1210,13 @@ export function createCfObservabilityReader(opts) {
1114
1210
  const id = typeof metadata['id'] === 'string' ? metadata['id'] : JSON.stringify(e);
1115
1211
  byId.set(id, e);
1116
1212
  }
1213
+ // ONE row per request: the runtime's own invocation record is folded into the stamped
1214
+ // line that describes the same request, which says everything it does and more.
1215
+ const runtimeOf = foldRuntimeRecords([...byId.values()]);
1117
1216
  const level = input.level?.toLowerCase();
1118
1217
  const search = input.search;
1119
- return [...byId.values()]
1120
- .map((e) => projectEvent(e))
1218
+ return runtimeOf.kept
1219
+ .map((e) => withRuntimeTimings(projectEvent(e), runtimeOf.byRequest))
1121
1220
  .map((e) => describeModuleLine(describeInvocation(e)))
1122
1221
  // A plain comparison is enough only because `describeInvocation` ran first: a stamped
1123
1222
  // line has no level of its own (Cloudflare sets `$metadata.level` for a string log
@@ -1161,6 +1260,7 @@ export function createCfObservabilityReader(opts) {
1161
1260
  timeframe,
1162
1261
  chartType: shape.chartType,
1163
1262
  ...(shape.granularity === undefined ? {} : { granularity: shape.granularity }),
1263
+ ...(shape.offsetBy === undefined ? {} : { offsetBy: shape.offsetBy }),
1164
1264
  parameters: {
1165
1265
  datasets: ['cloudflare-workers'],
1166
1266
  filters,
@@ -1170,11 +1270,12 @@ export function createCfObservabilityReader(opts) {
1170
1270
  ...(shape.orderBy ? { orderBy: shape.orderBy } : {}),
1171
1271
  },
1172
1272
  });
1173
- const json = (await res.json());
1174
- if (!res.ok || json.success === false) {
1175
- const message = json.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`;
1176
- throw new Error(`Cloudflare telemetry query failed: ${message}`);
1177
- }
1273
+ const text = await res.text();
1274
+ if (!res.ok)
1275
+ throw telemetryFailure(res.status, text);
1276
+ const json = JSON.parse(text);
1277
+ if (json.success === false)
1278
+ throw telemetryFailure(502, text);
1178
1279
  // One calculation was asked for, so the first answer is it. An answer with none is an
1179
1280
  // empty window, not an error: the backend omits a calculation that matched nothing.
1180
1281
  return json.result?.calculations?.[0] ?? {};
@@ -1196,11 +1297,12 @@ export function createCfObservabilityReader(opts) {
1196
1297
  parameters: { datasets: ['cloudflare-workers'], filters, limit },
1197
1298
  limit,
1198
1299
  });
1199
- const json = (await res.json());
1200
- if (!res.ok || json.success === false) {
1201
- const message = json.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`;
1202
- throw new Error(`Cloudflare telemetry query failed: ${message}`);
1203
- }
1300
+ const text = await res.text();
1301
+ if (!res.ok)
1302
+ throw telemetryFailure(res.status, text);
1303
+ const json = JSON.parse(text);
1304
+ if (json.success === false)
1305
+ throw telemetryFailure(502, text);
1204
1306
  const outer = json.result?.events;
1205
1307
  return (Array.isArray(outer) ? outer : (outer?.events ?? []));
1206
1308
  }