@jeffjassky/telemetry 0.3.0 → 0.5.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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Telemetry</title>
7
7
  <!--telemetry-config-->
8
- <script type="module" crossorigin src="./_assets/index-3jcToTuP.js"></script>
9
- <link rel="stylesheet" crossorigin href="./_assets/index-CXiDp_v6.css">
8
+ <script type="module" crossorigin src="./_assets/index-kvwB9_3A.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./_assets/index-COswHpSX.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="telemetry-root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffjassky/telemetry",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Unified telemetry — product events, errors, traces, state transitions, and billable usage in one Mongo envelope, with typed SDKs and a mountable dashboard.",
5
5
  "license": "MIT",
6
6
  "author": "Jeff Jassky <jeff@jeffjassky.com>",
package/types/index.d.ts CHANGED
@@ -166,6 +166,351 @@ export declare function boundedMeta(): z.ZodType<Record<string, unknown> | undef
166
166
  /** Boot-time contract checks — throws on misconfiguration. createTelemetry runs it. */
167
167
  export declare function validateRegistry(registry: Registry): void;
168
168
 
169
+ // ── catalog (reports §3) ────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Everything a reader can ask this instance, inferred from the registry alone:
173
+ * typed dimensions with their value domains, the measures each event supports,
174
+ * and which rollup family answers a sum exactly. Pure and boot-time, like
175
+ * validateRegistry — createDashboard() and createTelemetryMcp() build one each
176
+ * and serve it beside the projection.
177
+ */
178
+ export interface Catalog {
179
+ events: Record<string, EventFacet>;
180
+ families: Record<string, FamilyFacet>;
181
+ /** name prefix before the first '.' → event names. An undotted name namespaces to itself. */
182
+ namespaces: Record<string, string[]>;
183
+ /** dims every record carries — filterable and groupable raw, whatever the source */
184
+ envelope: DimFacet[];
185
+ /** every subject type named by any spec's `subjects` or any rollup's `subjects` */
186
+ subjectTypes: string[];
187
+ }
188
+
189
+ export interface EventFacet {
190
+ kind: TelemetryKind;
191
+ origin: Origin | 'any';
192
+ subjects: string[];
193
+ description: string;
194
+ namespace: string;
195
+ /** one per declared attr, typed, followed by this kind's own envelope fields */
196
+ dims: DimFacet[];
197
+ /** 'count' first, then per metric key: sum:, avg:, p50:, p95:, p99: */
198
+ measures: MeasureFacet[];
199
+ /** rollup family names this event feeds (its `as`, or its own name) */
200
+ families: string[];
201
+ indexedAttrs: string[];
202
+ indexedMetrics: string[];
203
+ /** the EFFECTIVE retention — the spec's override, else RETENTION_DAYS[kind] */
204
+ retentionDays: number | null;
205
+ }
206
+
207
+ export interface FamilyFacet {
208
+ as: string;
209
+ /** the grain, in declared order (pinned per family by validateRegistry) */
210
+ by: DimSource[];
211
+ /** rollups.ts `label(src)` per dim — the `x=` prefix written into `dims` */
212
+ labels: string[];
213
+ bucket: 'hour' | 'day' | 'week' | 'month' | null;
214
+ /** a lifetime rollup has no bucket, and its `firstAt` IS the milestone */
215
+ lifetime: boolean;
216
+ /** the spec's `subjects` when `by` has a subject dim, else [] */
217
+ subjectTypes: string[];
218
+ sums: string[];
219
+ /** labels of `capture` sources */
220
+ capture: string[];
221
+ /** event names declaring this family, registry order */
222
+ feeders: string[];
223
+ retentionDays: number | null;
224
+ }
225
+
226
+ export interface DimFacet {
227
+ /**
228
+ * The DimSource form, so it passes straight through to a rollup `by`, to a
229
+ * groupBy, and to a filter term: 'attr:model' | 'field:client.platform' |
230
+ * 'subjectType' | 'actorType'.
231
+ */
232
+ key: string;
233
+ /** what rollups.ts writes before '=' — 'model', 'client.platform'; for the two pseudo-dims, the key */
234
+ label: string;
235
+ type: 'string' | 'enum' | 'number' | 'boolean' | 'date';
236
+ /** closed domain when known: z.enum / z.literal values, envelope enums */
237
+ values?: string[];
238
+ optional: boolean;
239
+ /** true when a real index exists — an `indexedAttrs` attr, or a base-indexed envelope field */
240
+ indexed: boolean;
241
+ }
242
+
243
+ export interface MeasureFacet {
244
+ /** 'count' | 'sum:cost_usd' | 'avg:cost_usd' | 'p95:duration_ms' … */
245
+ key: string;
246
+ metric?: string;
247
+ /** families whose `sum` carries this metric — exact answers. Only 'sum:' keys ever have one. */
248
+ exactVia: string[];
249
+ }
250
+
251
+ export interface DeriveCatalogOptions {
252
+ /** host additions to `client.platform`, exactly as CreateTelemetryConfig.platforms extends them */
253
+ platforms?: readonly string[];
254
+ }
255
+
256
+ /** the projection `/api/registry` and `describe_telemetry` have always returned */
257
+ export interface RegistryProjectionEntry {
258
+ kind: TelemetryKind;
259
+ origin: Origin | 'any';
260
+ subjects: string[];
261
+ description: string;
262
+ attrKeys: string[];
263
+ metricKeys: string[];
264
+ indexedAttrs: string[];
265
+ indexedMetrics: string[];
266
+ rollups: {
267
+ as: string;
268
+ by: DimSource[];
269
+ bucket: 'hour' | 'day' | 'week' | 'month' | null;
270
+ sum: string[];
271
+ subjects: string[];
272
+ }[];
273
+ }
274
+ export type RegistryProjection = Record<string, RegistryProjectionEntry>;
275
+
276
+ /** Pure. No Mongo, no I/O — derive once at boot and cache it on the instance. */
277
+ export declare function deriveCatalog(registry: Registry, opts?: DeriveCatalogOptions): Catalog;
278
+
279
+ /** the catalog narrowed back to the projection, so adding it costs the SPA nothing */
280
+ export declare function projectRegistry(catalog: Catalog): RegistryProjection;
281
+
282
+ // ── suggestions (reports §9) ────────────────────────────────────────────────
283
+
284
+ /**
285
+ * One registry edit the data is asking for. `message` is the sentence a human
286
+ * reads; `fix` is the line they paste. Nothing here writes anything — the host
287
+ * still edits the registry by hand, the package just stops making it guess.
288
+ */
289
+ export interface Suggestion {
290
+ kind: 'undeclared_attr' | 'missing_dim_default' | 'unregistered_event';
291
+ /** the registry entry to touch — an event name, or a rollup family name */
292
+ target: string;
293
+ /** attr key or dim label, when the suggestion is about one */
294
+ key?: string;
295
+ count: number;
296
+ message: string;
297
+ /** the registry change, as code */
298
+ fix: string;
299
+ }
300
+
301
+ export interface DeriveSuggestionsInput {
302
+ counters: TelemetryCounters;
303
+ catalog: Catalog;
304
+ /** the quarantine rows the caller already fetched — only `name` and `reason` are read */
305
+ quarantine?: readonly { name?: unknown; reason?: unknown; [k: string]: unknown }[];
306
+ }
307
+
308
+ /**
309
+ * Pure, like deriveCatalog: counters + catalog + quarantine in, registry lines
310
+ * out. Served on `GET /api/system` and by the `telemetry_health` MCP tool.
311
+ */
312
+ export declare function deriveSuggestions(input: DeriveSuggestionsInput): Suggestion[];
313
+
314
+ /** the returned list is capped here — a System page is a thing a human reads */
315
+ export declare const MAX_SUGGESTIONS: 50;
316
+
317
+ // ── reports (reports §4, §6) ────────────────────────────────────────────────
318
+
319
+ /**
320
+ * A Report is one shape: what a page renders, what a saved view stores, what a
321
+ * URL hash carries, and what `run_report` executes. `resolveReport` turns one
322
+ * into a Plan — the cheapest primitive that answers it exactly, a raw plan when
323
+ * nothing can, and a refusal with a reason when nothing at all can.
324
+ */
325
+ export type ReportSource =
326
+ | { event: string }
327
+ | { namespace: string }
328
+ | { kind: TelemetryKind }
329
+ | { family: string };
330
+
331
+ /** a shorthand from the UI's RANGES ('7d'), or an explicit half-open ISO pair */
332
+ export type ReportRange = string | { from: string; to: string };
333
+
334
+ export interface ReportFilter {
335
+ /** a DimFacet.key: 'attr:model' | 'field:env' | 'subjectType' | 'field:name' … */
336
+ dim: string;
337
+ op: 'eq' | 'in' | 'gte' | 'lte';
338
+ value: string | string[] | number;
339
+ }
340
+
341
+ export interface Report {
342
+ source: ReportSource;
343
+ range: ReportRange;
344
+ interval?: 'hour' | 'day' | 'week' | 'month';
345
+ /** a MeasureFacet.key. Default 'count'; also 'distinct:<subjectType>' and 'funnel' */
346
+ measure?: string;
347
+ /** DimFacet.key[], at most two */
348
+ groupBy?: string[];
349
+ filters?: ReportFilter[];
350
+ excludeActorTypes?: string[];
351
+ /** a rendering hint carried with the Report; no primitive takes it today */
352
+ sort?: 'value' | 'label' | 'time';
353
+ limit?: number;
354
+ /** same length, immediately before */
355
+ compare?: 'previous';
356
+
357
+ // ── funnel-only (`measure: 'funnel'`) ──
358
+ stages?: string[];
359
+ anchor?: string;
360
+ exits?: string[];
361
+ subjectType?: string;
362
+ }
363
+
364
+ /**
365
+ * The pre-Report `ViewSpec.query`, still parsed and lifted by normalizeQuery().
366
+ * Its `display` key is removed — the renderer decides from the Report itself
367
+ * (reports §8) — and a stored view still carrying one keeps parsing.
368
+ * @deprecated write a Report.
369
+ */
370
+ export interface LegacyQuery {
371
+ range?: string;
372
+ filters?: Record<string, unknown>;
373
+ groupBy?: string;
374
+ sort?: string;
375
+ }
376
+
377
+ export type PlanPrimitive =
378
+ | 'records' | 'series' | 'breakdown' | 'distribution'
379
+ | 'rollups' | 'distinctCount' | 'funnel';
380
+
381
+ /**
382
+ * How the executor folds a `rollups` plan: `rollups()` has no server-side
383
+ * groupBy, and the requested dims ARE the family's dims, so the grouping is a
384
+ * fold over the returned rows. `labels[i]` is the `dims` prefix rollups.ts
385
+ * writes for `groupBy[i]`; a subject dim labels to 'subject' and its stored
386
+ * value is the bare `type:id` ref.
387
+ */
388
+ export interface PlanShape {
389
+ groupBy: string[];
390
+ labels: string[];
391
+ measure: string;
392
+ interval?: 'hour' | 'day' | 'week' | 'month';
393
+ filters?: { dim: string; label: string; op: ReportFilter['op']; value: ReportFilter['value'] }[];
394
+ }
395
+
396
+ export interface Plan {
397
+ primitive: PlanPrimitive;
398
+ /** positional args AFTER scope — the executor is literally `q[primitive](scope, ...args)` */
399
+ args: unknown[];
400
+ exactness: 'exact' | 'raw' | 'scan';
401
+ /** the family that answers it, when one does */
402
+ via?: string;
403
+ /** human sentence — the UI badge and the MCP explanation */
404
+ why: string;
405
+ /** how to fold the rows a `rollups` plan returns */
406
+ shape?: PlanShape;
407
+ /** present when `compare: 'previous'` — same primitive, range shifted back by its own length */
408
+ previous?: { args: unknown[] };
409
+ }
410
+
411
+ export interface Unavailable {
412
+ unavailable: true;
413
+ why: string;
414
+ }
415
+
416
+ export interface ResolveOptions {
417
+ /** injected so a plan is deterministic — shorthand ranges end here */
418
+ now?: Date;
419
+ limits?: Partial<QueryLimits>;
420
+ }
421
+
422
+ /**
423
+ * Report → Plan, pure. No Mongo, no I/O, deterministic given `now` — pinned by
424
+ * unit tests like deriveCatalog and summarizeStages.
425
+ */
426
+ export declare function resolveReport(
427
+ report: Report,
428
+ catalog: Catalog,
429
+ opts?: ResolveOptions,
430
+ ): Plan | Unavailable;
431
+
432
+ /** lift a stored view's legacy query onto a Report. null when nothing names a source. */
433
+ export declare function normalizeQuery(query: Report | LegacyQuery | null | undefined): Report | null;
434
+
435
+ /**
436
+ * A Report is a URL, and these two are inverses: `parseReportQuery(reportToQuery(r))`
437
+ * deep-equals `r`. `source=event:<name>|namespace:<ns>|kind:<kind>|family:<as>`,
438
+ * `range=7d` or `from`+`to`, `groupBy` and `excludeActors` comma-separated, and
439
+ * `filter=<dim>:<op>:<value>` REPEATED — the dim may itself contain a colon, so
440
+ * the first `eq`/`in`/`gte`/`lte` token ends it. Unknown params are ignored; a
441
+ * malformed one throws with `status: 400` naming the param.
442
+ */
443
+ export declare function parseReportQuery(query: Record<string, unknown>): Report;
444
+ export declare function reportToQuery(report: Report): Record<string, string | string[]>;
445
+
446
+ // ── the executor ────────────────────────────────────────────────────────────
447
+
448
+ export interface ExecuteOptions {
449
+ /** injected so a plan is deterministic — shorthand ranges end here */
450
+ now?: Date;
451
+ limits?: Partial<QueryLimits>;
452
+ /** applied to a `records` plan's items before they leave (mcp.ts passes its redactor) */
453
+ redact?: (items: any[]) => any[];
454
+ }
455
+
456
+ export interface ReportResult {
457
+ /** the Report as executed — after a legacy lift, so the caller sees what ran */
458
+ report: Report;
459
+ plan: Plan;
460
+ /** the primitive's own result, EXCEPT a `rollups` plan, which arrives folded */
461
+ result: unknown;
462
+ /** present when `compare: 'previous'` — the same call, range shifted back */
463
+ previous?: unknown;
464
+ dataSource: 'raw' | 'rollups' | 'raw+rollups';
465
+ }
466
+
467
+ /** the fields the fold reads off a rollup doc */
468
+ export interface RollupDoc {
469
+ /** dimension values in the family's `by` order: 'region=eu', or a bare 'user:u_1' */
470
+ dims: string[];
471
+ bucketAt?: Date | string | null;
472
+ count?: number;
473
+ sums?: Record<string, number> | Map<string, number> | null;
474
+ }
475
+
476
+ /** what `breakdown()` returns, answered from the rollup store instead */
477
+ export interface FoldedRollups {
478
+ rows: Array<{ dims: (string | null)[]; at?: Date; value: number }>;
479
+ groups: number;
480
+ truncated: boolean;
481
+ dataSource: 'rollups';
482
+ }
483
+
484
+ /**
485
+ * Report → the answer: resolve, then `q[plan.primitive](scope, ...plan.args)`.
486
+ * An `Unavailable` throws with `status: 400` and the `why` as its message —
487
+ * a refusal is an answer to `resolveReport`, but not to someone asking for data.
488
+ */
489
+ export declare function executeReport(
490
+ q: Queries,
491
+ scope: string,
492
+ report: Report,
493
+ catalog: Catalog,
494
+ opts?: ExecuteOptions,
495
+ ): Promise<ReportResult>;
496
+
497
+ /**
498
+ * Fold a `rollups` plan's docs into the row shape `breakdown()` returns, per
499
+ * `Plan.shape` — the family's docs ARE the groups, so this is arithmetic rather
500
+ * than a second read, and a renderer never learns which store answered. Pure.
501
+ */
502
+ export declare function foldRollups(
503
+ rows: readonly RollupDoc[],
504
+ shape: PlanShape,
505
+ truncated?: boolean,
506
+ ): FoldedRollups;
507
+
508
+ /** '7d' → a half-open pair ending at `now`; an ISO pair validated. Throws `status: 400`. */
509
+ export declare function rangeOf(range: ReportRange, now?: Date): TimeRange;
510
+
511
+ /** the interval that keeps a range under ~120 buckets — util.js `intervalFor`, for pairs too */
512
+ export declare function intervalForRange(range: ReportRange, now?: Date): 'hour' | 'day' | 'week' | 'month';
513
+
169
514
  // ── typed emit ──────────────────────────────────────────────────────────────
170
515
 
171
516
  export type AttrsOf<R extends Registry, N extends keyof R> =
@@ -275,8 +620,47 @@ export interface TelemetryCounters {
275
620
  deduped: number;
276
621
  /** `body` values clipped to the cap — the row survives, marked */
277
622
  truncated: number;
623
+ /**
624
+ * `rollupSkipped`, attributed: `${family}|${dimLabel}` → count. Which family
625
+ * dropped which dim, so the scalar becomes a `dimDefault` you can go and
626
+ * declare. The seven numbers above are unchanged — this is additive.
627
+ */
628
+ rollupSkippedBy: Record<string, number>;
629
+ /**
630
+ * attrs keys a record carried that its spec does not declare:
631
+ * `${name}|${key}` → count. Those records are REJECTED by the strict parse,
632
+ * not stripped; this groups what the quarantine lists one row at a time.
633
+ */
634
+ undeclaredAttrs: Record<string, number>;
635
+ /**
636
+ * Write-time subject linking. All six stay at zero without a
637
+ * `subjectLinker`. They are split six ways because every way linking can fail
638
+ * ends in the same row — one written with the subjects it arrived with — and
639
+ * a silently unlinked record is indistinguishable from one nobody could link.
640
+ */
641
+ /** subjects actually ADDED to records — two links on one record count twice */
642
+ subjectsLinked: number;
643
+ /** records where the linker answered `[]` — no link exists, which is an answer */
644
+ subjectLinkMisses: number;
645
+ /** the linker threw, rejected, or returned something that is not a list of refs */
646
+ subjectLinkErrors: number;
647
+ /** the linker outran `subjectLinkTimeoutMs`; the record was written unlinked */
648
+ subjectLinkTimeouts: number;
649
+ /** a linked subject whose `type` the event's `EventSpec.subjects` does not declare */
650
+ subjectLinkUndeclared: number;
651
+ /** a linked subject dropped because the record already held `SUBJECT_MAX` of them */
652
+ subjectLinkCapped: number;
278
653
  }
279
654
 
655
+ /**
656
+ * Distinct keys either attributed map holds before new ones fold into a single
657
+ * `(other)` bucket. Both are keyed on client-controlled data, so the bound is
658
+ * what stops a hostile client growing the process heap; the totals stay
659
+ * honest, only the attribution stops.
660
+ */
661
+ export declare const COUNTER_MAP_MAX: 1000;
662
+ export declare const COUNTER_OVERFLOW_KEY: '(other)|(other)';
663
+
280
664
  /** What emit() did. `Promise<void>` could not distinguish "written" from "queued". */
281
665
  export interface EmitResult {
282
666
  /** the record _id — usable for correlation even when the row was not stored */
@@ -343,9 +727,71 @@ export interface CreateTelemetryConfig<R extends Registry = Registry> {
343
727
  * different person in each, and one tenant's erasure would reach another's.
344
728
  */
345
729
  globalSubjectRefs?: boolean;
730
+ /**
731
+ * Attach additional subjects to a record AT WRITE TIME — the desktop
732
+ * `machine:<installId>` the host can resolve to a `user`, joined once, onto
733
+ * the row and its rollups, rather than at every read that ever wants it.
734
+ * Must be cached: it runs once per record on the ingest path.
735
+ */
736
+ subjectLinker?: SubjectLinker;
737
+ /**
738
+ * What `subjectLinker.link()` gets per record before the write proceeds
739
+ * UNLINKED and counts a timeout. Default 50.
740
+ */
741
+ subjectLinkTimeoutMs?: number;
346
742
  logger?: Logger;
347
743
  }
348
744
 
745
+ /**
746
+ * WRITE-time subject linking — distinct from `SubjectAdapter`, which labels
747
+ * refs at read time and changes nothing about what is stored.
748
+ *
749
+ * A desktop client knows its install and nothing else, so its records carry
750
+ * `machine:<installId>` and no `user`. Resolving that at read time leaves a
751
+ * cohort funnel anchored on `user` reading zero for every desktop stage;
752
+ * resolving it at write time puts the party on the row AND on its rollups,
753
+ * which is the half a read-time join can never reach.
754
+ */
755
+ export interface SubjectLinker {
756
+ /**
757
+ * Additional subjects for a record being written. `[]` when nothing links —
758
+ * that is an answer, and it is counted as one.
759
+ *
760
+ * Runs once per record on the write path, so it must answer from a cache.
761
+ * The package bounds it rather than trusting it: past `subjectLinkTimeoutMs`,
762
+ * or on a throw, the record is written unlinked and counted. A linked subject
763
+ * whose `type` the event does not declare is refused, not written.
764
+ */
765
+ link(
766
+ subjects: SubjectInput[],
767
+ ctx: { name: string; tenantId: string },
768
+ ): SubjectInput[] | Promise<SubjectInput[]>;
769
+ }
770
+
771
+ /**
772
+ * The guarded linker the instance resolved at construction: the merged subjects
773
+ * to write, or `null` when nothing changed. Exposed as `t.linkSubjects` for the
774
+ * router factories, which reach it the way they reach the registry.
775
+ */
776
+ export type LinkSubjects = (
777
+ name: string,
778
+ spec: { subjects: readonly string[] },
779
+ tenantId: string,
780
+ declared: unknown,
781
+ ) => Promise<SubjectInput[] | null>;
782
+
783
+ /**
784
+ * Total subjects one record may carry once linking has run. `subjectKeys` is a
785
+ * multikey index term and every subject fans a `by:['subject']` rollup out one
786
+ * more time, so an unbounded array is unbounded write amplification with a
787
+ * host's cache bug behind it. Overflow is dropped and counted in
788
+ * `counters.subjectLinkCapped`.
789
+ */
790
+ export declare const SUBJECT_MAX: 8;
791
+
792
+ /** default `subjectLinkTimeoutMs` — past it the record is written unlinked */
793
+ export declare const SUBJECT_LINK_TIMEOUT_MS: 50;
794
+
349
795
  export interface Telemetry<R extends Registry = Registry> {
350
796
  /** write — the only write. The result says what actually happened to the row. */
351
797
  emit<N extends keyof R & string>(name: N, doc: EmitInput<R, N>): Promise<EmitResult>;
@@ -373,6 +819,14 @@ export interface Telemetry<R extends Registry = Registry> {
373
819
  counters: TelemetryCounters;
374
820
  /** the registry this instance validates against */
375
821
  registry: R;
822
+ /**
823
+ * Write-time subject linking, guarded and resolved once at construction;
824
+ * `null` without a `subjectLinker`. Exposed for the router factories: the
825
+ * wire path does not go through `emit()` — at-least-once delivery inverts the
826
+ * plane order — so `createIngest` reaches the one implementation here rather
827
+ * than growing a second copy of the rules.
828
+ */
829
+ linkSubjects: LinkSubjects | null;
376
830
  logger: Logger;
377
831
  /** mint an ingest key against this instance's key collection */
378
832
  createKey(input: CreateKeyInput): Promise<{ key: string; id: string }>;
@@ -470,12 +924,33 @@ export declare function createIngest(opts: CreateIngestOptions): import('express
470
924
 
471
925
  // ── dashboard (dashboards §2–§8) ────────────────────────────────────────────
472
926
 
927
+ /**
928
+ * Two kinds of cap, and the word "limit" hides the difference. An OUTPUT cap
929
+ * bounds what the response CONTAINS — its `$limit` sits after the `$group`/sort
930
+ * or rides an indexed cursor, so the work behind it is bounded by the range and
931
+ * the indexes, not by the number. A SCAN cap bounds what the primitive READS,
932
+ * so an answer past it is an undercount — which is why all three report
933
+ * `truncated`.
934
+ */
473
935
  export interface QueryLimits {
936
+ // ── output caps ──
474
937
  records: number;
475
938
  series: number;
476
939
  rollups: number;
477
940
  trace: number;
478
941
  journey: number;
942
+ /**
943
+ * Distinct GROUPS one breakdown() returns — the top N by measure, read as
944
+ * cap+1 so truncation is observed. Never a bound on rows scanned.
945
+ */
946
+ breakdown: number;
947
+ /**
948
+ * Distinct VALUES one values() lookup returns — the top N by count, read as
949
+ * cap+1 so truncation is observed. Never a bound on rows scanned.
950
+ */
951
+ values: number;
952
+
953
+ // ── scan caps ──
479
954
  /** raw docs distribution will scan before it reports an undercount */
480
955
  distribution: number;
481
956
  /** rollup docs distinctCount will scan before it reports an undercount */
@@ -492,7 +967,8 @@ export interface TimeRange {
492
967
 
493
968
  export interface RecordFilter {
494
969
  kind?: string;
495
- name?: string;
970
+ /** one event name, or a SET of them as an `$in` — a namespace or a family is several */
971
+ name?: string | string[];
496
972
  severity?: string;
497
973
  env?: string;
498
974
  service?: string;
@@ -630,6 +1106,28 @@ export interface Queries {
630
1106
  Promise<{ items: any[]; nextCursor: string | null; dataSource: 'raw' }>;
631
1107
  series(scope: string, range: TimeRange, filter: RecordFilter, opts?: { measure?: string; interval?: 'hour' | 'day' | 'week' | 'month' }):
632
1108
  Promise<{ buckets: Array<{ at: Date; value: number }>; dataSource: 'raw' }>;
1109
+ /**
1110
+ * Top groups of a measure by one or two dimensions. `groupBy` takes
1111
+ * `attr:<key>`, `field:<path>` (an allowlist of envelope paths), `subjectType`
1112
+ * or `actorType`; 0 or 3+ dims, an unlisted path, or a bad interval throw with
1113
+ * `status: 400`. Rows carry `at` only when an `interval` is given.
1114
+ *
1115
+ * `limit` caps the GROUPS returned, never the rows scanned — the scan is
1116
+ * bounded by the range and the indexes exactly as `series` is, and truncation
1117
+ * keeps the TOP groups by measure. A record missing the dim groups under
1118
+ * `null` rather than being dropped. Aggregates across tenants under `'*'`.
1119
+ *
1120
+ * Two truncation flags, because they cut different axes: `truncated` means
1121
+ * groups were dropped, `bucketsTruncated` that the per-interval pass hit its
1122
+ * own ceiling (`limits.series` buckets per returned group) and some group
1123
+ * shown is missing periods. `bucketsTruncated` is always false with no
1124
+ * `interval`.
1125
+ *
1126
+ * `sum:durationMs` / `avg:durationMs` read the ENVELOPE field, not
1127
+ * `metrics.durationMs` — a span's duration is not a declared metric.
1128
+ */
1129
+ breakdown(scope: string, range: TimeRange, filter: RecordFilter, opts: { groupBy: string[]; measure?: string; interval?: 'hour' | 'day' | 'week' | 'month'; limit?: number }):
1130
+ Promise<{ rows: Array<{ dims: (string | null)[]; at?: Date; value: number }>; groups: number; truncated: boolean; bucketsTruncated: boolean; dataSource: 'raw' }>;
633
1131
  /**
634
1132
  * The sample is complete — nothing is sampled away between the match and the
635
1133
  * math — but `$percentile` is `method: 'approximate'` and the scan stops at
@@ -678,17 +1176,75 @@ export declare function createQueries(ctx: {
678
1176
  cacheSize?: number;
679
1177
  }): Queries;
680
1178
 
1179
+ // ── values (reports §5) ─────────────────────────────────────────────────────
1180
+
1181
+ /**
1182
+ * The observed domain of one dimension — the lookup a report builder makes
1183
+ * before it names a value. NOT a tenth primitive: it reads the catalog, which
1184
+ * the primitives deliberately do not, and it answers from whichever of four
1185
+ * sources is cheapest.
1186
+ */
1187
+ export interface ValuesParams {
1188
+ /**
1189
+ * A `DimFacet.key` — `attr:model`, `field:client.platform`, `subjectType`,
1190
+ * `actorType`. The literal `'subject'` also works and is the only way to ask
1191
+ * a family for its subject refs.
1192
+ */
1193
+ dim: string;
1194
+ /** the Report's source events: decides the raw step, narrows the other two */
1195
+ names?: string[];
1196
+ /** required by the raw step; ignored by the others */
1197
+ range?: TimeRange;
1198
+ /** values cap, default `limits.values` (200), clamped to it */
1199
+ limit?: number;
1200
+ }
1201
+
1202
+ export interface ValuesResult {
1203
+ /** catalog order for a declared enum, else by count desc then value asc */
1204
+ values: string[];
1205
+ /** parallel to `values` when the source can count — absent for 'catalog' */
1206
+ counts?: number[];
1207
+ /**
1208
+ * Which of the four answered, in preference order: `catalog` (a declared
1209
+ * enum — no read at all), `rollups` (one indexed `$group` over a family keyed
1210
+ * by the dim), `raw` (an indexed attr or envelope dim over the range), or
1211
+ * `none`. `none` is an ANSWER, never an error: the caller offers free-text
1212
+ * equality with a scan badge.
1213
+ */
1214
+ source: 'catalog' | 'rollups' | 'raw' | 'none';
1215
+ /** the family read, when `source === 'rollups'` */
1216
+ via?: string;
1217
+ /** more values existed than the cap; the ones kept are the top by count */
1218
+ truncated: boolean;
1219
+ dataSource: 'catalog' | 'rollups' | 'raw' | 'none';
1220
+ }
1221
+
1222
+ export interface ValuesCtx {
1223
+ catalog: Catalog;
1224
+ TelemetryModel: Model<any>;
1225
+ RollupModel: Model<any>;
1226
+ limits?: Partial<QueryLimits>;
1227
+ onSlowQuery?: (info: { op: string; ms: number; params: unknown }) => void;
1228
+ slowMs?: number;
1229
+ cacheTtlMs?: number;
1230
+ cacheSize?: number;
1231
+ }
1232
+
1233
+ export type Values = (scope: string, params: ValuesParams) => Promise<ValuesResult>;
1234
+
1235
+ /** memoized like `series`; never throws on the `none` path */
1236
+ export declare function createValues(ctx: ValuesCtx): Values;
1237
+
681
1238
  export interface ViewSpec {
682
1239
  name: string;
683
1240
  icon?: string;
684
- page: 'errors' | 'traces' | 'events' | 'journeys' | 'usage' | 'overview' | 'system';
685
- query: {
686
- range?: string;
687
- filters?: Record<string, unknown>;
688
- groupBy?: string;
689
- sort?: string;
690
- display?: 'table' | 'series' | 'breakdown' | 'stream';
691
- };
1241
+ page: 'errors' | 'traces' | 'events' | 'journeys' | 'usage' | 'overview' | 'system' | 'explore';
1242
+ /**
1243
+ * A Report — or the pre-Report shape, which every stored view still carries
1244
+ * and `normalizeQuery()` lifts. `spec` is a Mixed document, so nothing has to
1245
+ * migrate: a query with no `source` is read as legacy.
1246
+ */
1247
+ query: Report | LegacyQuery;
692
1248
  }
693
1249
 
694
1250
  export interface ResolvedView extends ViewSpec {
@@ -698,8 +1254,13 @@ export interface ResolvedView extends ViewSpec {
698
1254
  shared?: boolean;
699
1255
  }
700
1256
 
701
- /** derived views — generated from the registry, zero config */
702
- export declare function deriveViews(registry: Registry): ResolvedView[];
1257
+ /**
1258
+ * Derived views — generated from the registry, zero config. Five shapes, every
1259
+ * one a Report: per event, per rollup family, per namespace, per usage event
1260
+ * that meters money, and one funnel per subject type. Pass the boot-time
1261
+ * catalog to skip re-deriving one.
1262
+ */
1263
+ export declare function deriveViews(registry: Registry, catalog?: Catalog): ResolvedView[];
703
1264
 
704
1265
  export interface Viewer {
705
1266
  /**