@substrat-run/control-plane-api 0.124.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.
- package/dist/aggregate-reads.d.ts +67 -0
- package/dist/aggregate-reads.d.ts.map +1 -0
- package/dist/aggregate-reads.js +186 -0
- package/dist/aggregate-reads.js.map +1 -0
- package/dist/aggregate-source.d.ts +125 -0
- package/dist/aggregate-source.d.ts.map +1 -0
- package/dist/aggregate-source.js +134 -0
- package/dist/aggregate-source.js.map +1 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +63 -18
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts +7 -0
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +316 -214
- package/dist/cf-observability.js.map +1 -1
- package/dist/cube-store-sql.d.ts +34 -0
- package/dist/cube-store-sql.d.ts.map +1 -0
- package/dist/cube-store-sql.js +74 -0
- package/dist/cube-store-sql.js.map +1 -0
- package/dist/deploy.d.ts +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +16 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js.map +1 -1
- package/dist/tenant-request-query.d.ts +21 -0
- package/dist/tenant-request-query.d.ts.map +1 -1
- package/dist/tenant-request-query.js +27 -0
- package/dist/tenant-request-query.js.map +1 -1
- package/dist/vertical-client.d.ts +6 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +17 -3
- package/dist/vertical-client.js.map +1 -1
- 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":"
|
|
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"}
|
package/dist/cf-observability.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { resolveObservabilityWindow, observabilityBucketMinutes } from './observability-window.js';
|
|
2
|
-
import {
|
|
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
|
|
455
|
-
//
|
|
456
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
1174
|
-
if (!res.ok
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
|
1200
|
-
if (!res.ok
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
}
|