@substrat-run/control-plane-api 0.158.0 → 0.160.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/api.d.ts +25 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +570 -55
- package/dist/api.js.map +1 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -1
- package/dist/browser.js.map +1 -1
- package/dist/cf-observability.d.ts +30 -5
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +277 -92
- package/dist/cf-observability.js.map +1 -1
- package/dist/custom-hostnames.d.ts +30 -0
- package/dist/custom-hostnames.d.ts.map +1 -1
- package/dist/custom-hostnames.js +25 -2
- package/dist/custom-hostnames.js.map +1 -1
- package/dist/deploy.d.ts +3 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +26 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +6 -13
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +103 -40
- package/dist/platform-drain.js.map +1 -1
- package/dist/platform-entry.generated.d.ts +2 -2
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +5 -5
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-wire.d.ts +51 -0
- package/dist/platform-wire.d.ts.map +1 -0
- package/dist/platform-wire.js +163 -0
- package/dist/platform-wire.js.map +1 -0
- package/dist/scope-copy-backfill.d.ts.map +1 -1
- package/dist/scope-copy-backfill.js +22 -9
- package/dist/scope-copy-backfill.js.map +1 -1
- package/dist/staff-client.d.ts +38 -2
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +24 -1
- package/dist/staff-client.js.map +1 -1
- package/dist/vertical-client.d.ts +59 -3
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +144 -6
- package/dist/vertical-client.js.map +1 -1
- package/dist/wire-upload.d.ts +74 -0
- package/dist/wire-upload.d.ts.map +1 -0
- package/dist/wire-upload.js +114 -0
- package/dist/wire-upload.js.map +1 -0
- package/package.json +10 -10
package/dist/browser.d.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* `test/browser-entry.test.ts` holds that line.
|
|
8
8
|
*/
|
|
9
9
|
export { ControlPlaneStaffClient } from './staff-client.js';
|
|
10
|
-
export type { AdminLogPage, AuditLogQuery, BindHostnameInput, ConnectionHealthQuery, ConnectorCallsBucket, DoNamespace, EgressReport, IssuesQuery, MembersReading, ObservedEgressRow, OpsFailuresQuery, PageQuery, PlatformRuntime, ProvisionScopeInput, RebindScopeResult, RebindReviewResult, RecentLogEvent, ReprovisionedScope, ScopeHealth, ServiceMetricsRow, StaffMember, SweepRunsQuery, SystemSwitchesQuery, TenantStores, } from './staff-client.js';
|
|
11
|
-
export { outboundCallsOf } from './observability.js';
|
|
10
|
+
export type { AdminLogPage, AuditLogQuery, BindHostnameInput, ConnectionHealthQuery, ConnectorCallsBucket, DoNamespace, EgressReport, IssuesQuery, MembersReading, ObservedEgressRow, OpsFailuresQuery, PageQuery, PlatformRuntime, ProvisionScopeInput, RebindScopeResult, RebindReviewResult, RecentLogEvent, RecentLogsRead, ReprovisionedScope, ScopeHealth, ServiceMetricsRow, StaffMember, SweepRunsQuery, SystemSwitchesQuery, TenantStores, } from './staff-client.js';
|
|
11
|
+
export { LOGS_INCOMPLETE_HEADER, logsIncompleteOf, outboundCallsOf } from './observability.js';
|
|
12
12
|
export type { OutboundCall } from './observability.js';
|
|
13
13
|
export * from '@substrat-run/control-plane-client';
|
|
14
14
|
//# sourceMappingURL=browser.d.ts.map
|
package/dist/browser.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,YAAY,EACV,YAAY,EACZ,aAAa,EACb,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,WAAW,EACX,YAAY,EACZ,WAAW,EACX,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,WAAW,EACX,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,mBAAmB,EACnB,YAAY,GACb,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,YAAY,EACV,YAAY,EACZ,aAAa,EACb,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,WAAW,EACX,YAAY,EACZ,WAAW,EACX,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,WAAW,EACX,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,mBAAmB,EACnB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAI3B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/F,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGvD,cAAc,oCAAoC,CAAC"}
|
package/dist/browser.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
export { ControlPlaneStaffClient } from './staff-client.js';
|
|
10
10
|
// #2192: one reading of a request line's outbound calls, for the control plane's request list
|
|
11
11
|
// and the dashboard's slide-over alike. `observability.ts` imports nothing.
|
|
12
|
-
|
|
12
|
+
// #2520: and the header the logs read sets when its page may be incomplete, which a browser client reads.
|
|
13
|
+
export { LOGS_INCOMPLETE_HEADER, logsIncompleteOf, outboundCallsOf } from './observability.js';
|
|
13
14
|
// The client package's whole surface, so a caller of either entry reaches it from here and
|
|
14
15
|
// no existing import moved (#971). `test/reexports.test.ts` pins that nothing is missing.
|
|
15
16
|
export * from '@substrat-run/control-plane-client';
|
package/dist/browser.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.js","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"browser.js","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AA4B5D,8FAA8F;AAC9F,4EAA4E;AAC5E,0GAA0G;AAC1G,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAE/F,2FAA2F;AAC3F,0FAA0F;AAC1F,cAAc,oCAAoC,CAAC"}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { type CubeStore } from './aggregate-source.js';
|
|
2
|
+
import { type RequestDayCount } from '@substrat-run/kernel';
|
|
2
3
|
import type { ObservabilityReader } from './observability.js';
|
|
3
|
-
/**
|
|
4
|
-
* An HTTP method as RFC 9110 §9.1 defines it: a `token` (§5.6.2), one or more `tchar`.
|
|
5
|
-
* Not `[A-Z]+`: the writer records `Request.method` verbatim, and `M-SEARCH` and `X1` are
|
|
6
|
-
* methods too.
|
|
7
|
-
*/
|
|
8
4
|
export declare const HTTP_METHOD_PATTERN = "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$";
|
|
9
5
|
/**
|
|
10
6
|
* The event is a stamped invocation line belonging to the tenant (and the narrowing) that
|
|
@@ -84,4 +80,33 @@ export interface CfObservabilityOptions {
|
|
|
84
80
|
requestsFromRouterSince?: string;
|
|
85
81
|
}
|
|
86
82
|
export declare function createCfObservabilityReader(opts: CfObservabilityOptions): ObservabilityReader;
|
|
83
|
+
/**
|
|
84
|
+
* The most rows one day's rollup query may answer (#2501): one per (tenant, vertical, scope) that
|
|
85
|
+
* served a request that day. A day with more is refused as `RequestRollupSaturated`, never kept as
|
|
86
|
+
* a part of the day standing for the whole. The rollup records such a day as saturated and goes on
|
|
87
|
+
* with the days after it; reading it would need a paged read, which this is not yet.
|
|
88
|
+
*/
|
|
89
|
+
export declare const REQUEST_ROLLUP_ROW_LIMIT = 50000;
|
|
90
|
+
/**
|
|
91
|
+
* The daily request rollup's reader (#2501): one closed UTC day of the router's request
|
|
92
|
+
* datapoints, counted per (tenant, vertical, scope). The platform sweep's request-rollup phase
|
|
93
|
+
* calls it, never a route.
|
|
94
|
+
*
|
|
95
|
+
* It reads EVERY tenant at once, which is why it is not a member of `ObservabilityReader`: that
|
|
96
|
+
* seam answers routes, and keeps a fleet-wide read one forgotten parameter away by having no
|
|
97
|
+
* all-tenants spelling. One query per day for the whole fleet is what keeps the rollup to a
|
|
98
|
+
* handful of queries a pass.
|
|
99
|
+
*
|
|
100
|
+
* Counts are `sum(_sample_interval)`, never `count()`, for `queryTenantMetrics`'s reason: Analytics
|
|
101
|
+
* Engine samples under load, so each count is an estimate and `sampled` says whether any of the
|
|
102
|
+
* day's datapoints stood for more than one request. The ordinals are the router's published shape
|
|
103
|
+
* (`ROUTER_REQUEST_POINT`). Beside the total it counts each status class from `statusClass` and
|
|
104
|
+
* the 429s from `status`, weighted the same way, so the split is kept while the datapoints last.
|
|
105
|
+
*/
|
|
106
|
+
export declare function createRouterRequestDayReader(opts: {
|
|
107
|
+
accountId: string;
|
|
108
|
+
apiToken: string;
|
|
109
|
+
/** The router's dataset, never defaulted, for `CfObservabilityOptions.routerDataset`'s reason. */
|
|
110
|
+
routerDataset: string;
|
|
111
|
+
}): (day: string) => Promise<RequestDayCount[]>;
|
|
87
112
|
//# sourceMappingURL=cf-observability.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cf-observability.d.ts","sourceRoot":"","sources":["../src/cf-observability.ts"],"names":[],"mappings":"AAEA,OAAO,EAAqE,KAAK,SAAS,EAAuB,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"cf-observability.d.ts","sourceRoot":"","sources":["../src/cf-observability.ts"],"names":[],"mappings":"AAEA,OAAO,EAAqE,KAAK,SAAS,EAAuB,MAAM,uBAAuB,CAAC;AAK/I,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEpF,OAAO,KAAK,EACV,mBAAmB,EASpB,MAAM,oBAAoB,CAAC;AAU5B,eAAO,MAAM,mBAAmB,kCAAkC,CAAC;AAqPnE;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1B,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/D,OAAO,CAOT;AA6RD;;;;;;;;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;IAC/B;;;;;;;;;OASG;IACH,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC;AAuED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,sBAAsB,GAAG,mBAAmB,CAy2C7F;AAmBD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,kGAAkG;IAClG,aAAa,EAAE,MAAM,CAAC;CACvB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,EAAE,CAAC,CAgD9C"}
|
package/dist/cf-observability.js
CHANGED
|
@@ -4,7 +4,8 @@ import { cachedSource, cutOverSource } from './aggregate-source.js';
|
|
|
4
4
|
import { stableDeploymentRefFor } from './deploy.js';
|
|
5
5
|
import { aggregateReads } from './aggregate-reads.js';
|
|
6
6
|
import { serviceFamilyPattern } from './service-family.js';
|
|
7
|
-
import { FIELD_COVERAGE_ID_FIELD, ROUTER_SCRIPT_NAMES } from '@substrat-run/contracts';
|
|
7
|
+
import { FIELD_COVERAGE_ID_FIELD, ROUTER_REQUEST_POINT, ROUTER_SCRIPT_NAMES, utcDay } from '@substrat-run/contracts';
|
|
8
|
+
import { RequestRollupSaturated } from '@substrat-run/kernel';
|
|
8
9
|
import { readFieldCoverage } from './field-coverage-read.js';
|
|
9
10
|
import { TENANT_METRICS_LIMIT, REQUEST_FACET_KEYS, INVOCATION_KINDS, invocationKindOf, outboundCallsOf } from './observability.js';
|
|
10
11
|
/**
|
|
@@ -12,6 +13,7 @@ import { TENANT_METRICS_LIMIT, REQUEST_FACET_KEYS, INVOCATION_KINDS, invocationK
|
|
|
12
13
|
* Not `[A-Z]+`: the writer records `Request.method` verbatim, and `M-SEARCH` and `X1` are
|
|
13
14
|
* methods too.
|
|
14
15
|
*/
|
|
16
|
+
import { authChecksOf } from '@substrat-run/contracts/auth-diagnostics';
|
|
15
17
|
export const HTTP_METHOD_PATTERN = "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$";
|
|
16
18
|
/**
|
|
17
19
|
* #1901: a request's line, as a filter. Every request line carries its HTTP method — the
|
|
@@ -173,6 +175,8 @@ function requestRecordOf(e) {
|
|
|
173
175
|
eventId: str(source['eventId']),
|
|
174
176
|
attempt: num(source['attempt']),
|
|
175
177
|
latenessMs: num(source['latenessMs']),
|
|
178
|
+
authChecks: authChecksOf(source['authChecks']),
|
|
179
|
+
authChecksDropped: num(source['authChecksDropped']) ?? 0,
|
|
176
180
|
outboundCalls: outboundCallsOf(source['outboundCalls']),
|
|
177
181
|
outboundCallsDropped: num(source['outboundCallsDropped']) ?? 0,
|
|
178
182
|
};
|
|
@@ -189,6 +193,16 @@ function requestRecordOf(e) {
|
|
|
189
193
|
*/
|
|
190
194
|
const MAX_CORRELATED_INVOCATIONS = 40;
|
|
191
195
|
const MAX_LINES_PER_INVOCATION = 20;
|
|
196
|
+
/**
|
|
197
|
+
* How a service-grain read (`queryEvents`) is bounded while it fills its page after the wire line
|
|
198
|
+
* is left out: at most this many rounds of queries (the first round is one per service), and at
|
|
199
|
+
* most `MAX_FILL_QUERIES` queries past that first round in all, whatever the number of services.
|
|
200
|
+
* A page loses at most about a third to the wire line, so one more query nearly always fills it;
|
|
201
|
+
* the bounds keep a window of almost nothing else, or a read across many services, from costing
|
|
202
|
+
* more than a few.
|
|
203
|
+
*/
|
|
204
|
+
const MAX_FILL_ROUNDS = 4;
|
|
205
|
+
const MAX_FILL_QUERIES = 8;
|
|
192
206
|
/**
|
|
193
207
|
* #1877: the groups one telemetry answer carries, and how many pages of them a span may
|
|
194
208
|
* take. Past the cap a span is refused rather than cached in part.
|
|
@@ -439,6 +453,38 @@ function withoutOutputFields(e) {
|
|
|
439
453
|
const { outputFields: _report, [FIELD_COVERAGE_ID_FIELD]: _id, ...rest } = source;
|
|
440
454
|
return { ...e, source: rest };
|
|
441
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* The events of a telemetry `events` answer, as every reader in this module receives them.
|
|
458
|
+
*
|
|
459
|
+
* The list is unwrapped: the view has nested it one level deeper across API revisions, so
|
|
460
|
+
* both shapes are accepted rather than pinning whichever was current at writing. And the
|
|
461
|
+
* platform's wire-signing line (`substrat: 'wire'`, RFC wire-signing §8) is left out, here
|
|
462
|
+
* and only here, so no reader can return one: it is the platform's counter, it carries a
|
|
463
|
+
* refusal's reason, and it shares a request's id, which is how a tenant's view finds the
|
|
464
|
+
* lines it shows. No reader today needs it; one that does (the readiness report, RFC §10
|
|
465
|
+
* follow-on 4) is to read it through a query of its own that opts in by name.
|
|
466
|
+
*
|
|
467
|
+
* The line is left out AFTER the backend spent its `limit` on it, because no filter of the
|
|
468
|
+
* telemetry API is known to keep the lines that lack the key it negates. So beside the events go
|
|
469
|
+
* the size of what the backend returned (`fetched`) and its oldest timestamp, never a line of it:
|
|
470
|
+
* a reader judges a full page (more may exist) by those, and asks again for what the filter took
|
|
471
|
+
* (`queryEvents`, and the tenant view's per-request read).
|
|
472
|
+
*/
|
|
473
|
+
function telemetryPage(result) {
|
|
474
|
+
const outer = result?.events;
|
|
475
|
+
const all = (Array.isArray(outer) ? outer : (outer?.events ?? []));
|
|
476
|
+
let oldest = null;
|
|
477
|
+
for (const e of all) {
|
|
478
|
+
const t = e['timestamp'];
|
|
479
|
+
if (typeof t === 'number' && (oldest === null || t < oldest))
|
|
480
|
+
oldest = t;
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
events: all.filter((e) => (e['source'] ?? {})['substrat'] !== 'wire'),
|
|
484
|
+
fetched: all.length,
|
|
485
|
+
oldest,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
442
488
|
/**
|
|
443
489
|
* One backend event → the seam's neutral `RecentLogEvent`.
|
|
444
490
|
*
|
|
@@ -695,21 +741,22 @@ export function createCfObservabilityReader(opts) {
|
|
|
695
741
|
const pending = inFlight.get(key);
|
|
696
742
|
if (pending)
|
|
697
743
|
return pending;
|
|
744
|
+
const p = ROUTER_REQUEST_POINT;
|
|
698
745
|
const sql = `
|
|
699
746
|
SELECT
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
747
|
+
${p.vertical} AS vertical,
|
|
748
|
+
${p.scopeId} AS scopeId,
|
|
749
|
+
${p.surface} AS surface,
|
|
750
|
+
${p.status} AS status,
|
|
751
|
+
${p.operation} AS operation,
|
|
752
|
+
${p.problemCode} AS problemCode,
|
|
753
|
+
${p.principalKind} AS principalKind,
|
|
754
|
+
${p.level} AS level,
|
|
708
755
|
toStartOfInterval(timestamp, INTERVAL '${grainSeconds}' SECOND) AS bucket,
|
|
709
756
|
sum(_sample_interval) AS requests,
|
|
710
757
|
max(_sample_interval) AS maxInterval
|
|
711
758
|
FROM ${aeDataset(dataset)}
|
|
712
|
-
WHERE
|
|
759
|
+
WHERE ${p.tenantId} = ${aeLiteral(tenantId)}
|
|
713
760
|
AND timestamp >= toDateTime(${Math.floor(q.from / 1000)})
|
|
714
761
|
AND timestamp < toDateTime(${Math.ceil(q.to / 1000)})
|
|
715
762
|
GROUP BY vertical, scopeId, surface, status, operation, problemCode, principalKind, level, bucket
|
|
@@ -906,14 +953,7 @@ export function createCfObservabilityReader(opts) {
|
|
|
906
953
|
// single-valued equality, and an OR across script names is not a filter this
|
|
907
954
|
// API offers. The queries are independent, so they run concurrently and the
|
|
908
955
|
// merge (not the backend) enforces the overall `limit`.
|
|
909
|
-
|
|
910
|
-
const pages = await Promise.all(services.map((s) => queryEvents(s, level, search, hours, limit)));
|
|
911
|
-
return pages
|
|
912
|
-
.flat()
|
|
913
|
-
.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
|
|
914
|
-
.slice(0, limit);
|
|
915
|
-
}
|
|
916
|
-
return queryEvents(services?.[0], level, search, hours, limit);
|
|
956
|
+
return queryEvents(services && services.length > 1 ? services : [services?.[0]], level, search, hours, limit);
|
|
917
957
|
},
|
|
918
958
|
// Present only when the caller named the router's dataset: with no dataset there is
|
|
919
959
|
// no honest answer, and the route's 501 says so. See `routerDataset`. The bucketed
|
|
@@ -1054,33 +1094,32 @@ export function createCfObservabilityReader(opts) {
|
|
|
1054
1094
|
// would answer two rows whose p95s no caller can combine. Vertical reads null there.
|
|
1055
1095
|
const byScope = input.grain === 'scope';
|
|
1056
1096
|
const window = resolveObservabilityWindow(input);
|
|
1097
|
+
// Blob/double positions are the router's published shape, `ROUTER_REQUEST_POINT`: it only
|
|
1098
|
+
// ever grows, never reorders, which is what lets a reader name the ordinal it wants. The
|
|
1099
|
+
// connector-call dataset's ordinals are published beside these, on
|
|
1100
|
+
// `queryConnectorCallsSeries` below (#1691) — a different dataset, never this one.
|
|
1101
|
+
const p = ROUTER_REQUEST_POINT;
|
|
1057
1102
|
const where = [
|
|
1058
|
-
|
|
1103
|
+
`${p.tenantId} = ${aeLiteral(input.tenantId)}`,
|
|
1059
1104
|
`timestamp >= toDateTime(${Math.ceil(Date.parse(window.since) / 1000)})`,
|
|
1060
1105
|
`timestamp < toDateTime(${Math.ceil(Date.parse(window.until) / 1000)})`,
|
|
1061
1106
|
];
|
|
1062
1107
|
if (input.scopeId)
|
|
1063
|
-
where.push(
|
|
1108
|
+
where.push(`${p.scopeId} = ${aeLiteral(input.scopeId)}`);
|
|
1064
1109
|
if (input.vertical)
|
|
1065
|
-
where.push(
|
|
1066
|
-
// Blob/double positions are the router's published shape (`apps/router/src/worker.ts`
|
|
1067
|
-
// `record`): index1 tenant; blob1 vertical, blob2 scope, blob3 surface, blob4 status
|
|
1068
|
-
// class; double1 duration ms, double2 status. That shape only ever grows, never
|
|
1069
|
-
// reorders — which is what lets these ordinals be written down here at all. The
|
|
1070
|
-
// connector-call dataset's ordinals are published beside these, on
|
|
1071
|
-
// `queryConnectorCallsSeries` below (#1691) — a different dataset, never this one.
|
|
1110
|
+
where.push(`${p.vertical} = ${aeLiteral(input.vertical)}`);
|
|
1072
1111
|
const sql = `
|
|
1073
1112
|
SELECT
|
|
1074
|
-
|
|
1075
|
-
${byScope ? "'' AS vertical" :
|
|
1076
|
-
${byScope ? "'' AS surface" :
|
|
1113
|
+
${p.scopeId} AS scopeId,
|
|
1114
|
+
${byScope ? "'' AS vertical" : `${p.vertical} AS vertical`},
|
|
1115
|
+
${byScope ? "'' AS surface" : `${p.surface} AS surface`},
|
|
1077
1116
|
sum(_sample_interval) AS requests,
|
|
1078
|
-
sum(if(
|
|
1079
|
-
sum(if(
|
|
1080
|
-
sum(if(
|
|
1081
|
-
sum(if(
|
|
1082
|
-
quantileWeighted(0.5)(
|
|
1083
|
-
quantileWeighted(0.95)(
|
|
1117
|
+
sum(if(${p.statusClass} = '5xx', _sample_interval, 0)) AS errors,
|
|
1118
|
+
sum(if(${p.statusClass} = '2xx', _sample_interval, 0)) AS class2xx,
|
|
1119
|
+
sum(if(${p.statusClass} = '3xx', _sample_interval, 0)) AS class3xx,
|
|
1120
|
+
sum(if(${p.statusClass} = '4xx', _sample_interval, 0)) AS class4xx,
|
|
1121
|
+
quantileWeighted(0.5)(${p.durationMs}, _sample_interval) AS durationP50,
|
|
1122
|
+
quantileWeighted(0.95)(${p.durationMs}, _sample_interval) AS durationP95
|
|
1084
1123
|
FROM ${aeDataset(dataset)}
|
|
1085
1124
|
WHERE ${where.join(' AND ')}
|
|
1086
1125
|
GROUP BY ${byScope ? 'scopeId' : 'scopeId, vertical, surface'}
|
|
@@ -1127,22 +1166,23 @@ export function createCfObservabilityReader(opts) {
|
|
|
1127
1166
|
const window = resolveObservabilityWindow(input);
|
|
1128
1167
|
const bucketMinutes = observabilityBucketMinutes(window);
|
|
1129
1168
|
const scopes = input.scopeIds.map((s) => aeLiteral(s)).join(', ');
|
|
1169
|
+
const p = ROUTER_REQUEST_POINT;
|
|
1130
1170
|
const sql = `
|
|
1131
1171
|
SELECT
|
|
1132
|
-
|
|
1172
|
+
${p.scopeId} AS scopeId,
|
|
1133
1173
|
toStartOfInterval(timestamp, INTERVAL '${bucketMinutes}' MINUTE) AS start,
|
|
1134
1174
|
sum(_sample_interval) AS requests,
|
|
1135
|
-
sum(if(
|
|
1136
|
-
sum(if(
|
|
1137
|
-
sum(if(
|
|
1138
|
-
sum(if(
|
|
1139
|
-
quantileWeighted(0.5)(
|
|
1140
|
-
quantileWeighted(0.95)(
|
|
1175
|
+
sum(if(${p.statusClass} = '5xx', _sample_interval, 0)) AS errors,
|
|
1176
|
+
sum(if(${p.statusClass} = '2xx', _sample_interval, 0)) AS class2xx,
|
|
1177
|
+
sum(if(${p.statusClass} = '3xx', _sample_interval, 0)) AS class3xx,
|
|
1178
|
+
sum(if(${p.statusClass} = '4xx', _sample_interval, 0)) AS class4xx,
|
|
1179
|
+
quantileWeighted(0.5)(${p.durationMs}, _sample_interval) AS durationP50,
|
|
1180
|
+
quantileWeighted(0.95)(${p.durationMs}, _sample_interval) AS durationP95
|
|
1141
1181
|
FROM ${aeDataset(dataset)}
|
|
1142
|
-
WHERE
|
|
1182
|
+
WHERE ${p.tenantId} = ${aeLiteral(input.tenantId)}
|
|
1143
1183
|
AND timestamp >= toDateTime(${Math.ceil(Date.parse(window.since) / 1000)})
|
|
1144
1184
|
AND timestamp < toDateTime(${Math.ceil(Date.parse(window.until) / 1000)})
|
|
1145
|
-
AND
|
|
1185
|
+
AND ${p.scopeId} IN (${scopes})
|
|
1146
1186
|
GROUP BY scopeId, start
|
|
1147
1187
|
ORDER BY start ASC
|
|
1148
1188
|
LIMIT ${SERIES_ROW_LIMIT}
|
|
@@ -1283,14 +1323,8 @@ export function createCfObservabilityReader(opts) {
|
|
|
1283
1323
|
});
|
|
1284
1324
|
}
|
|
1285
1325
|
/** One Analytics Engine SQL read, returning the rows as the API shaped them. */
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
const text = await res.text();
|
|
1289
|
-
if (!res.ok) {
|
|
1290
|
-
throw new Error(`Cloudflare Analytics Engine query failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
1291
|
-
}
|
|
1292
|
-
const json = JSON.parse(text);
|
|
1293
|
-
return json.data ?? [];
|
|
1326
|
+
function analyticsEngineSql(sql) {
|
|
1327
|
+
return analyticsEngineQuery(opts, sql);
|
|
1294
1328
|
}
|
|
1295
1329
|
/**
|
|
1296
1330
|
* One tenant's log events, in two phases (§4.3).
|
|
@@ -1438,7 +1472,10 @@ export function createCfObservabilityReader(opts) {
|
|
|
1438
1472
|
// `limit` (the route bounds that at 200).
|
|
1439
1473
|
const linesPerInvocation = input.invocationId !== undefined ? Math.min(input.limit, 200) : MAX_LINES_PER_INVOCATION;
|
|
1440
1474
|
const sibling = await Promise.all(requestIds.map(async (id) => {
|
|
1441
|
-
|
|
1475
|
+
// One more than the budget, then cut to it: a request writes at most one wire line (RFC
|
|
1476
|
+
// wire-signing §8), which `telemetryPage` leaves out after the backend spent a slot on it.
|
|
1477
|
+
// So the page holds exactly the lines it would if that line did not exist.
|
|
1478
|
+
const events = (await queryRaw([{ key: '$metadata.requestId', operation: 'eq', type: 'string', value: id }, ...serviceFilter(input.services)], timeframe, linesPerInvocation + 1)).slice(0, linesPerInvocation);
|
|
1442
1479
|
// An account-wide candidate earns its place only by producing this tenant's
|
|
1443
1480
|
// stamped line. No stamped line, or somebody else's, and the whole invocation is
|
|
1444
1481
|
// dropped — a line shown to the wrong tenant is exactly the leak the tenant grain
|
|
@@ -1542,6 +1579,10 @@ export function createCfObservabilityReader(opts) {
|
|
|
1542
1579
|
// searches the same one, so this takes the instants rather than a duration it would
|
|
1543
1580
|
// have to re-anchor to a `Date.now()` of its own.
|
|
1544
1581
|
timeframe, limit) {
|
|
1582
|
+
return (await queryRawPage(filters, timeframe, limit)).events;
|
|
1583
|
+
}
|
|
1584
|
+
/** {@link queryRaw}, with the size of the page the backend returned (see `telemetryPage`). */
|
|
1585
|
+
async function queryRawPage(filters, timeframe, limit) {
|
|
1545
1586
|
const res = await authed(`https://api.cloudflare.com/client/v4/accounts/${opts.accountId}/workers/observability/telemetry/query`, {
|
|
1546
1587
|
queryId: 'substrat-tenant-logs',
|
|
1547
1588
|
view: 'events',
|
|
@@ -1555,14 +1596,14 @@ export function createCfObservabilityReader(opts) {
|
|
|
1555
1596
|
const json = JSON.parse(text);
|
|
1556
1597
|
if (json.success === false)
|
|
1557
1598
|
throw telemetryFailure(502, text);
|
|
1558
|
-
|
|
1559
|
-
return (Array.isArray(outer) ? outer : (outer?.events ?? []));
|
|
1599
|
+
return telemetryPage(json.result);
|
|
1560
1600
|
}
|
|
1561
1601
|
/** Read a complete sampled span, or refuse it; a truncated page must never mean zero fields. */
|
|
1562
1602
|
async function queryAllFieldEvents(filters, timeframe, depth = 0) {
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1603
|
+
// Full is judged on what the backend returned, not on what is left once a line is filtered out.
|
|
1604
|
+
const page = await queryRawPage(filters, timeframe, 2000);
|
|
1605
|
+
if (page.fetched < 2000)
|
|
1606
|
+
return page.events;
|
|
1566
1607
|
if (depth >= 12 || timeframe.to - timeframe.from <= 1000) {
|
|
1567
1608
|
throw new ControlPlaneError(503, 'field coverage window is too busy — choose a shorter window');
|
|
1568
1609
|
}
|
|
@@ -1573,38 +1614,103 @@ export function createCfObservabilityReader(opts) {
|
|
|
1573
1614
|
]);
|
|
1574
1615
|
return [...left, ...right];
|
|
1575
1616
|
}
|
|
1576
|
-
/**
|
|
1577
|
-
|
|
1617
|
+
/**
|
|
1618
|
+
* The service-grain read: one walk per service, or one over none (the fleet view), merged
|
|
1619
|
+
* newest first and cut to `limit`, and whether it stopped short (`RecentLogs.incomplete`).
|
|
1620
|
+
*/
|
|
1621
|
+
async function queryEvents(services, level, search, hours, limit) {
|
|
1578
1622
|
const to = Date.now();
|
|
1579
|
-
const
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1623
|
+
const from = to - hours * 3_600_000;
|
|
1624
|
+
const filtersFor = (service) => {
|
|
1625
|
+
const filters = [];
|
|
1626
|
+
// `$metadata.service` is the script name in Workers Logs events — the field
|
|
1627
|
+
// Cloudflare's own query examples filter on.
|
|
1628
|
+
if (service) {
|
|
1629
|
+
filters.push({ key: '$metadata.service', operation: 'eq', type: 'string', value: service });
|
|
1630
|
+
}
|
|
1631
|
+
if (level) {
|
|
1632
|
+
filters.push({ key: '$metadata.level', operation: 'eq', type: 'string', value: level });
|
|
1633
|
+
}
|
|
1634
|
+
if (search) {
|
|
1635
|
+
filters.push({ key: '$metadata.message', operation: 'includes', type: 'string', value: search });
|
|
1636
|
+
}
|
|
1637
|
+
return filters;
|
|
1638
|
+
};
|
|
1639
|
+
const page = async (filters, timeframe) => {
|
|
1640
|
+
const res = await authed(`https://api.cloudflare.com/client/v4/accounts/${opts.accountId}/workers/observability/telemetry/query`, {
|
|
1641
|
+
queryId: 'substrat-recent-logs',
|
|
1642
|
+
view: 'events',
|
|
1643
|
+
timeframe,
|
|
1644
|
+
parameters: { datasets: ['cloudflare-workers'], filters, limit },
|
|
1645
|
+
limit,
|
|
1646
|
+
});
|
|
1647
|
+
const json = (await res.json());
|
|
1648
|
+
if (!res.ok || json.success === false) {
|
|
1649
|
+
const message = json.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`;
|
|
1650
|
+
throw new Error(`Cloudflare telemetry query failed: ${message}`);
|
|
1651
|
+
}
|
|
1652
|
+
return telemetryPage(json.result);
|
|
1653
|
+
};
|
|
1654
|
+
// Filled to `limit` when more exist. A full page the wire line was taken out of (in `dual`,
|
|
1655
|
+
// up to a third of one: a request writes its runtime record, its stamp and the wire line) may
|
|
1656
|
+
// be followed by the page before its oldest line: its next window ends just after that oldest
|
|
1657
|
+
// instant, and the event id folds the overlap. A walk is read again only while its next page
|
|
1658
|
+
// could still reach the merged answer, so a read across many services pays for the fill only
|
|
1659
|
+
// where a service's page decides the answer's tail, and never more than `MAX_FILL_QUERIES`.
|
|
1660
|
+
//
|
|
1661
|
+
// A read that stops while a walk could still hold a line for the answer says so (`incomplete`):
|
|
1662
|
+
// a walk the bounds left unread, or a full page all of one instant, which no earlier window
|
|
1663
|
+
// can cut past.
|
|
1664
|
+
const walks = services.map((service) => ({ filters: filtersFor(service), until: to, more: false, stuck: false, oldest: 0 }));
|
|
1665
|
+
const kept = new Map();
|
|
1666
|
+
const read = async (walk) => {
|
|
1667
|
+
const { events, fetched, oldest } = await page(walk.filters, { from, to: walk.until });
|
|
1668
|
+
for (const e of events) {
|
|
1669
|
+
const metadata = (e['$metadata'] ?? {});
|
|
1670
|
+
kept.set(typeof metadata['id'] === 'string' ? metadata['id'] : JSON.stringify(e), e);
|
|
1671
|
+
}
|
|
1672
|
+
// More may exist only behind a full page, and only if an earlier window can be cut: a page
|
|
1673
|
+
// all of one instant cannot be, so the walk stops there, stuck rather than spent.
|
|
1674
|
+
walk.more = fetched >= limit && oldest !== null && oldest + 1 < walk.until;
|
|
1675
|
+
walk.stuck = fetched >= limit && !walk.more;
|
|
1676
|
+
if (oldest !== null)
|
|
1677
|
+
walk.oldest = oldest;
|
|
1678
|
+
if (walk.more)
|
|
1679
|
+
walk.until = oldest + 1;
|
|
1680
|
+
};
|
|
1681
|
+
const answer = () => [...kept.values()].sort((a, b) => rawTime(b) - rawTime(a)).slice(0, limit);
|
|
1682
|
+
// Lines older than this may have a newer one behind a walk the budget left unread.
|
|
1683
|
+
let floor = -Infinity;
|
|
1684
|
+
let incomplete = false;
|
|
1685
|
+
let budget = MAX_FILL_QUERIES;
|
|
1686
|
+
let pending = walks;
|
|
1687
|
+
for (let round = 0; pending.length > 0; round++) {
|
|
1688
|
+
await Promise.all(pending.map(read));
|
|
1689
|
+
const merged = answer();
|
|
1690
|
+
// A walk's unread lines are no newer than its oldest read one, so it can change the answer
|
|
1691
|
+
// only while that line is newer than the answer's last (or the answer is short). Not "as
|
|
1692
|
+
// new": the walk holding the answer's last line could add only lines of that same instant,
|
|
1693
|
+
// whose order against it is arbitrary anyway, and every full page would cost a second query.
|
|
1694
|
+
const cutoff = merged.length < limit ? -Infinity : rawTime(merged[merged.length - 1]);
|
|
1695
|
+
const reaches = (w) => w.oldest > cutoff;
|
|
1696
|
+
const wanted = walks.filter((w) => w.more && reaches(w)).sort((a, b) => b.oldest - a.oldest);
|
|
1697
|
+
const roundsLeft = round + 1 < MAX_FILL_ROUNDS;
|
|
1698
|
+
pending = roundsLeft ? wanted.slice(0, budget) : [];
|
|
1699
|
+
budget -= pending.length;
|
|
1700
|
+
const unread = wanted.slice(pending.length);
|
|
1701
|
+
// Left unread by the budget while others are read on: their older lines would sit beside
|
|
1702
|
+
// lines this walk may still hold, so the answer stops at its oldest and stays a true prefix.
|
|
1703
|
+
// A walk that ran out of rounds is not a floor: it was read as far as every other, and a
|
|
1704
|
+
// floor from it would let one service whose pages are all wire lines empty the answer.
|
|
1705
|
+
if (roundsLeft)
|
|
1706
|
+
for (const w of unread)
|
|
1707
|
+
floor = Math.max(floor, w.oldest);
|
|
1708
|
+
incomplete = unread.length > 0 || walks.some((w) => w.stuck && reaches(w));
|
|
1602
1709
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
return events.map((e) => projectEvent(e));
|
|
1710
|
+
const events = answer()
|
|
1711
|
+
.filter((e) => rawTime(e) >= floor)
|
|
1712
|
+
.map((e) => projectEvent(e));
|
|
1713
|
+
return { events, incomplete };
|
|
1608
1714
|
}
|
|
1609
1715
|
/**
|
|
1610
1716
|
* Outbound `fetch` spans for one service (#859).
|
|
@@ -1648,8 +1754,7 @@ export function createCfObservabilityReader(opts) {
|
|
|
1648
1754
|
const message = json.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`;
|
|
1649
1755
|
throw new Error(`Cloudflare span query failed: ${message}`);
|
|
1650
1756
|
}
|
|
1651
|
-
const
|
|
1652
|
-
const events = (Array.isArray(outer) ? outer : (outer?.events ?? []));
|
|
1757
|
+
const { events, fetched } = telemetryPage(json.result);
|
|
1653
1758
|
const spans = events.map((e) => {
|
|
1654
1759
|
const source = (e['source'] ?? {});
|
|
1655
1760
|
const metadata = (e['$metadata'] ?? {});
|
|
@@ -1679,9 +1784,89 @@ export function createCfObservabilityReader(opts) {
|
|
|
1679
1784
|
});
|
|
1680
1785
|
// Row-cap detection: a full page means there may be more. Reported rather than
|
|
1681
1786
|
// swallowed — an egress report that silently drops hosts is worse than none (#859).
|
|
1682
|
-
return { spans, truncated:
|
|
1787
|
+
return { spans, truncated: fetched >= limit };
|
|
1683
1788
|
}
|
|
1684
1789
|
}
|
|
1790
|
+
/** One Analytics Engine SQL read, returning the rows as the API shaped them. */
|
|
1791
|
+
async function analyticsEngineQuery(opts, sql) {
|
|
1792
|
+
const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${opts.accountId}/analytics_engine/sql`, { method: 'POST', headers: { authorization: `Bearer ${opts.apiToken}` }, body: sql });
|
|
1793
|
+
const text = await res.text();
|
|
1794
|
+
if (!res.ok) {
|
|
1795
|
+
throw new Error(`Cloudflare Analytics Engine query failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
1796
|
+
}
|
|
1797
|
+
const json = JSON.parse(text);
|
|
1798
|
+
return json.data ?? [];
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* The most rows one day's rollup query may answer (#2501): one per (tenant, vertical, scope) that
|
|
1802
|
+
* served a request that day. A day with more is refused as `RequestRollupSaturated`, never kept as
|
|
1803
|
+
* a part of the day standing for the whole. The rollup records such a day as saturated and goes on
|
|
1804
|
+
* with the days after it; reading it would need a paged read, which this is not yet.
|
|
1805
|
+
*/
|
|
1806
|
+
export const REQUEST_ROLLUP_ROW_LIMIT = 50_000;
|
|
1807
|
+
/**
|
|
1808
|
+
* The daily request rollup's reader (#2501): one closed UTC day of the router's request
|
|
1809
|
+
* datapoints, counted per (tenant, vertical, scope). The platform sweep's request-rollup phase
|
|
1810
|
+
* calls it, never a route.
|
|
1811
|
+
*
|
|
1812
|
+
* It reads EVERY tenant at once, which is why it is not a member of `ObservabilityReader`: that
|
|
1813
|
+
* seam answers routes, and keeps a fleet-wide read one forgotten parameter away by having no
|
|
1814
|
+
* all-tenants spelling. One query per day for the whole fleet is what keeps the rollup to a
|
|
1815
|
+
* handful of queries a pass.
|
|
1816
|
+
*
|
|
1817
|
+
* Counts are `sum(_sample_interval)`, never `count()`, for `queryTenantMetrics`'s reason: Analytics
|
|
1818
|
+
* Engine samples under load, so each count is an estimate and `sampled` says whether any of the
|
|
1819
|
+
* day's datapoints stood for more than one request. The ordinals are the router's published shape
|
|
1820
|
+
* (`ROUTER_REQUEST_POINT`). Beside the total it counts each status class from `statusClass` and
|
|
1821
|
+
* the 429s from `status`, weighted the same way, so the split is kept while the datapoints last.
|
|
1822
|
+
*/
|
|
1823
|
+
export function createRouterRequestDayReader(opts) {
|
|
1824
|
+
return async (day) => {
|
|
1825
|
+
if (!utcDay.safeParse(day).success) {
|
|
1826
|
+
throw new Error(`request rollup: refusing a day that is not a UTC day: ${JSON.stringify(day)}`);
|
|
1827
|
+
}
|
|
1828
|
+
const from = Date.parse(`${day}T00:00:00Z`) / 1000;
|
|
1829
|
+
const p = ROUTER_REQUEST_POINT;
|
|
1830
|
+
const sql = `
|
|
1831
|
+
SELECT
|
|
1832
|
+
${p.tenantId} AS tenantId,
|
|
1833
|
+
${p.vertical} AS vertical,
|
|
1834
|
+
${p.scopeId} AS scopeId,
|
|
1835
|
+
sum(_sample_interval) AS requests,
|
|
1836
|
+
sum(if(${p.statusClass} = '2xx', _sample_interval, 0)) AS class2xx,
|
|
1837
|
+
sum(if(${p.statusClass} = '3xx', _sample_interval, 0)) AS class3xx,
|
|
1838
|
+
sum(if(${p.statusClass} = '4xx', _sample_interval, 0)) AS class4xx,
|
|
1839
|
+
sum(if(${p.statusClass} = '5xx', _sample_interval, 0)) AS class5xx,
|
|
1840
|
+
sum(if(${p.status} = 429, _sample_interval, 0)) AS rateLimited,
|
|
1841
|
+
max(_sample_interval) AS maxInterval
|
|
1842
|
+
FROM ${aeDataset(opts.routerDataset)}
|
|
1843
|
+
WHERE timestamp >= toDateTime(${from})
|
|
1844
|
+
AND timestamp < toDateTime(${from + 86_400})
|
|
1845
|
+
GROUP BY tenantId, vertical, scopeId
|
|
1846
|
+
ORDER BY tenantId, vertical, scopeId
|
|
1847
|
+
LIMIT ${REQUEST_ROLLUP_ROW_LIMIT + 1}
|
|
1848
|
+
FORMAT JSON`;
|
|
1849
|
+
const rows = await analyticsEngineQuery(opts, sql);
|
|
1850
|
+
// One row past the ceiling says the day holds more than one answer carries. An answer of
|
|
1851
|
+
// exactly the ceiling is the whole day.
|
|
1852
|
+
if (rows.length > REQUEST_ROLLUP_ROW_LIMIT) {
|
|
1853
|
+
throw new RequestRollupSaturated(`Cloudflare Analytics Engine request rollup holds more than ${REQUEST_ROLLUP_ROW_LIMIT} groups for ${day}: the counts would be partial`);
|
|
1854
|
+
}
|
|
1855
|
+
const count = (r, key) => Math.round(aeNum(r[key]));
|
|
1856
|
+
return rows.map((r) => ({
|
|
1857
|
+
tenantId: String(r['tenantId'] ?? ''),
|
|
1858
|
+
vertical: blank(r['vertical']),
|
|
1859
|
+
scopeId: String(r['scopeId'] ?? ''),
|
|
1860
|
+
requests: count(r, 'requests'),
|
|
1861
|
+
class2xx: count(r, 'class2xx'),
|
|
1862
|
+
class3xx: count(r, 'class3xx'),
|
|
1863
|
+
class4xx: count(r, 'class4xx'),
|
|
1864
|
+
class5xx: count(r, 'class5xx'),
|
|
1865
|
+
rateLimited: count(r, 'rateLimited'),
|
|
1866
|
+
sampled: aeNum(r['maxInterval']) > 1,
|
|
1867
|
+
}));
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1685
1870
|
/**
|
|
1686
1871
|
* The router dataset's name, checked before it is spliced into SQL. It is an IDENTIFIER,
|
|
1687
1872
|
* not a bound value — it cannot be quoted into place, so the only defence is refusing
|