@jeffjassky/telemetry 0.1.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/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/cli.cjs +310 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +307 -0
- package/dist/cli.js.map +1 -0
- package/dist/core.cjs +276 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.js +271 -0
- package/dist/core.js.map +1 -0
- package/dist/electron.cjs +287 -0
- package/dist/electron.cjs.map +1 -0
- package/dist/electron.js +282 -0
- package/dist/electron.js.map +1 -0
- package/dist/index.cjs +2394 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +2350 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +303 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +278 -0
- package/dist/react.js.map +1 -0
- package/dist/ui/_assets/index-3jcToTuP.js +41 -0
- package/dist/ui/_assets/index-3jcToTuP.js.map +1 -0
- package/dist/ui/_assets/index-CXiDp_v6.css +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/vue.cjs +267 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.js +262 -0
- package/dist/vue.js.map +1 -0
- package/dist/web.cjs +335 -0
- package/dist/web.cjs.map +1 -0
- package/dist/web.js +332 -0
- package/dist/web.js.map +1 -0
- package/package.json +126 -0
- package/types/cli.d.ts +20 -0
- package/types/core.d.ts +126 -0
- package/types/electron.d.ts +24 -0
- package/types/index.d.ts +763 -0
- package/types/react.d.ts +19 -0
- package/types/test-d.ts +687 -0
- package/types/vue.d.ts +17 -0
- package/types/web.d.ts +19 -0
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import type { Aggregate, Collection, Connection, Model, Mongoose, Query } from 'mongoose';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// ── vocabulary ──────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The vocabulary ships as `const` objects, so each name is a VALUE as well as a
|
|
8
|
+
* type: `TelemetryKind.Usage` in an expression, `TelemetryKind` in a type
|
|
9
|
+
* position. Declaring only the union would deny a value the package exports —
|
|
10
|
+
* working code that fails `tsc`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const TelemetryKind: {
|
|
13
|
+
readonly Event: 'event';
|
|
14
|
+
readonly Error: 'error';
|
|
15
|
+
readonly Span: 'span';
|
|
16
|
+
readonly State: 'state';
|
|
17
|
+
readonly Usage: 'usage';
|
|
18
|
+
};
|
|
19
|
+
export type TelemetryKind = (typeof TelemetryKind)[keyof typeof TelemetryKind];
|
|
20
|
+
|
|
21
|
+
export declare const LogLevel: {
|
|
22
|
+
readonly Debug: 'debug';
|
|
23
|
+
readonly Info: 'info';
|
|
24
|
+
readonly Warn: 'warn';
|
|
25
|
+
readonly Error: 'error';
|
|
26
|
+
readonly Fatal: 'fatal';
|
|
27
|
+
};
|
|
28
|
+
export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
|
|
29
|
+
|
|
30
|
+
export declare const Env: {
|
|
31
|
+
readonly Prod: 'prod';
|
|
32
|
+
readonly Staging: 'staging';
|
|
33
|
+
readonly Dev: 'dev';
|
|
34
|
+
};
|
|
35
|
+
export type Env = (typeof Env)[keyof typeof Env];
|
|
36
|
+
|
|
37
|
+
export declare const Origin: {
|
|
38
|
+
readonly Server: 'server';
|
|
39
|
+
readonly Client: 'client';
|
|
40
|
+
};
|
|
41
|
+
export type Origin = (typeof Origin)[keyof typeof Origin];
|
|
42
|
+
|
|
43
|
+
/** `type:id` — 'user:u_1', 'org:o_9', 'system:cron' */
|
|
44
|
+
export type EntityRef = `${string}:${string}`;
|
|
45
|
+
|
|
46
|
+
export declare const RETENTION_DAYS: Record<TelemetryKind, number | null>;
|
|
47
|
+
export declare const SAMPLE_RATE: Record<TelemetryKind, number>;
|
|
48
|
+
export declare const SCHEMA_VERSION: number;
|
|
49
|
+
export declare const INDEX_BUDGET: number;
|
|
50
|
+
/** `body` cap in characters (16384). Over it, the value is clipped and marked. */
|
|
51
|
+
export declare const BODY_MAX_CHARS: number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `'*'` — the dashboard's cross-tenant read scope. Set it as a `Viewer.tenantId`
|
|
55
|
+
* (from your own `viewerAdapter`, for viewers you have already authorized) and
|
|
56
|
+
* the query primitives drop the tenant term: a support console, a platform-wide
|
|
57
|
+
* cost page. Every row shape still carries its `tenantId`, so a cross-tenant
|
|
58
|
+
* number stays attributable.
|
|
59
|
+
*
|
|
60
|
+
* RESERVED on the write side, because a tenant literally named `'*'` would be a
|
|
61
|
+
* privilege escalation via a string: `emit()` quarantines it, `forget()` and
|
|
62
|
+
* `createKey()` (fixed mode) throw, and ingest refuses the batch whichever way
|
|
63
|
+
* the tenant resolved. `scoped()` does NOT honour it — that primitive's
|
|
64
|
+
* isolation guarantee is unconditional by design, so `scoped('*')` matches the
|
|
65
|
+
* literal string, which is to say nothing.
|
|
66
|
+
*/
|
|
67
|
+
export declare const PLATFORM_SCOPE: '*';
|
|
68
|
+
|
|
69
|
+
/** true when a scope is PLATFORM_SCOPE rather than a tenantId */
|
|
70
|
+
export declare function isPlatformScope(tenantId: unknown): boolean;
|
|
71
|
+
|
|
72
|
+
/** UUIDv7 — sortable, insertion-local. Never substitute crypto.randomUUID (v4). */
|
|
73
|
+
export declare function newId(): string;
|
|
74
|
+
|
|
75
|
+
/** Consistent per-trace sampling verdict. Throws in dev on an unsampleable traceId. */
|
|
76
|
+
export declare function traceKeep(traceId: string | undefined, rate: number): boolean;
|
|
77
|
+
|
|
78
|
+
/** Deep-converts Mongoose Maps to plain objects — JSON.stringify(Map) is '{}'. */
|
|
79
|
+
export declare function plain(v: unknown): unknown;
|
|
80
|
+
|
|
81
|
+
export declare function truncate(d: Date, bucket?: 'hour' | 'day' | 'week' | 'month'): Date | undefined;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read one dimension source off a record — the same resolution rollups and
|
|
85
|
+
* emit()'s burst cap use, exported so a host can key its own derived state the
|
|
86
|
+
* way the package keys its aggregates. `'subject'` is handled by fan-out, not
|
|
87
|
+
* here, so it resolves to `undefined`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function resolveDim(src: DimSource, doc: any): unknown;
|
|
90
|
+
|
|
91
|
+
export interface Logger {
|
|
92
|
+
info(...args: unknown[]): void;
|
|
93
|
+
warn(...args: unknown[]): void;
|
|
94
|
+
error(...args: unknown[]): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── registry ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** A dimension source: `subject` fans out over subject refs; the others read one value. */
|
|
100
|
+
export type DimSource = 'subject' | `attr:${string}` | `field:${string}`;
|
|
101
|
+
|
|
102
|
+
export interface RollupSpec {
|
|
103
|
+
/** rollup family — several event names may feed one. Default: the event name */
|
|
104
|
+
as?: string;
|
|
105
|
+
/** dimensions, in order. At most one `subject`. */
|
|
106
|
+
by: readonly DimSource[];
|
|
107
|
+
/** when `by` includes `subject`, restrict to these subject types (required then) */
|
|
108
|
+
subjects?: readonly string[];
|
|
109
|
+
/** actor TYPE allowlist — e.g. ['user','system'] keeps admin browsing out of customer aggregates */
|
|
110
|
+
actors?: readonly string[];
|
|
111
|
+
/** UTC time bucket. Omit for a lifetime rollup — the classic milestone. */
|
|
112
|
+
bucket?: 'hour' | 'day' | 'week' | 'month';
|
|
113
|
+
/** metric keys accumulated with $add */
|
|
114
|
+
sum?: readonly string[];
|
|
115
|
+
/** dimension sources snapshotted at FIRST occurrence — cohort dimensions */
|
|
116
|
+
capture?: readonly DimSource[];
|
|
117
|
+
/**
|
|
118
|
+
* Bucket name for a non-subject dim that resolves null/empty. Absent = skip
|
|
119
|
+
* the record and count it in `rollupSkipped`. May not contain `|` or `=`.
|
|
120
|
+
* Never applies to the subject dim.
|
|
121
|
+
*/
|
|
122
|
+
dimDefault?: string;
|
|
123
|
+
/** rollup TTL. Omit or null = immortal. */
|
|
124
|
+
retentionDays?: number | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface EventSpec {
|
|
128
|
+
kind: TelemetryKind;
|
|
129
|
+
origin: Origin | 'any';
|
|
130
|
+
/** subject TYPES that must be present on every record */
|
|
131
|
+
subjects: readonly string[];
|
|
132
|
+
/** attrs are STRING values — use z.string()/z.enum()/z.coerce.* */
|
|
133
|
+
attrs?: z.ZodObject<any>;
|
|
134
|
+
metrics?: z.ZodObject<any>;
|
|
135
|
+
/**
|
|
136
|
+
* `data` is UNSTORED unless declared. Closes the erasure hole. Declare an
|
|
137
|
+
* OBJECT schema or `boundedMeta()` — `EmitInput.data` is an object, so a
|
|
138
|
+
* scalar schema is expressible here and unreachable through emit().
|
|
139
|
+
*/
|
|
140
|
+
data?: z.ZodType<any>;
|
|
141
|
+
indexedAttrs?: readonly string[];
|
|
142
|
+
indexedMetrics?: readonly string[];
|
|
143
|
+
rollups?: readonly RollupSpec[];
|
|
144
|
+
/** overrides RETENTION_DAYS[kind]. null = immortal */
|
|
145
|
+
retentionDays?: number | null;
|
|
146
|
+
/** overrides SAMPLE_RATE[kind]. Dormant — everything ships at 1. */
|
|
147
|
+
sampleRate?: number;
|
|
148
|
+
/** cap RAW rows per resolved key per minute; rollups still see every record */
|
|
149
|
+
burst?: { key?: DimSource; maxPerMinute: number };
|
|
150
|
+
/** await the write with {w:'majority', j:true} and rethrow. usage is durable regardless. */
|
|
151
|
+
durable?: boolean;
|
|
152
|
+
description: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type Registry = Record<string, EventSpec>;
|
|
156
|
+
|
|
157
|
+
/** Identity with a `const` type parameter — literal specs keep their shapes. */
|
|
158
|
+
export declare function defineRegistry<const R extends Registry>(specs: R): R;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The maxed-shaped `data` escape hatch: scalars, ≤12 keys, ≤200-char strings,
|
|
162
|
+
* one nesting level, ≤4KB. Out of bounds drops the WHOLE object — never truncates.
|
|
163
|
+
*/
|
|
164
|
+
export declare function boundedMeta(): z.ZodType<Record<string, unknown> | undefined>;
|
|
165
|
+
|
|
166
|
+
/** Boot-time contract checks — throws on misconfiguration. createTelemetry runs it. */
|
|
167
|
+
export declare function validateRegistry(registry: Registry): void;
|
|
168
|
+
|
|
169
|
+
// ── typed emit ──────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export type AttrsOf<R extends Registry, N extends keyof R> =
|
|
172
|
+
R[N] extends { attrs: infer A extends z.ZodType<any> } ? z.infer<A> : Record<string, never>;
|
|
173
|
+
export type MetricsOf<R extends Registry, N extends keyof R> =
|
|
174
|
+
R[N] extends { metrics: infer M extends z.ZodType<any> } ? z.infer<M> : Record<string, never>;
|
|
175
|
+
|
|
176
|
+
export interface SubjectInput {
|
|
177
|
+
type: string;
|
|
178
|
+
id: string;
|
|
179
|
+
/** disambiguates same-type parties: sender | recipient | impersonated */
|
|
180
|
+
role?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ClientContext {
|
|
184
|
+
/** the builtins autocomplete; hosts extend the accepted set via CreateTelemetryConfig.platforms */
|
|
185
|
+
platform: 'web' | 'electron' | 'ios' | 'android' | 'server' | 'cli' | (string & {});
|
|
186
|
+
appVersion: string;
|
|
187
|
+
userAgent?: string;
|
|
188
|
+
os?: string;
|
|
189
|
+
osVersion?: string;
|
|
190
|
+
browser?: string;
|
|
191
|
+
browserVersion?: string;
|
|
192
|
+
deviceType?: string;
|
|
193
|
+
locale?: string;
|
|
194
|
+
timezone?: string;
|
|
195
|
+
screenW?: number;
|
|
196
|
+
screenH?: number;
|
|
197
|
+
viewportW?: number;
|
|
198
|
+
viewportH?: number;
|
|
199
|
+
connection?: string;
|
|
200
|
+
online?: boolean;
|
|
201
|
+
/** client clock minus server clock, ms */
|
|
202
|
+
clockSkewMs?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface EmitBase {
|
|
206
|
+
tenantId: string;
|
|
207
|
+
subjects?: SubjectInput[];
|
|
208
|
+
actor?: EntityRef;
|
|
209
|
+
onBehalfOf?: EntityRef;
|
|
210
|
+
occurredAt?: Date;
|
|
211
|
+
severity?: LogLevel;
|
|
212
|
+
service?: string;
|
|
213
|
+
release?: string;
|
|
214
|
+
env?: Env;
|
|
215
|
+
origin?: Origin;
|
|
216
|
+
client?: ClientContext;
|
|
217
|
+
traceId?: string;
|
|
218
|
+
spanId?: string;
|
|
219
|
+
parentId?: string;
|
|
220
|
+
durationMs?: number;
|
|
221
|
+
data?: Record<string, unknown>;
|
|
222
|
+
body?: string;
|
|
223
|
+
/** keep despite sampling — set automatically for money/errors */
|
|
224
|
+
forceKeep?: boolean;
|
|
225
|
+
/**
|
|
226
|
+
* Caller idempotency for event/state/span/error — trusted SERVER callers
|
|
227
|
+
* only. Non-empty, ≤200 chars. Implies forceKeep (the record's aggregation is
|
|
228
|
+
* gated on its own insert) and inverts the plane order: save first, roll up
|
|
229
|
+
* only if the insert won.
|
|
230
|
+
*/
|
|
231
|
+
dedupeKey?: string;
|
|
232
|
+
/** await the write with {w:'majority', j:true} and rethrow — overrides EventSpec.durable */
|
|
233
|
+
durable?: boolean;
|
|
234
|
+
error?: {
|
|
235
|
+
type: string;
|
|
236
|
+
message: string;
|
|
237
|
+
handled?: boolean;
|
|
238
|
+
fingerprint: string;
|
|
239
|
+
frames?: Array<{
|
|
240
|
+
filename?: string; fn?: string; lineno?: number; colno?: number;
|
|
241
|
+
inApp?: boolean; context?: string[];
|
|
242
|
+
}>;
|
|
243
|
+
};
|
|
244
|
+
state?: { key: string; from?: string; to: string; previousSinceMs?: number };
|
|
245
|
+
usage?: {
|
|
246
|
+
meter: string;
|
|
247
|
+
quantity: number;
|
|
248
|
+
unit: string;
|
|
249
|
+
/** authoritative money — pass a Decimal128-compatible value */
|
|
250
|
+
amount?: unknown;
|
|
251
|
+
currency?: string;
|
|
252
|
+
/** at-least-once dedupe, deterministic — e.g. `${traceId}:${spanId}` */
|
|
253
|
+
idempotencyKey: string;
|
|
254
|
+
billedTo: EntityRef;
|
|
255
|
+
billable?: boolean;
|
|
256
|
+
priceVersion?: string;
|
|
257
|
+
reverses?: string;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export type EmitInput<R extends Registry, N extends keyof R> = EmitBase & {
|
|
262
|
+
attrs?: AttrsOf<R, N>;
|
|
263
|
+
metrics?: MetricsOf<R, N>;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// ── the factory ─────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export interface TelemetryCounters {
|
|
269
|
+
rejected: number;
|
|
270
|
+
defaulted: number;
|
|
271
|
+
sampled: number;
|
|
272
|
+
capped: number;
|
|
273
|
+
rollupSkipped: number;
|
|
274
|
+
/** insert-gated writes whose dedupeKey / usage.idempotencyKey already existed */
|
|
275
|
+
deduped: number;
|
|
276
|
+
/** `body` values clipped to the cap — the row survives, marked */
|
|
277
|
+
truncated: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** What emit() did. `Promise<void>` could not distinguish "written" from "queued". */
|
|
281
|
+
export interface EmitResult {
|
|
282
|
+
/** the record _id — usable for correlation even when the row was not stored */
|
|
283
|
+
id: string;
|
|
284
|
+
/**
|
|
285
|
+
* written — BOTH planes are on disk and awaited: the row, and its rollups.
|
|
286
|
+
* Readable immediately, with no flush(). One meaning, on every
|
|
287
|
+
* path that returns it — durable specs, kind=usage, and
|
|
288
|
+
* insert-gated dedupeKey writes alike.
|
|
289
|
+
* A rollup that FAILS is quarantined and counted rather than
|
|
290
|
+
* thrown, as on every other path — awaiting an aggregate must not
|
|
291
|
+
* turn its failure into a report that the row does not exist.
|
|
292
|
+
* queued — validated and aggregated; the save is in flight, t.flush() awaits it
|
|
293
|
+
* deduped — dedupeKey already present: nothing written, nothing aggregated
|
|
294
|
+
* sampled — evidence plane declined; aggregates were still updated
|
|
295
|
+
* capped — burst cap declined; aggregates were still updated
|
|
296
|
+
* rejected — unregistered or failed validation; quarantined in the rejects collection
|
|
297
|
+
*/
|
|
298
|
+
outcome: 'written' | 'queued' | 'deduped' | 'sampled' | 'capped' | 'rejected';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface Checkpoint {
|
|
302
|
+
/** null on the first ever run */
|
|
303
|
+
get(): Promise<Date | null>;
|
|
304
|
+
advance(at: Date): Promise<void>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface ForgetResult {
|
|
308
|
+
deleted: number;
|
|
309
|
+
redacted: number;
|
|
310
|
+
rollups: number;
|
|
311
|
+
aliases: number;
|
|
312
|
+
views: number;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface Scoped {
|
|
316
|
+
find(q?: Record<string, unknown>): Query<any[], any>;
|
|
317
|
+
aggregate(stages: Record<string, unknown>[]): Aggregate<any[]>;
|
|
318
|
+
rollups(q?: Record<string, unknown>): Query<any[], any>;
|
|
319
|
+
rollupAggregate(stages: Record<string, unknown>[]): Aggregate<any[]>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface CreateTelemetryConfig<R extends Registry = Registry> {
|
|
323
|
+
/** the host-owned event registry — see defineRegistry() */
|
|
324
|
+
registry: R;
|
|
325
|
+
/** a mongoose Connection, or the mongoose module itself */
|
|
326
|
+
connection: Connection | Mongoose;
|
|
327
|
+
/** base collection; six siblings derive: `<collection>_rollups`, `_rejects`,
|
|
328
|
+
* `_aliases`, `_checkpoints`, `_keys`, `_views` */
|
|
329
|
+
collection?: string;
|
|
330
|
+
/** set when two instances share one connection — traps #2 */
|
|
331
|
+
modelName?: string;
|
|
332
|
+
/** secret pepper for forget()'s rekeying. Falls back to TELEMETRY_PEPPER. */
|
|
333
|
+
pepper?: string;
|
|
334
|
+
/** EXTENDS the builtin `client.platform` list — never replaces it */
|
|
335
|
+
platforms?: readonly string[];
|
|
336
|
+
/** override BODY_MAX_CHARS for this instance */
|
|
337
|
+
bodyMax?: number;
|
|
338
|
+
/**
|
|
339
|
+
* Declares that a subject ref (`type:id`) names the same party in EVERY
|
|
340
|
+
* tenant. Only effect today: forget() also erases the person's
|
|
341
|
+
* platform-scoped saved views, which a tenant-scoped call otherwise misses.
|
|
342
|
+
* Leave it off when ids are minted per tenant — there, `user:u_1` is a
|
|
343
|
+
* different person in each, and one tenant's erasure would reach another's.
|
|
344
|
+
*/
|
|
345
|
+
globalSubjectRefs?: boolean;
|
|
346
|
+
logger?: Logger;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface Telemetry<R extends Registry = Registry> {
|
|
350
|
+
/** write — the only write. The result says what actually happened to the row. */
|
|
351
|
+
emit<N extends keyof R & string>(name: N, doc: EmitInput<R, N>): Promise<EmitResult>;
|
|
352
|
+
/**
|
|
353
|
+
* Erasure: delete sole-party rows, redact shared ones, rekey rollups, drop
|
|
354
|
+
* aliases. Tenant-scoped — rejects PLATFORM_SCOPE, so a platform-wide erasure
|
|
355
|
+
* is N calls that each name their tenant. Reaches the person's
|
|
356
|
+
* platform-scoped saved views only when `globalSubjectRefs` is set.
|
|
357
|
+
*/
|
|
358
|
+
forget(tenantId: string, ref: EntityRef): Promise<ForgetResult>;
|
|
359
|
+
/**
|
|
360
|
+
* Tenant scope is not optional — every read goes through here. Unconditional
|
|
361
|
+
* on purpose: it does not understand PLATFORM_SCOPE, so `scoped('*')` scopes
|
|
362
|
+
* to the literal '*' and matches nothing. Cross-tenant reads live in the
|
|
363
|
+
* dashboard query layer, behind `viewerAdapter`.
|
|
364
|
+
*/
|
|
365
|
+
scoped(tenantId: string): Scoped;
|
|
366
|
+
/** pull-importer watermark — advisory; downstream writers must be idempotent */
|
|
367
|
+
checkpoint(key: string): Checkpoint;
|
|
368
|
+
/** boot: declared + registry-driven indexes. Await before first write — traps #3. */
|
|
369
|
+
syncIndexes(): Promise<void>;
|
|
370
|
+
/** await in-flight fire-and-forget writes (tests, graceful shutdown) */
|
|
371
|
+
flush(): Promise<void>;
|
|
372
|
+
/** drop/default/cap counts — surface on /metrics so drops are never silent */
|
|
373
|
+
counters: TelemetryCounters;
|
|
374
|
+
/** the registry this instance validates against */
|
|
375
|
+
registry: R;
|
|
376
|
+
logger: Logger;
|
|
377
|
+
/** mint an ingest key against this instance's key collection */
|
|
378
|
+
createKey(input: CreateKeyInput): Promise<{ key: string; id: string }>;
|
|
379
|
+
models: {
|
|
380
|
+
telemetry: Model<any>;
|
|
381
|
+
byKind: Record<TelemetryKind, Model<any>>;
|
|
382
|
+
rollups: Model<any>;
|
|
383
|
+
checkpoints: Model<any>;
|
|
384
|
+
keys: Model<any>;
|
|
385
|
+
};
|
|
386
|
+
collections: {
|
|
387
|
+
rejects(): Collection;
|
|
388
|
+
aliases(): Collection;
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export declare function createTelemetry<const R extends Registry>(
|
|
393
|
+
config: CreateTelemetryConfig<R>,
|
|
394
|
+
): Telemetry<R>;
|
|
395
|
+
|
|
396
|
+
// ── keys (instrumentation §2) ───────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
/** `const` + type twin, like the vocabulary above — `KeyKind.Secret` is a value */
|
|
399
|
+
export declare const KeyKind: {
|
|
400
|
+
readonly Publishable: 'publishable';
|
|
401
|
+
readonly Secret: 'secret';
|
|
402
|
+
};
|
|
403
|
+
export type KeyKind = (typeof KeyKind)[keyof typeof KeyKind];
|
|
404
|
+
|
|
405
|
+
/** fixed: the key carries tenantId · session: the host resolves it · claimed: the payload asserts it (sk_ only) */
|
|
406
|
+
export declare const TenantMode: {
|
|
407
|
+
readonly Fixed: 'fixed';
|
|
408
|
+
readonly Session: 'session';
|
|
409
|
+
readonly Claimed: 'claimed';
|
|
410
|
+
};
|
|
411
|
+
export type TenantMode = (typeof TenantMode)[keyof typeof TenantMode];
|
|
412
|
+
|
|
413
|
+
export interface ParsedKey {
|
|
414
|
+
kind: KeyKind;
|
|
415
|
+
label: string;
|
|
416
|
+
id: string;
|
|
417
|
+
secret?: string;
|
|
418
|
+
}
|
|
419
|
+
export declare function parseKeyString(raw: string | undefined): ParsedKey | null;
|
|
420
|
+
/** versioned scrypt — a param change bumps the prefix, old hashes keep verifying */
|
|
421
|
+
export declare function hashSecret(secret: string): string;
|
|
422
|
+
/** constant-time comparison */
|
|
423
|
+
export declare function verifySecret(secret: string, stored: string | undefined): boolean;
|
|
424
|
+
|
|
425
|
+
export interface CreateKeyInput {
|
|
426
|
+
kind: KeyKind;
|
|
427
|
+
tenantMode: TenantMode;
|
|
428
|
+
tenantId?: string;
|
|
429
|
+
service: string;
|
|
430
|
+
env: string;
|
|
431
|
+
label?: string;
|
|
432
|
+
origins?: string[];
|
|
433
|
+
allowedKinds?: string[];
|
|
434
|
+
allowedNames?: string[];
|
|
435
|
+
maxPerMinute?: number;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Mint a key. The full string is returned ONCE — only the secret's hash is stored. */
|
|
439
|
+
export declare function createKey(
|
|
440
|
+
KeyModel: Model<any>,
|
|
441
|
+
input: CreateKeyInput,
|
|
442
|
+
): Promise<{ key: string; id: string }>;
|
|
443
|
+
|
|
444
|
+
// ── ingest (instrumentation §3–4) ───────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
export interface IngestContext {
|
|
447
|
+
tenantId: string;
|
|
448
|
+
subjects?: Array<SubjectInput>;
|
|
449
|
+
actor?: string;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export interface ContextAdapter {
|
|
453
|
+
/** INBOUND: who is making this request? Only consulted for tenantMode=session. */
|
|
454
|
+
resolveContext(req: unknown): IngestContext | null | Promise<IngestContext | null>;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export interface CreateIngestOptions {
|
|
458
|
+
telemetry: Telemetry<any>;
|
|
459
|
+
contextAdapter?: ContextAdapter;
|
|
460
|
+
maxRecords?: number;
|
|
461
|
+
bodyLimit?: string;
|
|
462
|
+
keyCacheMs?: number;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The wire endpoint — an express Router the host mounts. Batch-only,
|
|
467
|
+
* insert-gated rollups, pk_ never 4xxes, sk_ gets honest errors.
|
|
468
|
+
*/
|
|
469
|
+
export declare function createIngest(opts: CreateIngestOptions): import('express').Router;
|
|
470
|
+
|
|
471
|
+
// ── dashboard (dashboards §2–§8) ────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
export interface QueryLimits {
|
|
474
|
+
records: number;
|
|
475
|
+
series: number;
|
|
476
|
+
rollups: number;
|
|
477
|
+
trace: number;
|
|
478
|
+
journey: number;
|
|
479
|
+
/** raw docs distribution will scan before it reports an undercount */
|
|
480
|
+
distribution: number;
|
|
481
|
+
/** rollup docs distinctCount will scan before it reports an undercount */
|
|
482
|
+
distinct: number;
|
|
483
|
+
/** subjects in one funnel cohort */
|
|
484
|
+
funnel: number;
|
|
485
|
+
}
|
|
486
|
+
export declare const DEFAULT_LIMITS: QueryLimits;
|
|
487
|
+
|
|
488
|
+
export interface TimeRange {
|
|
489
|
+
from: Date;
|
|
490
|
+
to: Date;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface RecordFilter {
|
|
494
|
+
kind?: string;
|
|
495
|
+
name?: string;
|
|
496
|
+
severity?: string;
|
|
497
|
+
env?: string;
|
|
498
|
+
service?: string;
|
|
499
|
+
release?: string;
|
|
500
|
+
subject?: string;
|
|
501
|
+
traceId?: string;
|
|
502
|
+
attrs?: Record<string, string>;
|
|
503
|
+
metrics?: Record<string, { gte?: number; lte?: number }>;
|
|
504
|
+
/** the customer toggle: exclude these actor TYPES ('admin', 'system') */
|
|
505
|
+
excludeActorTypes?: string[];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── cohort math (cohort-math §1) ────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
export interface FunnelStageSpec {
|
|
511
|
+
/** the lifetime rollup family whose doc marks this stage — `firstAt` IS the timestamp */
|
|
512
|
+
as: string;
|
|
513
|
+
/** stable identifier in the response. Default: `as` */
|
|
514
|
+
key?: string;
|
|
515
|
+
label?: string;
|
|
516
|
+
description?: string;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export interface FunnelCohortWindow extends TimeRange {
|
|
520
|
+
/**
|
|
521
|
+
* Include `to` itself. Default FALSE — the package is half-open everywhere.
|
|
522
|
+
* maxed's funnel is closed on both ends, so a host migrating off it sets this.
|
|
523
|
+
*/
|
|
524
|
+
endInclusive?: boolean;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export interface FunnelParams {
|
|
528
|
+
stages: readonly FunnelStageSpec[];
|
|
529
|
+
/** the milestone that assigns cohort membership and anchors time-to-step. Default: `stages[0].as` */
|
|
530
|
+
anchor?: string;
|
|
531
|
+
cohort: FunnelCohortWindow;
|
|
532
|
+
/** exit families — counted, never staged */
|
|
533
|
+
exits?: readonly FunnelStageSpec[];
|
|
534
|
+
subjectType?: string;
|
|
535
|
+
/** also slice the cohort by anchor date. UTC, Monday-start weeks. */
|
|
536
|
+
interval?: 'day' | 'week' | 'month';
|
|
537
|
+
limit?: number;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export interface FunnelStageResult {
|
|
541
|
+
order: number;
|
|
542
|
+
key: string;
|
|
543
|
+
as: string;
|
|
544
|
+
label: string;
|
|
545
|
+
description?: string;
|
|
546
|
+
/** subjects with this stage present. NOT monotonic — the funnel is literal, never backfilled. */
|
|
547
|
+
subjects: number;
|
|
548
|
+
/** 0–100, unrounded. null when stage 1's count is 0. */
|
|
549
|
+
pctOfFirst: number | null;
|
|
550
|
+
/** null on stage 1 and whenever the previous count is 0 — never 0, never Infinity. */
|
|
551
|
+
pctOfPrevious: number | null;
|
|
552
|
+
/** fractional days, unrounded. null on an empty sample. */
|
|
553
|
+
medianDaysFromAnchor: number | null;
|
|
554
|
+
medianDaysFromPrevious: number | null;
|
|
555
|
+
/** reached the PREVIOUS stage and not this one. 0 on stage 1. */
|
|
556
|
+
notReached: number;
|
|
557
|
+
/** reached this stage and not the NEXT. null on the terminal stage — there is nowhere to stall. */
|
|
558
|
+
stalledAt: number | null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export interface FunnelExitResult {
|
|
562
|
+
key: string;
|
|
563
|
+
as: string;
|
|
564
|
+
label: string;
|
|
565
|
+
subjects: number;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export interface FunnelSlice {
|
|
569
|
+
/** the truncated anchor date — a UTC bucket start, not a '2026-W31' label */
|
|
570
|
+
at: Date;
|
|
571
|
+
subjects: number;
|
|
572
|
+
stages: FunnelStageResult[];
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export interface FunnelResult {
|
|
576
|
+
cohortSubjects: number;
|
|
577
|
+
/** |{ s : stage 1 present }| — the pctOfFirst denominator */
|
|
578
|
+
first: number;
|
|
579
|
+
stages: FunnelStageResult[];
|
|
580
|
+
exits: FunnelExitResult[];
|
|
581
|
+
/** present only when `interval` was asked for; ascending by `at` */
|
|
582
|
+
slices: FunnelSlice[] | null;
|
|
583
|
+
/** the cohort read hit its cap — every number is an UNDERCOUNT */
|
|
584
|
+
truncated: boolean;
|
|
585
|
+
cohort: { from: Date; to: Date; endInclusive: boolean; anchor: string };
|
|
586
|
+
dataSource: 'rollups';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** one subject's assembled milestone index — what summarizeStages reasons over */
|
|
590
|
+
export interface CohortSubject {
|
|
591
|
+
ref: string;
|
|
592
|
+
anchorAt: Date | null;
|
|
593
|
+
/** stage key → first occurrence */
|
|
594
|
+
stages: Record<string, Date>;
|
|
595
|
+
exits: Record<string, Date>;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Mean of the two middles on even counts. Empty set is null, never 0. No rounding. */
|
|
599
|
+
export declare function median(values: readonly number[]): number | null;
|
|
600
|
+
|
|
601
|
+
/** the stage table, pure — same input, same output, no Mongo */
|
|
602
|
+
export declare function summarizeStages(
|
|
603
|
+
subjects: readonly CohortSubject[],
|
|
604
|
+
stages: readonly { order: number; key: string; as: string; label: string; description?: string }[],
|
|
605
|
+
): FunnelStageResult[];
|
|
606
|
+
|
|
607
|
+
/** the first declaration of a rollup family — validateRegistry pins the shape, so it speaks for all */
|
|
608
|
+
export declare function findFamily(
|
|
609
|
+
registry: Registry,
|
|
610
|
+
as: string,
|
|
611
|
+
): { name: string; spec: RollupSpec } | null;
|
|
612
|
+
|
|
613
|
+
/** throws unless `as` is a LIFETIME family keyed by exactly one subject dim */
|
|
614
|
+
export declare function requireMilestoneFamily(
|
|
615
|
+
registry: Registry,
|
|
616
|
+
as: string,
|
|
617
|
+
primitive: string,
|
|
618
|
+
): RollupSpec;
|
|
619
|
+
|
|
620
|
+
/** the six read primitives — everything the UI renders comes through these */
|
|
621
|
+
export interface Queries {
|
|
622
|
+
/**
|
|
623
|
+
* `scope` is a tenantId, or PLATFORM_SCOPE ('*') for a cross-tenant read.
|
|
624
|
+
* Under '*' the tenant term is dropped and nothing else changes: the time
|
|
625
|
+
* range is still mandatory, the caps still apply, and every row still carries
|
|
626
|
+
* its own `tenantId`. `series` and `distribution` aggregate ACROSS tenants
|
|
627
|
+
* under '*' — the platform-wide chart, by design.
|
|
628
|
+
*/
|
|
629
|
+
records(scope: string, range: TimeRange, filter?: RecordFilter, opts?: { limit?: number; cursor?: string }):
|
|
630
|
+
Promise<{ items: any[]; nextCursor: string | null; dataSource: 'raw' }>;
|
|
631
|
+
series(scope: string, range: TimeRange, filter: RecordFilter, opts?: { measure?: string; interval?: 'hour' | 'day' | 'week' | 'month' }):
|
|
632
|
+
Promise<{ buckets: Array<{ at: Date; value: number }>; dataSource: 'raw' }>;
|
|
633
|
+
/**
|
|
634
|
+
* The sample is complete — nothing is sampled away between the match and the
|
|
635
|
+
* math — but `$percentile` is `method: 'approximate'` and the scan stops at
|
|
636
|
+
* `limits.distribution`. `truncated` says when that ceiling was reached; the
|
|
637
|
+
* percentile keys are absent on an empty match, `truncated` never is.
|
|
638
|
+
*/
|
|
639
|
+
distribution(scope: string, range: TimeRange, filter: RecordFilter, opts?: { measure?: string }):
|
|
640
|
+
Promise<Record<string, unknown> & { n: number; truncated: boolean; dataSource: 'raw' }>;
|
|
641
|
+
/**
|
|
642
|
+
* `dims` accepts several values as an `$in` — one read for N subjects instead
|
|
643
|
+
* of N reads. `on` picks the field `range` filters (default: bucketAt when
|
|
644
|
+
* bucketed, lastAt otherwise); cohort selection wants `firstAt`. The range is
|
|
645
|
+
* half-open either way, and `truncated` says when the cap was actually hit.
|
|
646
|
+
*/
|
|
647
|
+
rollups(scope: string, params: { as: string; dims?: string | string[]; subjectType?: string; on?: 'firstAt' | 'lastAt' | 'bucketAt'; range?: TimeRange; sort?: 'count' | 'lastAt' | 'firstAt' | 'bucketAt'; limit?: number }):
|
|
648
|
+
Promise<{ rows: any[]; bucketed: boolean; truncated: boolean; dataSource: 'rollups' }>;
|
|
649
|
+
trace(scope: string, traceId: string): Promise<{ items: any[]; dataSource: 'raw' }>;
|
|
650
|
+
journey(scope: string, subjectRef: string, range: TimeRange, opts?: { limit?: number }):
|
|
651
|
+
Promise<{ records: any[]; milestones: any[]; dataSource: 'raw+rollups' }>;
|
|
652
|
+
/**
|
|
653
|
+
* Distinct subjects per bucket and over the range — DAU/MAU, exact, no sketch.
|
|
654
|
+
* A family declared `by: ['subject']` with a bucket already writes one doc per
|
|
655
|
+
* (subject, bucket), so the doc count IS the distinct count.
|
|
656
|
+
*
|
|
657
|
+
* THROWS when the named family has no subject dim or no bucket: that is a
|
|
658
|
+
* registry mistake, and a plausible wrong number is the failure mode this
|
|
659
|
+
* package exists to prevent.
|
|
660
|
+
*/
|
|
661
|
+
distinctCount(scope: string, params: { as: string; subjectType?: string; range: TimeRange; interval?: 'hour' | 'day' | 'week' | 'month' }):
|
|
662
|
+
Promise<{ buckets: Array<{ at: Date; value: number }>; distinct: number; interval: 'hour' | 'day' | 'week' | 'month'; truncated: boolean; dataSource: 'rollups' }>;
|
|
663
|
+
/** cohort funnel over lifetime milestone families — counts, conversion, median time-to-step */
|
|
664
|
+
funnel(scope: string, params: FunnelParams): Promise<FunnelResult>;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export declare function createQueries(ctx: {
|
|
668
|
+
TelemetryModel: Model<any>;
|
|
669
|
+
RollupModel: Model<any>;
|
|
670
|
+
registry: Registry;
|
|
671
|
+
limits?: Partial<QueryLimits>;
|
|
672
|
+
onSlowQuery?: (info: { op: string; ms: number; params: unknown }) => void;
|
|
673
|
+
/** threshold onSlowQuery fires above, ms. Default 500. */
|
|
674
|
+
slowMs?: number;
|
|
675
|
+
/** in-process result cache TTL, ms. Default 600_000 — ten minutes. */
|
|
676
|
+
cacheTtlMs?: number;
|
|
677
|
+
/** cached entries kept before the oldest is evicted. Default 60. */
|
|
678
|
+
cacheSize?: number;
|
|
679
|
+
}): Queries;
|
|
680
|
+
|
|
681
|
+
export interface ViewSpec {
|
|
682
|
+
name: string;
|
|
683
|
+
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
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export interface ResolvedView extends ViewSpec {
|
|
695
|
+
origin: 'derived' | 'configured' | 'saved';
|
|
696
|
+
id?: string;
|
|
697
|
+
ownerRef?: string;
|
|
698
|
+
shared?: boolean;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** derived views — generated from the registry, zero config */
|
|
702
|
+
export declare function deriveViews(registry: Registry): ResolvedView[];
|
|
703
|
+
|
|
704
|
+
export interface Viewer {
|
|
705
|
+
/**
|
|
706
|
+
* The read scope: a tenantId, or PLATFORM_SCOPE (`'*'`) to read across every
|
|
707
|
+
* tenant — a support console, a platform-wide cost page.
|
|
708
|
+
*
|
|
709
|
+
* Returning `'*'` IS the authorization decision, and it is yours: the package
|
|
710
|
+
* never infers platform admin from a role, a header, or a config flag. It
|
|
711
|
+
* only makes the escape hatch expressible, so a host that needs a
|
|
712
|
+
* cross-tenant read declares it here instead of reaching around `scoped()`
|
|
713
|
+
* with a raw model. `'*'` is reserved on the write side, so no stored row
|
|
714
|
+
* carries it and no tenant can ever be named it.
|
|
715
|
+
*
|
|
716
|
+
* Saved views scope on this string LITERALLY, `'*'` included: a platform
|
|
717
|
+
* viewer's views are invisible to every tenant and vice versa, and neither
|
|
718
|
+
* can delete the other's. `'*'` reads telemetry across tenants; it is not a
|
|
719
|
+
* master key to other people's saved state.
|
|
720
|
+
*/
|
|
721
|
+
tenantId: string;
|
|
722
|
+
/** 'admin' unlocks System writes (key revoke) — within this scope */
|
|
723
|
+
role: string;
|
|
724
|
+
/** owns saved views, e.g. 'user:u_1' */
|
|
725
|
+
viewerRef?: string;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export interface ViewerAdapter {
|
|
729
|
+
/** INBOUND: who may look, and how widely? Construction fails without this. */
|
|
730
|
+
resolveViewer(req: unknown): Viewer | null | Promise<Viewer | null>;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export interface SubjectAdapter {
|
|
734
|
+
/** pretty labels for subject refs; absent refs render raw */
|
|
735
|
+
describe(refs: string[]): Promise<Record<string, { label: string; href?: string }>>;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export interface CreateDashboardOptions {
|
|
739
|
+
telemetry: Telemetry<any>;
|
|
740
|
+
viewerAdapter: ViewerAdapter;
|
|
741
|
+
subjectAdapter?: SubjectAdapter;
|
|
742
|
+
/** configured views — versioned in host code */
|
|
743
|
+
views?: ViewSpec[];
|
|
744
|
+
queryLimits?: Partial<QueryLimits>;
|
|
745
|
+
onSlowQuery?: (info: { op: string; ms: number; params: unknown }) => void;
|
|
746
|
+
/** threshold onSlowQuery fires above, ms. Default 500. Forwarded to createQueries. */
|
|
747
|
+
slowMs?: number;
|
|
748
|
+
/** in-process query cache TTL, ms. Default 600_000. Forwarded to createQueries. */
|
|
749
|
+
cacheTtlMs?: number;
|
|
750
|
+
/** cached query results kept before eviction. Default 60. Forwarded to createQueries. */
|
|
751
|
+
cacheSize?: number;
|
|
752
|
+
/** where the browser sees this router mounted — MUST match (traps #8) */
|
|
753
|
+
mountPath?: string;
|
|
754
|
+
apiBase?: string;
|
|
755
|
+
title?: string;
|
|
756
|
+
spaDir?: string;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** /api/* (five primitives, views, system) + the built SPA with hashed assets */
|
|
760
|
+
export declare function createDashboard(opts: CreateDashboardOptions): import('express').Router;
|
|
761
|
+
|
|
762
|
+
/** the bundled SPA directory — resolves dist/ui in builds and source runs */
|
|
763
|
+
export declare function defaultSpaDir(): string;
|