@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.
- package/README.md +3 -1
- package/dist/index.cjs +1583 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1567 -66
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs +1468 -78
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.js +1468 -78
- package/dist/mcp.js.map +1 -1
- package/dist/ui/_assets/{index-CXiDp_v6.css → index-COswHpSX.css} +1 -1
- package/dist/ui/_assets/index-kvwB9_3A.js +41 -0
- package/dist/ui/_assets/index-kvwB9_3A.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/types/index.d.ts +572 -11
- package/types/test-d.ts +193 -2
- package/dist/ui/_assets/index-3jcToTuP.js +0 -41
- package/dist/ui/_assets/index-3jcToTuP.js.map +0 -1
package/dist/ui/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="./_assets/index-
|
|
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
|
+
"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
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
/**
|
|
702
|
-
|
|
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
|
/**
|