@substrat-run/control-plane-api 0.159.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.
Files changed (57) hide show
  1. package/dist/api.d.ts +25 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +570 -55
  4. package/dist/api.js.map +1 -1
  5. package/dist/browser.d.ts +2 -2
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +2 -1
  8. package/dist/browser.js.map +1 -1
  9. package/dist/cf-observability.d.ts +30 -0
  10. package/dist/cf-observability.d.ts.map +1 -1
  11. package/dist/cf-observability.js +274 -92
  12. package/dist/cf-observability.js.map +1 -1
  13. package/dist/custom-hostnames.d.ts +30 -0
  14. package/dist/custom-hostnames.d.ts.map +1 -1
  15. package/dist/custom-hostnames.js +25 -2
  16. package/dist/custom-hostnames.js.map +1 -1
  17. package/dist/deploy.d.ts +3 -0
  18. package/dist/deploy.d.ts.map +1 -1
  19. package/dist/deploy.js.map +1 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +3 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +5 -4
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +24 -1
  28. package/dist/observability.d.ts.map +1 -1
  29. package/dist/observability.js +8 -0
  30. package/dist/observability.js.map +1 -1
  31. package/dist/platform-drain.d.ts.map +1 -1
  32. package/dist/platform-drain.js +103 -40
  33. package/dist/platform-drain.js.map +1 -1
  34. package/dist/platform-entry.generated.d.ts +2 -2
  35. package/dist/platform-entry.generated.d.ts.map +1 -1
  36. package/dist/platform-entry.generated.js +5 -5
  37. package/dist/platform-entry.generated.js.map +1 -1
  38. package/dist/platform-wire.d.ts +51 -0
  39. package/dist/platform-wire.d.ts.map +1 -0
  40. package/dist/platform-wire.js +163 -0
  41. package/dist/platform-wire.js.map +1 -0
  42. package/dist/scope-copy-backfill.d.ts.map +1 -1
  43. package/dist/scope-copy-backfill.js +22 -9
  44. package/dist/scope-copy-backfill.js.map +1 -1
  45. package/dist/staff-client.d.ts +38 -2
  46. package/dist/staff-client.d.ts.map +1 -1
  47. package/dist/staff-client.js +24 -1
  48. package/dist/staff-client.js.map +1 -1
  49. package/dist/vertical-client.d.ts +59 -3
  50. package/dist/vertical-client.d.ts.map +1 -1
  51. package/dist/vertical-client.js +144 -6
  52. package/dist/vertical-client.js.map +1 -1
  53. package/dist/wire-upload.d.ts +74 -0
  54. package/dist/wire-upload.d.ts.map +1 -0
  55. package/dist/wire-upload.js +114 -0
  56. package/dist/wire-upload.js.map +1 -0
  57. package/package.json +9 -9
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
@@ -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;AAG3B,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGvD,cAAc,oCAAoC,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
- export { outboundCallsOf } from './observability.js';
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';
@@ -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;AA2B5D,8FAA8F;AAC9F,4EAA4E;AAC5E,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAErD,2FAA2F;AAC3F,0FAA0F;AAC1F,cAAc,oCAAoC,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,4 +1,5 @@
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
  export declare const HTTP_METHOD_PATTERN = "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$";
4
5
  /**
@@ -79,4 +80,33 @@ export interface CfObservabilityOptions {
79
80
  requestsFromRouterSince?: string;
80
81
  }
81
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[]>;
82
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;AAM/I,OAAO,KAAK,EACV,mBAAmB,EASpB,MAAM,oBAAoB,CAAC;AAU5B,eAAO,MAAM,mBAAmB,kCAAkC,CAAC;AA0OnE;;;;;;;;;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;AAyPD;;;;;;;;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,CAkzC7F"}
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"}
@@ -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
  /**
@@ -192,6 +193,16 @@ function requestRecordOf(e) {
192
193
  */
193
194
  const MAX_CORRELATED_INVOCATIONS = 40;
194
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;
195
206
  /**
196
207
  * #1877: the groups one telemetry answer carries, and how many pages of them a span may
197
208
  * take. Past the cap a span is refused rather than cached in part.
@@ -442,6 +453,38 @@ function withoutOutputFields(e) {
442
453
  const { outputFields: _report, [FIELD_COVERAGE_ID_FIELD]: _id, ...rest } = source;
443
454
  return { ...e, source: rest };
444
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
+ }
445
488
  /**
446
489
  * One backend event → the seam's neutral `RecentLogEvent`.
447
490
  *
@@ -698,21 +741,22 @@ export function createCfObservabilityReader(opts) {
698
741
  const pending = inFlight.get(key);
699
742
  if (pending)
700
743
  return pending;
744
+ const p = ROUTER_REQUEST_POINT;
701
745
  const sql = `
702
746
  SELECT
703
- blob1 AS vertical,
704
- blob2 AS scopeId,
705
- blob3 AS surface,
706
- double2 AS status,
707
- blob6 AS operation,
708
- blob7 AS problemCode,
709
- blob8 AS principalKind,
710
- blob9 AS level,
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,
711
755
  toStartOfInterval(timestamp, INTERVAL '${grainSeconds}' SECOND) AS bucket,
712
756
  sum(_sample_interval) AS requests,
713
757
  max(_sample_interval) AS maxInterval
714
758
  FROM ${aeDataset(dataset)}
715
- WHERE index1 = ${aeLiteral(tenantId)}
759
+ WHERE ${p.tenantId} = ${aeLiteral(tenantId)}
716
760
  AND timestamp >= toDateTime(${Math.floor(q.from / 1000)})
717
761
  AND timestamp < toDateTime(${Math.ceil(q.to / 1000)})
718
762
  GROUP BY vertical, scopeId, surface, status, operation, problemCode, principalKind, level, bucket
@@ -909,14 +953,7 @@ export function createCfObservabilityReader(opts) {
909
953
  // single-valued equality, and an OR across script names is not a filter this
910
954
  // API offers. The queries are independent, so they run concurrently and the
911
955
  // merge (not the backend) enforces the overall `limit`.
912
- if (services && services.length > 1) {
913
- const pages = await Promise.all(services.map((s) => queryEvents(s, level, search, hours, limit)));
914
- return pages
915
- .flat()
916
- .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
917
- .slice(0, limit);
918
- }
919
- return queryEvents(services?.[0], level, search, hours, limit);
956
+ return queryEvents(services && services.length > 1 ? services : [services?.[0]], level, search, hours, limit);
920
957
  },
921
958
  // Present only when the caller named the router's dataset: with no dataset there is
922
959
  // no honest answer, and the route's 501 says so. See `routerDataset`. The bucketed
@@ -1057,33 +1094,32 @@ export function createCfObservabilityReader(opts) {
1057
1094
  // would answer two rows whose p95s no caller can combine. Vertical reads null there.
1058
1095
  const byScope = input.grain === 'scope';
1059
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;
1060
1102
  const where = [
1061
- `index1 = ${aeLiteral(input.tenantId)}`,
1103
+ `${p.tenantId} = ${aeLiteral(input.tenantId)}`,
1062
1104
  `timestamp >= toDateTime(${Math.ceil(Date.parse(window.since) / 1000)})`,
1063
1105
  `timestamp < toDateTime(${Math.ceil(Date.parse(window.until) / 1000)})`,
1064
1106
  ];
1065
1107
  if (input.scopeId)
1066
- where.push(`blob2 = ${aeLiteral(input.scopeId)}`);
1108
+ where.push(`${p.scopeId} = ${aeLiteral(input.scopeId)}`);
1067
1109
  if (input.vertical)
1068
- where.push(`blob1 = ${aeLiteral(input.vertical)}`);
1069
- // Blob/double positions are the router's published shape (`apps/router/src/worker.ts`
1070
- // `record`): index1 tenant; blob1 vertical, blob2 scope, blob3 surface, blob4 status
1071
- // class; double1 duration ms, double2 status. That shape only ever grows, never
1072
- // reorders — which is what lets these ordinals be written down here at all. The
1073
- // connector-call dataset's ordinals are published beside these, on
1074
- // `queryConnectorCallsSeries` below (#1691) — a different dataset, never this one.
1110
+ where.push(`${p.vertical} = ${aeLiteral(input.vertical)}`);
1075
1111
  const sql = `
1076
1112
  SELECT
1077
- blob2 AS scopeId,
1078
- ${byScope ? "'' AS vertical" : 'blob1 AS vertical'},
1079
- ${byScope ? "'' AS surface" : 'blob3 AS surface'},
1113
+ ${p.scopeId} AS scopeId,
1114
+ ${byScope ? "'' AS vertical" : `${p.vertical} AS vertical`},
1115
+ ${byScope ? "'' AS surface" : `${p.surface} AS surface`},
1080
1116
  sum(_sample_interval) AS requests,
1081
- sum(if(blob4 = '5xx', _sample_interval, 0)) AS errors,
1082
- sum(if(blob4 = '2xx', _sample_interval, 0)) AS class2xx,
1083
- sum(if(blob4 = '3xx', _sample_interval, 0)) AS class3xx,
1084
- sum(if(blob4 = '4xx', _sample_interval, 0)) AS class4xx,
1085
- quantileWeighted(0.5)(double1, _sample_interval) AS durationP50,
1086
- quantileWeighted(0.95)(double1, _sample_interval) AS durationP95
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
1087
1123
  FROM ${aeDataset(dataset)}
1088
1124
  WHERE ${where.join(' AND ')}
1089
1125
  GROUP BY ${byScope ? 'scopeId' : 'scopeId, vertical, surface'}
@@ -1130,22 +1166,23 @@ export function createCfObservabilityReader(opts) {
1130
1166
  const window = resolveObservabilityWindow(input);
1131
1167
  const bucketMinutes = observabilityBucketMinutes(window);
1132
1168
  const scopes = input.scopeIds.map((s) => aeLiteral(s)).join(', ');
1169
+ const p = ROUTER_REQUEST_POINT;
1133
1170
  const sql = `
1134
1171
  SELECT
1135
- blob2 AS scopeId,
1172
+ ${p.scopeId} AS scopeId,
1136
1173
  toStartOfInterval(timestamp, INTERVAL '${bucketMinutes}' MINUTE) AS start,
1137
1174
  sum(_sample_interval) AS requests,
1138
- sum(if(blob4 = '5xx', _sample_interval, 0)) AS errors,
1139
- sum(if(blob4 = '2xx', _sample_interval, 0)) AS class2xx,
1140
- sum(if(blob4 = '3xx', _sample_interval, 0)) AS class3xx,
1141
- sum(if(blob4 = '4xx', _sample_interval, 0)) AS class4xx,
1142
- quantileWeighted(0.5)(double1, _sample_interval) AS durationP50,
1143
- quantileWeighted(0.95)(double1, _sample_interval) AS durationP95
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
1144
1181
  FROM ${aeDataset(dataset)}
1145
- WHERE index1 = ${aeLiteral(input.tenantId)}
1182
+ WHERE ${p.tenantId} = ${aeLiteral(input.tenantId)}
1146
1183
  AND timestamp >= toDateTime(${Math.ceil(Date.parse(window.since) / 1000)})
1147
1184
  AND timestamp < toDateTime(${Math.ceil(Date.parse(window.until) / 1000)})
1148
- AND blob2 IN (${scopes})
1185
+ AND ${p.scopeId} IN (${scopes})
1149
1186
  GROUP BY scopeId, start
1150
1187
  ORDER BY start ASC
1151
1188
  LIMIT ${SERIES_ROW_LIMIT}
@@ -1286,14 +1323,8 @@ export function createCfObservabilityReader(opts) {
1286
1323
  });
1287
1324
  }
1288
1325
  /** One Analytics Engine SQL read, returning the rows as the API shaped them. */
1289
- async function analyticsEngineSql(sql) {
1290
- 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 });
1291
- const text = await res.text();
1292
- if (!res.ok) {
1293
- throw new Error(`Cloudflare Analytics Engine query failed: HTTP ${res.status} ${text.slice(0, 200)}`);
1294
- }
1295
- const json = JSON.parse(text);
1296
- return json.data ?? [];
1326
+ function analyticsEngineSql(sql) {
1327
+ return analyticsEngineQuery(opts, sql);
1297
1328
  }
1298
1329
  /**
1299
1330
  * One tenant's log events, in two phases (§4.3).
@@ -1441,7 +1472,10 @@ export function createCfObservabilityReader(opts) {
1441
1472
  // `limit` (the route bounds that at 200).
1442
1473
  const linesPerInvocation = input.invocationId !== undefined ? Math.min(input.limit, 200) : MAX_LINES_PER_INVOCATION;
1443
1474
  const sibling = await Promise.all(requestIds.map(async (id) => {
1444
- const events = await queryRaw([{ key: '$metadata.requestId', operation: 'eq', type: 'string', value: id }, ...serviceFilter(input.services)], timeframe, linesPerInvocation);
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);
1445
1479
  // An account-wide candidate earns its place only by producing this tenant's
1446
1480
  // stamped line. No stamped line, or somebody else's, and the whole invocation is
1447
1481
  // dropped — a line shown to the wrong tenant is exactly the leak the tenant grain
@@ -1545,6 +1579,10 @@ export function createCfObservabilityReader(opts) {
1545
1579
  // searches the same one, so this takes the instants rather than a duration it would
1546
1580
  // have to re-anchor to a `Date.now()` of its own.
1547
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) {
1548
1586
  const res = await authed(`https://api.cloudflare.com/client/v4/accounts/${opts.accountId}/workers/observability/telemetry/query`, {
1549
1587
  queryId: 'substrat-tenant-logs',
1550
1588
  view: 'events',
@@ -1558,14 +1596,14 @@ export function createCfObservabilityReader(opts) {
1558
1596
  const json = JSON.parse(text);
1559
1597
  if (json.success === false)
1560
1598
  throw telemetryFailure(502, text);
1561
- const outer = json.result?.events;
1562
- return (Array.isArray(outer) ? outer : (outer?.events ?? []));
1599
+ return telemetryPage(json.result);
1563
1600
  }
1564
1601
  /** Read a complete sampled span, or refuse it; a truncated page must never mean zero fields. */
1565
1602
  async function queryAllFieldEvents(filters, timeframe, depth = 0) {
1566
- const page = await queryRaw(filters, timeframe, 2000);
1567
- if (page.length < 2000)
1568
- return page;
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;
1569
1607
  if (depth >= 12 || timeframe.to - timeframe.from <= 1000) {
1570
1608
  throw new ControlPlaneError(503, 'field coverage window is too busy — choose a shorter window');
1571
1609
  }
@@ -1576,38 +1614,103 @@ export function createCfObservabilityReader(opts) {
1576
1614
  ]);
1577
1615
  return [...left, ...right];
1578
1616
  }
1579
- /** One telemetry query — narrowed to a single service, or to none (the fleet view). */
1580
- async function queryEvents(service, level, search, hours, limit) {
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) {
1581
1622
  const to = Date.now();
1582
- const filters = [];
1583
- // `$metadata.service` is the script name in Workers Logs events — the field
1584
- // Cloudflare's own query examples filter on.
1585
- if (service) {
1586
- filters.push({ key: '$metadata.service', operation: 'eq', type: 'string', value: service });
1587
- }
1588
- if (level) {
1589
- filters.push({ key: '$metadata.level', operation: 'eq', type: 'string', value: level });
1590
- }
1591
- if (search) {
1592
- filters.push({ key: '$metadata.message', operation: 'includes', type: 'string', value: search });
1593
- }
1594
- const res = await authed(`https://api.cloudflare.com/client/v4/accounts/${opts.accountId}/workers/observability/telemetry/query`, {
1595
- queryId: 'substrat-recent-logs',
1596
- view: 'events',
1597
- timeframe: { from: to - hours * 3_600_000, to },
1598
- parameters: { datasets: ['cloudflare-workers'], filters, limit },
1599
- limit,
1600
- });
1601
- const json = (await res.json());
1602
- if (!res.ok || json.success === false) {
1603
- const message = json.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`;
1604
- throw new Error(`Cloudflare telemetry query failed: ${message}`);
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));
1605
1709
  }
1606
- // The events view has nested the list one level deeper across API revisions —
1607
- // accept both rather than pinning to whichever shape was current at writing.
1608
- const outer = json.result?.events;
1609
- const events = (Array.isArray(outer) ? outer : (outer?.events ?? []));
1610
- 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 };
1611
1714
  }
1612
1715
  /**
1613
1716
  * Outbound `fetch` spans for one service (#859).
@@ -1651,8 +1754,7 @@ export function createCfObservabilityReader(opts) {
1651
1754
  const message = json.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`;
1652
1755
  throw new Error(`Cloudflare span query failed: ${message}`);
1653
1756
  }
1654
- const outer = json.result?.events;
1655
- const events = (Array.isArray(outer) ? outer : (outer?.events ?? []));
1757
+ const { events, fetched } = telemetryPage(json.result);
1656
1758
  const spans = events.map((e) => {
1657
1759
  const source = (e['source'] ?? {});
1658
1760
  const metadata = (e['$metadata'] ?? {});
@@ -1682,9 +1784,89 @@ export function createCfObservabilityReader(opts) {
1682
1784
  });
1683
1785
  // Row-cap detection: a full page means there may be more. Reported rather than
1684
1786
  // swallowed — an egress report that silently drops hosts is worse than none (#859).
1685
- return { spans, truncated: events.length >= limit };
1787
+ return { spans, truncated: fetched >= limit };
1686
1788
  }
1687
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
+ }
1688
1870
  /**
1689
1871
  * The router dataset's name, checked before it is spliced into SQL. It is an IDENTIFIER,
1690
1872
  * not a bound value — it cannot be quoted into place, so the only defence is refusing