@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/dist/index.cjs ADDED
@@ -0,0 +1,2394 @@
1
+ 'use strict';
2
+
3
+ var uuidv7 = require('uuidv7');
4
+ var zod = require('zod');
5
+ var mongoose = require('mongoose');
6
+ var crypto = require('crypto');
7
+ var express2 = require('express');
8
+ var fs = require('fs');
9
+ var path2 = require('path');
10
+ var url = require('url');
11
+
12
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
13
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
14
+
15
+ var express2__default = /*#__PURE__*/_interopDefault(express2);
16
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
17
+ var path2__default = /*#__PURE__*/_interopDefault(path2);
18
+
19
+ // src/server/types.ts
20
+ var newId = uuidv7.uuidv7;
21
+ var TelemetryKind = {
22
+ Event: "event",
23
+ Error: "error",
24
+ Span: "span",
25
+ State: "state",
26
+ Usage: "usage"
27
+ };
28
+ var TELEMETRY_KINDS = Object.values(TelemetryKind);
29
+ var LogLevel = {
30
+ Debug: "debug",
31
+ Info: "info",
32
+ Warn: "warn",
33
+ Error: "error",
34
+ Fatal: "fatal"
35
+ };
36
+ var Env = { Prod: "prod", Staging: "staging", Dev: "dev" };
37
+ var Origin = { Server: "server", Client: "client" };
38
+ var UNKNOWN = "unknown";
39
+ var PLATFORM_SCOPE = "*";
40
+ var isPlatformScope = (tenantId) => tenantId === PLATFORM_SCOPE;
41
+ var RESERVED_TENANT_MESSAGE = `telemetry: tenantId "${PLATFORM_SCOPE}" is reserved for the dashboard's cross-tenant platform scope and may never be written`;
42
+ var RETENTION_DAYS = {
43
+ // keep-all + 90d is cheap at this scale, and it makes p95-by-route a raw
44
+ // query instead of a rollup design (schema §2.1)
45
+ [TelemetryKind.Span]: 90,
46
+ [TelemetryKind.Error]: 90,
47
+ [TelemetryKind.Event]: 730,
48
+ [TelemetryKind.State]: 730,
49
+ [TelemetryKind.Usage]: null
50
+ // money is immortal
51
+ };
52
+ var SAMPLE_RATE = {
53
+ [TelemetryKind.Span]: 1,
54
+ [TelemetryKind.Event]: 1,
55
+ [TelemetryKind.Error]: 1,
56
+ // raw storage burst-capped per fingerprint instead
57
+ [TelemetryKind.State]: 1,
58
+ [TelemetryKind.Usage]: 1
59
+ // NEVER sample. money.
60
+ };
61
+ var REJECT_TTL_DAYS = 30;
62
+ var SCHEMA_VERSION = 2;
63
+ var BODY_MAX_CHARS = 16384;
64
+ var newCounters = () => ({
65
+ rejected: 0,
66
+ defaulted: 0,
67
+ sampled: 0,
68
+ capped: 0,
69
+ rollupSkipped: 0,
70
+ deduped: 0,
71
+ truncated: 0
72
+ });
73
+ var traceKeep = (traceId, rate) => {
74
+ if (rate >= 1) return true;
75
+ if (!traceId) return Math.random() < rate;
76
+ const tail = parseInt(traceId.slice(-8), 16);
77
+ if (!Number.isFinite(tail)) {
78
+ if (process.env.NODE_ENV !== "production") {
79
+ throw new Error(
80
+ `telemetry: traceId "${traceId}" has no parseable hex tail \u2014 trace-consistent sampling is impossible and would degrade to per-record. Use UUIDv7 or a 32-hex OTel trace id.`
81
+ );
82
+ }
83
+ return Math.random() < rate;
84
+ }
85
+ return tail / 4294967295 < rate;
86
+ };
87
+ var plain = (v) => v instanceof Map ? Object.fromEntries([...v].map(([k, x]) => [k, plain(x)])) : Array.isArray(v) ? v.map(plain) : v instanceof Date ? v : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)])) : v;
88
+ var noopLogger = { info() {
89
+ }, warn() {
90
+ }, error() {
91
+ } };
92
+ function defineRegistry(specs) {
93
+ return specs;
94
+ }
95
+ function boundedMeta() {
96
+ const scalar = (v) => v === null || typeof v === "boolean" || typeof v === "number" && Number.isFinite(v) || typeof v === "string" && v.length <= 200;
97
+ const flat = (v) => {
98
+ if (scalar(v)) return true;
99
+ if (Array.isArray(v)) return v.length <= 20 && v.every(scalar);
100
+ if (v && typeof v === "object" && !(v instanceof Date)) {
101
+ const entries = Object.entries(v);
102
+ return entries.length <= 12 && entries.every(([, x]) => scalar(x));
103
+ }
104
+ return false;
105
+ };
106
+ const bounded = (v) => {
107
+ if (!v || typeof v !== "object" || Array.isArray(v) || v instanceof Date) return false;
108
+ const entries = Object.entries(v);
109
+ if (entries.length > 12) return false;
110
+ if (!entries.every(([, x]) => flat(x))) return false;
111
+ try {
112
+ return JSON.stringify(v).length <= 4096;
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+ return zod.z.unknown().transform((v) => bounded(v) ? v : void 0);
118
+ }
119
+ var DIM_RE = /^(subject|attr:.+|field:.+)$/;
120
+ function validateRegistry(registry) {
121
+ const shapes = /* @__PURE__ */ new Map();
122
+ for (const [name, spec] of Object.entries(registry)) {
123
+ const fail = (msg) => {
124
+ throw new Error(`telemetry: registry "${name}": ${msg}`);
125
+ };
126
+ if (!TELEMETRY_KINDS.includes(spec.kind)) fail(`unknown kind "${spec.kind}"`);
127
+ if (!["server", "client", "any"].includes(spec.origin)) fail(`unknown origin "${spec.origin}"`);
128
+ if (!Array.isArray(spec.subjects)) fail("`subjects` must be an array of subject types");
129
+ if (spec.sampleRate != null && !(spec.sampleRate > 0 && spec.sampleRate <= 1)) {
130
+ fail(`sampleRate ${spec.sampleRate} outside (0, 1]`);
131
+ }
132
+ if (spec.burst && !(spec.burst.maxPerMinute > 0)) fail("burst.maxPerMinute must be positive");
133
+ if (spec.burst?.key && !DIM_RE.test(spec.burst.key)) fail(`bad burst key "${spec.burst.key}"`);
134
+ for (const k of spec.indexedAttrs ?? []) {
135
+ if (!spec.attrs || !(k in spec.attrs.shape)) fail(`indexedAttrs "${k}" not declared in attrs`);
136
+ }
137
+ for (const k of spec.indexedMetrics ?? []) {
138
+ if (!spec.metrics || !(k in spec.metrics.shape)) fail(`indexedMetrics "${k}" not declared in metrics`);
139
+ }
140
+ for (const r of spec.rollups ?? []) {
141
+ if (!r.by?.length) fail("rollup has empty `by`");
142
+ for (const d of r.by) if (!DIM_RE.test(d)) fail(`bad dim source "${d}"`);
143
+ if (r.by.filter((d) => d === "subject").length > 1) {
144
+ fail("rollup has more than one subject dim");
145
+ }
146
+ if (r.by.includes("subject") && !r.subjects?.length) {
147
+ fail("rollup uses subject without `subjects`");
148
+ }
149
+ if (r.actors && !r.actors.length) fail("rollup `actors` must be non-empty when present");
150
+ if (r.dimDefault !== void 0) {
151
+ if (typeof r.dimDefault !== "string" || !r.dimDefault) {
152
+ fail("rollup `dimDefault` must be a non-empty string");
153
+ }
154
+ if (/[|=]/.test(r.dimDefault)) fail(`rollup dimDefault "${r.dimDefault}" may not contain "|" or "="`);
155
+ }
156
+ const as = r.as ?? name;
157
+ const shape = r.by.join(",") + "|" + (r.bucket ?? "") + "|" + [...r.subjects ?? []].sort().join(",");
158
+ const seen = shapes.get(as);
159
+ if (seen && seen !== shape) {
160
+ throw new Error(`telemetry: rollup family "${as}" declared with two shapes: ${seen} / ${shape}`);
161
+ }
162
+ shapes.set(as, shape);
163
+ }
164
+ }
165
+ }
166
+ var safeKey = (k) => k.replace(/\./g, "_");
167
+ var sanitize = (m) => m ? new Map([...m].map(([k, v]) => [safeKey(k), v])) : m;
168
+ var SubjectRefSchema = new mongoose.Schema(
169
+ {
170
+ type: { type: String, required: true },
171
+ // user | org | team | session
172
+ id: { type: String, required: true },
173
+ /** disambiguates same-type parties: sender | recipient | impersonated */
174
+ role: { type: String }
175
+ },
176
+ { _id: false }
177
+ );
178
+ var BUILTIN_PLATFORMS = ["web", "electron", "ios", "android", "server", "cli"];
179
+ var buildClientContextSchema = (platforms) => new mongoose.Schema(
180
+ {
181
+ platform: { type: String, required: true, enum: platforms },
182
+ appVersion: { type: String, required: true },
183
+ userAgent: String,
184
+ os: String,
185
+ osVersion: String,
186
+ browser: String,
187
+ browserVersion: String,
188
+ deviceType: String,
189
+ // desktop | mobile | tablet
190
+ locale: String,
191
+ timezone: String,
192
+ screenW: Number,
193
+ screenH: Number,
194
+ viewportW: Number,
195
+ viewportH: Number,
196
+ connection: String,
197
+ // 4g | wifi | slow-2g
198
+ online: Boolean,
199
+ /** client clock minus server clock, ms — client timestamps lie */
200
+ clockSkewMs: Number
201
+ },
202
+ { _id: false }
203
+ );
204
+ var StackFrameSchema = new mongoose.Schema(
205
+ {
206
+ filename: String,
207
+ fn: String,
208
+ lineno: Number,
209
+ colno: Number,
210
+ inApp: Boolean,
211
+ context: [String]
212
+ },
213
+ { _id: false }
214
+ );
215
+ var ErrorDetailSchema = new mongoose.Schema(
216
+ {
217
+ type: { type: String, required: true },
218
+ message: { type: String, required: true },
219
+ handled: { type: Boolean, required: true, default: false },
220
+ /** grouping key */
221
+ fingerprint: { type: String, required: true },
222
+ frames: [StackFrameSchema]
223
+ },
224
+ { _id: false }
225
+ );
226
+ var StateDetailSchema = new mongoose.Schema(
227
+ {
228
+ key: { type: String, required: true },
229
+ // 'lifecycle' | 'onboarding_step'
230
+ from: String,
231
+ to: { type: String, required: true },
232
+ /** how long the subject sat in `from` — answers "where do they stall" */
233
+ previousSinceMs: Number
234
+ },
235
+ { _id: false }
236
+ );
237
+ var UsageDetailSchema = new mongoose.Schema(
238
+ {
239
+ meter: { type: String, required: true },
240
+ quantity: { type: Number, required: true },
241
+ unit: { type: String, required: true },
242
+ /**
243
+ * Authoritative money. `metrics.cost_usd` is a BSON double — fine as a
244
+ * measure, wrong as the thing that becomes an invoice. Money is
245
+ * authoritative only on kind=usage; the metric is a lossy copy.
246
+ */
247
+ amount: mongoose.Schema.Types.Decimal128,
248
+ currency: String,
249
+ /** at-least-once dedupe. Deterministic, e.g. `${traceId}:${spanId}` */
250
+ idempotencyKey: { type: String, required: true },
251
+ /** who pays — 'org:o_9'. Distinct from subject and from actor. */
252
+ billedTo: { type: String, required: true },
253
+ billable: { type: Boolean, required: true, default: true },
254
+ priceVersion: String,
255
+ /** corrections are new reversing rows; never UPDATE a billed row */
256
+ reverses: String
257
+ },
258
+ { _id: false }
259
+ );
260
+ function buildBaseSchema(collection, registry, counters, opts) {
261
+ const schema = new mongoose.Schema(
262
+ {
263
+ /** UUIDv7 — sortable, insertion-local, replaces the ObjectId */
264
+ _id: { type: String, required: true },
265
+ schemaVersion: { type: Number, required: true },
266
+ occurredAt: { type: Date, required: true },
267
+ name: { type: String, required: true },
268
+ severity: { type: String, required: true, enum: Object.values(LogLevel), default: LogLevel.Info },
269
+ // ── identity ──
270
+ /** tenancy root: shard key, access boundary, index prefix. The only promoted id. */
271
+ tenantId: { type: String, required: true },
272
+ /** multi-party capable: two users, sender/recipient, admin+impersonated */
273
+ subjects: { type: [SubjectRefSchema], default: [] },
274
+ /** derived: ['user:u_1','org:o_9'] */
275
+ subjectKeys: { type: [String] },
276
+ /** who caused it */
277
+ actor: String,
278
+ /** impersonation / delegation */
279
+ onBehalfOf: String,
280
+ /** derived: actor/onBehalfOf NOT already in subjects. Erasure completeness. */
281
+ otherPrincipals: { type: [String] },
282
+ // ── origin — required, but filled by the pre-validate hook, NEVER `default:`.
283
+ // A schema default is applied at construction, before the hook, which would
284
+ // silently stamp dev traffic as prod and pin counters.defaulted at zero
285
+ // forever (schema §7 / ops rule 5).
286
+ service: { type: String, required: true },
287
+ release: { type: String, required: true },
288
+ env: { type: String, required: true, enum: Object.values(Env) },
289
+ origin: { type: String, enum: Object.values(Origin), default: Origin.Server },
290
+ /** required for client-origin events (enforced by the registry hook, not the schema) */
291
+ client: buildClientContextSchema(opts.platforms),
292
+ /**
293
+ * Caller-supplied idempotency for the four non-usage kinds — a Stripe
294
+ * webhook redelivery, a nightly lifecycle diff, a bridge that rewinds its
295
+ * watermark by design. Trusted server callers only; the wire dedupes on
296
+ * the client `_id` instead.
297
+ *
298
+ * Deliberately NOT an `_id` passthrough: `_id` is a UUIDv7 and doubles as
299
+ * insertion order (§2.6), so letting a caller supply an arbitrary string
300
+ * would break that invariant for every reader that sorts on it.
301
+ */
302
+ dedupeKey: String,
303
+ // ── correlation ──
304
+ traceId: String,
305
+ spanId: String,
306
+ parentId: String,
307
+ durationMs: Number,
308
+ // ── payload — no wildcard index; registry-driven indexes instead (§4.4) ──
309
+ /** STRING VALUES ONLY — Mongoose casts on assignment. Numbers belong in metrics. */
310
+ attrs: { type: Map, of: String, default: {} },
311
+ metrics: { type: Map, of: Number, default: {} },
312
+ /** only stored when the registry declares a schema for it */
313
+ data: { type: mongoose.Schema.Types.Mixed },
314
+ body: String,
315
+ // ── ops ──
316
+ /** 1 = kept everything. 0.05 = multiply counts by 20 when aggregating. */
317
+ sampleRate: { type: Number, default: 1 },
318
+ /** kept despite sampling (carried money or an error). NOT representative. */
319
+ forced: { type: Boolean, default: false },
320
+ expiresAt: Date,
321
+ /** set by forget() — row survives, identifiers do not */
322
+ redactedAt: Date
323
+ },
324
+ {
325
+ collection,
326
+ discriminatorKey: "kind",
327
+ timestamps: { createdAt: "receivedAt", updatedAt: false },
328
+ versionKey: false,
329
+ minimize: false
330
+ }
331
+ );
332
+ schema.index({ tenantId: 1, subjectKeys: 1, occurredAt: -1 });
333
+ schema.index({ tenantId: 1, kind: 1, name: 1, occurredAt: -1 });
334
+ schema.index(
335
+ { traceId: 1, occurredAt: 1 },
336
+ { partialFilterExpression: { traceId: { $exists: true } } }
337
+ );
338
+ schema.index(
339
+ { tenantId: 1, otherPrincipals: 1 },
340
+ { partialFilterExpression: { "otherPrincipals.0": { $exists: true } } }
341
+ );
342
+ schema.index(
343
+ { expiresAt: 1 },
344
+ { expireAfterSeconds: 0, partialFilterExpression: { expiresAt: { $exists: true } } }
345
+ );
346
+ schema.index(
347
+ { tenantId: 1, dedupeKey: 1 },
348
+ { unique: true, partialFilterExpression: { dedupeKey: { $exists: true } } }
349
+ );
350
+ schema.pre("validate", function() {
351
+ this._id ??= newId();
352
+ this.schemaVersion ??= SCHEMA_VERSION;
353
+ this.occurredAt ??= /* @__PURE__ */ new Date();
354
+ this.attrs = sanitize(this.attrs);
355
+ this.metrics = sanitize(this.metrics);
356
+ if (typeof this.body === "string" && this.body.length > opts.bodyMax) {
357
+ const dropped = this.body.length - opts.bodyMax;
358
+ this.body = `${this.body.slice(0, opts.bodyMax)}\u2026 [truncated ${dropped} chars]`;
359
+ counters.truncated++;
360
+ }
361
+ const refs = (this.subjects ?? []).map((s) => `${s.type}:${s.id}`);
362
+ this.subjectKeys = [...new Set(refs)];
363
+ this.otherPrincipals = [
364
+ ...new Set(
365
+ [this.actor, this.onBehalfOf].filter((r) => !!r && !refs.includes(r))
366
+ )
367
+ ];
368
+ for (const f of ["service", "release"]) {
369
+ if (!this[f]) {
370
+ this[f] = UNKNOWN;
371
+ counters.defaulted++;
372
+ }
373
+ }
374
+ if (!this.env) {
375
+ this.env = process.env.NODE_ENV === "production" ? Env.Prod : Env.Dev;
376
+ }
377
+ const spec = registry[this.name];
378
+ if (!spec) throw new Error(`telemetry: unregistered event "${this.name}"`);
379
+ if (spec.kind !== this.kind) throw new Error(`telemetry: "${this.name}" is kind=${spec.kind}`);
380
+ const days = "retentionDays" in spec ? spec.retentionDays : RETENTION_DAYS[this.kind];
381
+ if (days != null && !this.expiresAt) {
382
+ this.expiresAt = new Date(this.occurredAt.getTime() + days * 864e5);
383
+ }
384
+ const haveTypes = new Set((this.subjects ?? []).map((s) => s.type));
385
+ for (const t of spec.subjects) {
386
+ if (!haveTypes.has(t)) throw new Error(`telemetry: "${this.name}" requires subject "${t}"`);
387
+ }
388
+ if (spec.origin === Origin.Client && !this.client) {
389
+ throw new Error(`telemetry: "${this.name}" is client-origin and requires client context`);
390
+ }
391
+ if (this.kind === TelemetryKind.Span) {
392
+ for (const f of ["traceId", "spanId"]) {
393
+ if (!this[f]) throw new Error(`telemetry: span requires ${f}`);
394
+ }
395
+ if (typeof this.durationMs !== "number") throw new Error("telemetry: span requires durationMs");
396
+ }
397
+ if (this.kind === TelemetryKind.State && !this.state?.to) {
398
+ throw new Error("telemetry: state requires state.to");
399
+ }
400
+ const check = (label2, m, zschema) => {
401
+ const obj = Object.fromEntries(m ?? []);
402
+ if (!zschema) {
403
+ if (Object.keys(obj).length) throw new Error(`telemetry: "${this.name}" declares no ${label2}`);
404
+ return;
405
+ }
406
+ const s = zschema.strict?.() ?? zschema;
407
+ const r = s.safeParse(obj);
408
+ if (!r.success) throw new Error(`telemetry: ${label2} invalid for "${this.name}": ${r.error.message}`);
409
+ };
410
+ check("attrs", this.attrs, spec.attrs);
411
+ check("metrics", this.metrics, spec.metrics);
412
+ if (this.data != null) {
413
+ const s = spec.data;
414
+ if (!s) {
415
+ this.data = void 0;
416
+ counters.rejected++;
417
+ } else {
418
+ const strict = s.strict?.() ?? s;
419
+ const r = strict.safeParse(this.data);
420
+ if (!r.success) throw new Error(`telemetry: data invalid for "${this.name}": ${r.error.message}`);
421
+ if (r.data === void 0) counters.rejected++;
422
+ this.data = r.data;
423
+ }
424
+ }
425
+ });
426
+ return schema;
427
+ }
428
+ function buildTelemetryModels(opts) {
429
+ const { connection, registry, counters, modelName, collection } = opts;
430
+ const platforms = [.../* @__PURE__ */ new Set([...BUILTIN_PLATFORMS, ...opts.platforms ?? []])];
431
+ const bodyMax = opts.bodyMax ?? BODY_MAX_CHARS;
432
+ const existing = connection.models?.[modelName];
433
+ if (existing) {
434
+ return {
435
+ TelemetryModel: existing,
436
+ byKind: Object.fromEntries(
437
+ Object.values(TelemetryKind).map((k) => [k, (existing.discriminators ?? {})[`${modelName}_${k}`] ?? existing])
438
+ )
439
+ };
440
+ }
441
+ const base = buildBaseSchema(collection, registry, counters, { platforms, bodyMax });
442
+ const TelemetryModel = connection.model(modelName, base);
443
+ const disc = (kind, build) => TelemetryModel.discriminator(`${modelName}_${kind}`, build(), kind);
444
+ const byKind = {
445
+ [TelemetryKind.Event]: disc(TelemetryKind.Event, () => new mongoose.Schema({})),
446
+ // envelope suffices
447
+ [TelemetryKind.Error]: disc(TelemetryKind.Error, () => {
448
+ const s = new mongoose.Schema({ error: { type: ErrorDetailSchema, required: true } });
449
+ s.index(
450
+ { tenantId: 1, "error.fingerprint": 1, occurredAt: -1 },
451
+ { partialFilterExpression: { kind: TelemetryKind.Error } }
452
+ );
453
+ return s;
454
+ }),
455
+ [TelemetryKind.Span]: disc(TelemetryKind.Span, () => {
456
+ const s = new mongoose.Schema({});
457
+ s.index(
458
+ { traceId: 1, parentId: 1 },
459
+ { partialFilterExpression: { kind: TelemetryKind.Span } }
460
+ );
461
+ return s;
462
+ }),
463
+ [TelemetryKind.State]: disc(TelemetryKind.State, () => {
464
+ const s = new mongoose.Schema({ state: { type: StateDetailSchema, required: true } });
465
+ s.index(
466
+ { tenantId: 1, "state.key": 1, "state.to": 1, occurredAt: -1 },
467
+ { partialFilterExpression: { kind: TelemetryKind.State } }
468
+ );
469
+ return s;
470
+ }),
471
+ [TelemetryKind.Usage]: disc(TelemetryKind.Usage, () => {
472
+ const s = new mongoose.Schema({ usage: { type: UsageDetailSchema, required: true } });
473
+ s.index(
474
+ { "usage.idempotencyKey": 1 },
475
+ { unique: true, partialFilterExpression: { kind: TelemetryKind.Usage } }
476
+ );
477
+ s.index(
478
+ { tenantId: 1, "usage.meter": 1, occurredAt: 1 },
479
+ { partialFilterExpression: { kind: TelemetryKind.Usage } }
480
+ );
481
+ return s;
482
+ })
483
+ };
484
+ return { TelemetryModel, byKind };
485
+ }
486
+ function buildRollupModel(connection, modelName, collection) {
487
+ const existing = connection.models?.[modelName];
488
+ if (existing) return existing;
489
+ const schema = new mongoose.Schema(
490
+ {
491
+ /** `${tenantId}|${as}|${dims.join('|')}|${bucketKey}` — deterministic, upsert-safe */
492
+ _id: { type: String, required: true },
493
+ tenantId: { type: String, required: true },
494
+ /** rollup family. Several event names may feed one. */
495
+ as: { type: String, required: true },
496
+ /**
497
+ * Dimension values in spec order. Subject dims keep their native
498
+ * `type:id` form ('user:u_1') so erasure can match them directly;
499
+ * everything else is `key=value`. Flattened strings for the same reason
500
+ * subjectKeys is — never compound-index two fields of one subdoc array.
501
+ */
502
+ dims: { type: [String], required: true },
503
+ /** denormalized prefix of the subject dim, when there is one — §5.4 hazard */
504
+ subjectType: String,
505
+ /** UTC-truncated bucket start; absent on lifetime rollups */
506
+ bucketAt: Date,
507
+ firstAt: { type: Date, required: true },
508
+ lastAt: { type: Date, required: true },
509
+ count: { type: Number, required: true, default: 0 },
510
+ /** registry `sum` keys accumulated across every contributing record */
511
+ sums: { type: Map, of: Number, default: {} },
512
+ /** registry `capture` snapshot at FIRST occurrence — cohort dimensions */
513
+ firstCapture: { type: Map, of: String, default: {} },
514
+ firstTraceId: String,
515
+ expiresAt: Date
516
+ },
517
+ { collection, versionKey: false }
518
+ );
519
+ schema.index({ tenantId: 1, as: 1, subjectType: 1, firstAt: 1 });
520
+ schema.index({ tenantId: 1, as: 1, dims: 1, bucketAt: -1 });
521
+ schema.index({ tenantId: 1, dims: 1 });
522
+ schema.index(
523
+ { expiresAt: 1 },
524
+ { expireAfterSeconds: 0, partialFilterExpression: { expiresAt: { $exists: true } } }
525
+ );
526
+ return connection.model(modelName, schema);
527
+ }
528
+ var path = (o, p) => typeof o?.get === "function" ? o.get(p) : p.split(".").reduce((a, k) => a?.[k], o);
529
+ var resolveDim = (src, doc) => src.startsWith("attr:") ? doc.attrs?.get(src.slice(5)) : src.startsWith("field:") ? path(doc, src.slice(6)) : void 0;
530
+ var label = (src) => src.slice(src.indexOf(":") + 1);
531
+ var truncate = (d, b) => {
532
+ if (!b) return void 0;
533
+ if (b === "hour") return new Date(Math.floor(d.getTime() / 36e5) * 36e5);
534
+ const y = d.getUTCFullYear();
535
+ const m = d.getUTCMonth();
536
+ if (b === "month") return new Date(Date.UTC(y, m, 1));
537
+ const day = new Date(Date.UTC(y, m, d.getUTCDate()));
538
+ if (b === "day") return day;
539
+ return new Date(day.getTime() - (day.getUTCDay() + 6) % 7 * 864e5);
540
+ };
541
+ async function recordRollup(RollupModel, doc, name, spec, counters) {
542
+ if (spec.actors && doc.actor) {
543
+ const actorType = String(doc.actor).split(":")[0];
544
+ if (!spec.actors.includes(actorType)) return;
545
+ }
546
+ const as = spec.as ?? name;
547
+ const at = doc.occurredAt;
548
+ const bucketAt = truncate(at, spec.bucket);
549
+ const bucketKey = bucketAt ? bucketAt.toISOString() : "";
550
+ const fixed = /* @__PURE__ */ new Map();
551
+ for (const src of spec.by) {
552
+ if (src === "subject") continue;
553
+ let v = resolveDim(src, doc);
554
+ if (v == null || v === "") {
555
+ if (spec.dimDefault === void 0) {
556
+ counters.rollupSkipped++;
557
+ return;
558
+ }
559
+ v = spec.dimDefault;
560
+ }
561
+ fixed.set(src, `${label(src)}=${String(v)}`);
562
+ }
563
+ const fansOut = spec.by.includes("subject");
564
+ const refs = fansOut ? (doc.subjectKeys ?? []).filter(
565
+ (r) => !spec.subjects || spec.subjects.includes(r.split(":")[0])
566
+ ) : [null];
567
+ if (!refs.length) return;
568
+ const firstCapture = Object.fromEntries(
569
+ (spec.capture ?? []).map((src) => [label(src), resolveDim(src, doc)]).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)])
570
+ );
571
+ const expiresAt = spec.retentionDays != null ? new Date(at.getTime() + spec.retentionDays * 864e5) : void 0;
572
+ await RollupModel.bulkWrite(
573
+ refs.map((ref) => {
574
+ const dims = spec.by.map((src) => src === "subject" ? ref : fixed.get(src));
575
+ const isNewFirst = {
576
+ $or: [{ $eq: [{ $type: "$firstAt" }, "missing"] }, { $lt: [at, "$firstAt"] }]
577
+ };
578
+ const sums = Object.fromEntries(
579
+ (spec.sum ?? []).map((k) => [k, doc.metrics?.get(k)]).filter(([, v]) => typeof v === "number").map(([k, v]) => [`sums.${k}`, { $add: [{ $ifNull: [`$sums.${k}`, 0] }, v] }])
580
+ );
581
+ return {
582
+ updateOne: {
583
+ filter: { _id: `${doc.tenantId}|${as}|${dims.join("|")}|${bucketKey}` },
584
+ update: [
585
+ {
586
+ $set: {
587
+ tenantId: doc.tenantId,
588
+ as,
589
+ dims,
590
+ ...ref ? { subjectType: ref.split(":")[0] } : {},
591
+ ...bucketAt ? { bucketAt } : {},
592
+ ...expiresAt ? { expiresAt } : {},
593
+ // aggregation $min/$max ignore missing, so correct on insert too
594
+ firstAt: { $min: ["$firstAt", at] },
595
+ lastAt: { $max: ["$lastAt", at] },
596
+ count: { $add: [{ $ifNull: ["$count", 0] }, 1] },
597
+ ...sums,
598
+ firstTraceId: { $cond: [isNewFirst, doc.traceId ?? null, "$firstTraceId"] },
599
+ firstCapture: { $cond: [isNewFirst, { $literal: firstCapture }, "$firstCapture"] }
600
+ }
601
+ }
602
+ ],
603
+ upsert: true
604
+ }
605
+ };
606
+ }),
607
+ { ordered: false }
608
+ );
609
+ }
610
+ function buildCheckpointModel(connection, modelName, collection) {
611
+ const existing = connection.models?.[modelName];
612
+ if (existing) return existing;
613
+ const schema = new mongoose.Schema(
614
+ {
615
+ /** stable scanner name, e.g. "mailery-bridge" */
616
+ key: { type: String, required: true, unique: true },
617
+ /** high-water mark: the scanner has processed everything at/before this */
618
+ at: { type: Date, required: true }
619
+ },
620
+ { collection, timestamps: { createdAt: false, updatedAt: true }, versionKey: false }
621
+ );
622
+ return connection.model(modelName, schema);
623
+ }
624
+ function createCheckpointFactory(CheckpointModel, logger) {
625
+ return function checkpoint(key) {
626
+ return {
627
+ async get() {
628
+ try {
629
+ const doc = await CheckpointModel.findOne({ key }).lean();
630
+ return doc?.at ?? null;
631
+ } catch (err) {
632
+ logger.error({ err, key }, "[telemetry] checkpoint read failed");
633
+ return null;
634
+ }
635
+ },
636
+ async advance(at) {
637
+ try {
638
+ await CheckpointModel.updateOne({ key }, { $set: { at } }, { upsert: true });
639
+ } catch (err) {
640
+ logger.error({ err, key, at }, "[telemetry] checkpoint write failed");
641
+ }
642
+ }
643
+ };
644
+ };
645
+ }
646
+
647
+ // src/server/emit.ts
648
+ function createEmitter(ctx) {
649
+ const { registry, byKind, RollupModel, rejects, counters } = ctx;
650
+ const burstBuckets = /* @__PURE__ */ new Map();
651
+ const burstAllow = (key, maxPerMinute) => {
652
+ const now = Date.now();
653
+ let b = burstBuckets.get(key);
654
+ if (!b || now >= b.resetAt) {
655
+ if (burstBuckets.size > 1e4) burstBuckets.clear();
656
+ b = { n: 0, resetAt: now + 6e4 };
657
+ burstBuckets.set(key, b);
658
+ }
659
+ return ++b.n <= maxPerMinute;
660
+ };
661
+ return async function emit(name, doc) {
662
+ const id = newId();
663
+ const reject = (reason) => {
664
+ counters.rejected++;
665
+ ctx.track(
666
+ rejects().insertOne({ at: /* @__PURE__ */ new Date(), name, reason, raw: plain(doc) }).catch(() => {
667
+ })
668
+ );
669
+ return { id, outcome: "rejected" };
670
+ };
671
+ if (isPlatformScope(doc.tenantId)) return reject(RESERVED_TENANT_MESSAGE);
672
+ const spec = registry[name];
673
+ if (!spec) return reject("unregistered event");
674
+ const dedupeKey = doc.dedupeKey;
675
+ if (dedupeKey !== void 0 && (typeof dedupeKey !== "string" || !dedupeKey || dedupeKey.length > 200)) {
676
+ return reject("dedupeKey must be a non-empty string of at most 200 chars");
677
+ }
678
+ const kind = spec.kind;
679
+ const baseRate = spec.sampleRate ?? SAMPLE_RATE[kind];
680
+ const forced = !!doc.forceKeep || kind === TelemetryKind.Usage || !!doc.error || dedupeKey != null || doc.metrics?.cost_usd != null;
681
+ const durable = kind === TelemetryKind.Usage || (doc.durable ?? spec.durable ?? false);
682
+ const Model = byKind[kind];
683
+ const { forceKeep: _drop, durable: _durable, ...rest } = doc;
684
+ const safe = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [k.replace(/\./g, "_"), v]));
685
+ const payload = {
686
+ ...rest,
687
+ _id: id,
688
+ name,
689
+ sampleRate: forced ? 1 : baseRate,
690
+ forced,
691
+ attrs: safe(doc.attrs),
692
+ metrics: safe(doc.metrics)
693
+ };
694
+ const onFail = async (e) => {
695
+ counters.rejected++;
696
+ await rejects().insertOne({ at: /* @__PURE__ */ new Date(), name, reason: String(e), raw: plain(doc) }).catch(() => {
697
+ });
698
+ };
699
+ const d = new Model(payload);
700
+ try {
701
+ await d.validate();
702
+ } catch (e) {
703
+ await onFail(e);
704
+ if (kind === TelemetryKind.Usage) throw e;
705
+ return { id, outcome: "rejected" };
706
+ }
707
+ const rollup = () => (spec.rollups ?? []).map((r) => {
708
+ const p = recordRollup(RollupModel, d, name, r, counters).catch(onFail);
709
+ ctx.track(p);
710
+ return p;
711
+ });
712
+ const saveOpts = {
713
+ validateBeforeSave: false,
714
+ ...durable ? { writeConcern: { w: "majority", j: true } } : {}
715
+ };
716
+ if (kind === TelemetryKind.Usage || dedupeKey != null) {
717
+ try {
718
+ await d.save(saveOpts);
719
+ } catch (e) {
720
+ if (isDuplicateKey(e)) {
721
+ counters.deduped++;
722
+ return { id, outcome: "deduped" };
723
+ }
724
+ await onFail(e);
725
+ if (durable) throw e;
726
+ return { id, outcome: "rejected" };
727
+ }
728
+ await Promise.all(rollup());
729
+ return { id, outcome: "written" };
730
+ }
731
+ const aggregates = rollup();
732
+ if (!forced && !traceKeep(doc.traceId, baseRate)) {
733
+ counters.sampled++;
734
+ return { id, outcome: "sampled" };
735
+ }
736
+ const burst = spec.burst;
737
+ if (burst && doc.metrics?.cost_usd == null) {
738
+ const v = burst.key ? resolveDim(burst.key, d) : "";
739
+ if (!burstAllow(`${doc.tenantId}|${name}|${v ?? ""}`, burst.maxPerMinute)) {
740
+ counters.capped++;
741
+ return { id, outcome: "capped" };
742
+ }
743
+ }
744
+ if (durable) {
745
+ try {
746
+ await d.save(saveOpts);
747
+ } catch (e) {
748
+ await onFail(e);
749
+ throw e;
750
+ }
751
+ await Promise.all(aggregates);
752
+ return { id, outcome: "written" };
753
+ }
754
+ ctx.track(d.save(saveOpts).catch(onFail));
755
+ return { id, outcome: "queued" };
756
+ };
757
+ }
758
+ function isDuplicateKey(e) {
759
+ return e?.code === 11e3 || e?.cause?.code === 11e3;
760
+ }
761
+ function createForget(ctx) {
762
+ const pseudoId = (ref) => "redacted_" + crypto.createHash("sha256").update(ref + ctx.pepper()).digest("hex").slice(0, 16);
763
+ return async function forget(tenantId, ref) {
764
+ const { TelemetryModel, RollupModel } = ctx;
765
+ if (isPlatformScope(tenantId)) throw new Error(RESERVED_TENANT_MESSAGE);
766
+ const [type, rawId] = ref.split(":");
767
+ if (!type || !rawId) throw new Error(`telemetry: forget ref "${ref}" is not "type:id"`);
768
+ const newId2 = pseudoId(ref);
769
+ const newRef = `${type}:${newId2}`;
770
+ const match = { tenantId, $or: [{ subjectKeys: ref }, { otherPrincipals: ref }] };
771
+ const del = await TelemetryModel.deleteMany({
772
+ ...match,
773
+ kind: { $ne: TelemetryKind.Usage },
774
+ subjectKeys: { $eq: ref, $size: 1 },
775
+ // ref is the ONLY subject
776
+ otherPrincipals: { $in: [[], null] }
777
+ });
778
+ const red = await TelemetryModel.collection.updateMany(match, [
779
+ {
780
+ $set: {
781
+ subjects: {
782
+ $map: {
783
+ input: "$subjects",
784
+ as: "s",
785
+ in: {
786
+ $cond: [
787
+ { $eq: [{ $concat: ["$$s.type", ":", "$$s.id"] }, ref] },
788
+ { $mergeObjects: ["$$s", { id: newId2 }] },
789
+ "$$s"
790
+ ]
791
+ }
792
+ }
793
+ },
794
+ subjectKeys: {
795
+ $map: {
796
+ input: "$subjectKeys",
797
+ as: "k",
798
+ in: { $cond: [{ $eq: ["$$k", ref] }, newRef, "$$k"] }
799
+ }
800
+ },
801
+ otherPrincipals: {
802
+ $map: {
803
+ input: { $ifNull: ["$otherPrincipals", []] },
804
+ as: "k",
805
+ in: { $cond: [{ $eq: ["$$k", ref] }, newRef, "$$k"] }
806
+ }
807
+ },
808
+ actor: { $cond: [{ $eq: ["$actor", ref] }, newRef, "$actor"] },
809
+ onBehalfOf: { $cond: [{ $eq: ["$onBehalfOf", ref] }, newRef, "$onBehalfOf"] },
810
+ client: "$$REMOVE",
811
+ redactedAt: "$$NOW"
812
+ }
813
+ }
814
+ ]);
815
+ const rolls = await RollupModel.find({ tenantId, dims: ref }).lean();
816
+ let rollups = 0;
817
+ if (rolls.length) {
818
+ await RollupModel.bulkWrite(
819
+ rolls.map((r) => {
820
+ const dims = r.dims.map((d) => d === ref ? newRef : d);
821
+ const bucketKey = r.bucketAt ? new Date(r.bucketAt).toISOString() : "";
822
+ return {
823
+ insertOne: {
824
+ document: { ...r, _id: `${tenantId}|${r.as}|${dims.join("|")}|${bucketKey}`, dims }
825
+ }
826
+ };
827
+ }),
828
+ { ordered: false }
829
+ ).catch((e) => {
830
+ const errs = e?.writeErrors ?? [];
831
+ if (e?.code !== 11e3 && !(errs.length && errs.every((w) => w.code === 11e3))) throw e;
832
+ });
833
+ rollups = (await RollupModel.deleteMany({ tenantId, dims: ref })).deletedCount;
834
+ }
835
+ await ctx.rejects().deleteMany({
836
+ "raw.tenantId": tenantId,
837
+ $or: [
838
+ { "raw.subjects": { $elemMatch: { type, id: rawId } } },
839
+ { "raw.actor": ref },
840
+ { "raw.onBehalfOf": ref }
841
+ ]
842
+ });
843
+ const aliases = (await ctx.aliases().deleteMany({ tenantId, $or: [{ anonRef: ref }, { userRef: ref }] })).deletedCount;
844
+ const viewScope = ctx.globalSubjectRefs() ? { $in: [tenantId, PLATFORM_SCOPE] } : tenantId;
845
+ const views = (await ctx.views().deleteMany({ tenantId: viewScope, ownerRef: ref, shared: { $ne: true } })).deletedCount + (await ctx.views().updateMany(
846
+ { tenantId: viewScope, ownerRef: ref, shared: true },
847
+ { $set: { ownerRef: newRef } }
848
+ )).modifiedCount;
849
+ return { deleted: del.deletedCount ?? 0, redacted: red.modifiedCount ?? 0, rollups, aliases, views };
850
+ };
851
+ }
852
+
853
+ // src/server/indexes.ts
854
+ var INDEX_BUDGET = 24;
855
+ function createSyncIndexes(ctx) {
856
+ return async function syncIndexes() {
857
+ await Promise.all(ctx.models.map((m) => m.init()));
858
+ const plan = (name, kind, key) => ({
859
+ name: `${kind}_${name.replace(/\./g, "_")}_${key}`,
860
+ keys: {
861
+ tenantId: 1,
862
+ [`${kind === "attr" ? "attrs" : "metrics"}.${key}`]: 1,
863
+ occurredAt: -1
864
+ },
865
+ partial: { name }
866
+ // keeps each index to just that event's rows
867
+ });
868
+ const planned = Object.entries(ctx.registry).flatMap(([name, spec]) => [
869
+ ...(spec.indexedAttrs ?? []).map((k) => plan(name, "attr", k)),
870
+ ...(spec.indexedMetrics ?? []).map((k) => plan(name, "metric", k))
871
+ ]);
872
+ const coll = ctx.TelemetryModel.collection;
873
+ const plannedNames = new Set(planned.map((p) => p.name));
874
+ for (const ix of await coll.indexes()) {
875
+ if ((ix.name?.startsWith("attr_") || ix.name?.startsWith("metric_")) && !plannedNames.has(ix.name)) {
876
+ await coll.dropIndex(ix.name);
877
+ }
878
+ }
879
+ if (planned.length > INDEX_BUDGET) {
880
+ throw new Error(`telemetry: ${planned.length} payload indexes exceeds budget ${INDEX_BUDGET}`);
881
+ }
882
+ for (const p of planned) {
883
+ await coll.createIndex(p.keys, {
884
+ name: p.name,
885
+ partialFilterExpression: p.partial,
886
+ background: true
887
+ });
888
+ }
889
+ await ctx.rejects().createIndex({ at: 1 }, { expireAfterSeconds: REJECT_TTL_DAYS * 86400 });
890
+ };
891
+ }
892
+ var KeyKind = { Publishable: "publishable", Secret: "secret" };
893
+ var TenantMode = { Fixed: "fixed", Session: "session", Claimed: "claimed" };
894
+ var KEY_RE = /^(pk|sk)_([a-z0-9]+)_(tk_[a-f0-9]{24})(?:_([a-f0-9]{48}))?$/;
895
+ function parseKeyString(raw) {
896
+ if (!raw) return null;
897
+ const m = KEY_RE.exec(raw.trim());
898
+ if (!m) return null;
899
+ const [, prefix, label2, id, secret] = m;
900
+ if (prefix === "sk" && !secret) return null;
901
+ if (prefix === "pk" && secret) return null;
902
+ return { kind: prefix === "pk" ? KeyKind.Publishable : KeyKind.Secret, label: label2, id, secret };
903
+ }
904
+ var SCRYPT_VERSION = "scrypt1";
905
+ var SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
906
+ function hashSecret(secret) {
907
+ const salt = crypto.randomBytes(16).toString("hex");
908
+ const hash = crypto.scryptSync(secret, salt, SCRYPT.keylen, SCRYPT).toString("hex");
909
+ return `${SCRYPT_VERSION}$${salt}$${hash}`;
910
+ }
911
+ function verifySecret(secret, stored) {
912
+ if (!stored) return false;
913
+ const [version, salt, hex] = stored.split("$");
914
+ if (version !== SCRYPT_VERSION || !salt || !hex) return false;
915
+ const expected = Buffer.from(hex, "hex");
916
+ const actual = crypto.scryptSync(secret, salt, SCRYPT.keylen, SCRYPT);
917
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
918
+ }
919
+ function buildKeyModel(connection, modelName, collection) {
920
+ const existing = connection.models?.[modelName];
921
+ if (existing) return existing;
922
+ const schema = new mongoose.Schema(
923
+ {
924
+ /** the tk_ id embedded in the key string — lookup is a point read */
925
+ _id: { type: String, required: true },
926
+ kind: { type: String, required: true, enum: Object.values(KeyKind) },
927
+ /** sk_ only — versioned scrypt hash of the secret half */
928
+ secretHash: String,
929
+ tenantMode: { type: String, required: true, enum: Object.values(TenantMode) },
930
+ /** required iff tenantMode = fixed */
931
+ tenantId: String,
932
+ /** stamped onto every record — the client cannot lie about where it runs */
933
+ service: { type: String, required: true },
934
+ env: { type: String, required: true },
935
+ /** CORS allowlist. pk_ only; empty = no browser origins accepted. */
936
+ origins: { type: [String], default: [] },
937
+ /** pk_ defaults to event/error/span; sk_ to all five */
938
+ allowedKinds: { type: [String], required: true },
939
+ /** optional narrowing to a subset of registry names */
940
+ allowedNames: { type: [String], default: void 0 },
941
+ /** records/min across the key. Distinct from EventSpec.burst (per-group). */
942
+ maxPerMinute: { type: Number, required: true, default: 600 },
943
+ createdAt: { type: Date, required: true },
944
+ revokedAt: Date,
945
+ /** touched at most once/min, not per request */
946
+ lastUsedAt: Date
947
+ },
948
+ { collection, versionKey: false }
949
+ );
950
+ schema.index({ revokedAt: 1 });
951
+ return connection.model(modelName, schema);
952
+ }
953
+ async function createKey(KeyModel, input) {
954
+ const {
955
+ kind,
956
+ tenantMode,
957
+ tenantId,
958
+ service,
959
+ env,
960
+ label: label2 = "live",
961
+ origins = [],
962
+ allowedNames,
963
+ maxPerMinute = 600
964
+ } = input;
965
+ if (tenantMode === TenantMode.Claimed && kind !== KeyKind.Secret) {
966
+ throw new Error('telemetry: tenantMode "claimed" requires a secret key');
967
+ }
968
+ if (tenantMode === TenantMode.Fixed && !tenantId) {
969
+ throw new Error('telemetry: tenantMode "fixed" requires tenantId');
970
+ }
971
+ if (isPlatformScope(tenantId)) throw new Error(RESERVED_TENANT_MESSAGE);
972
+ const allowedKinds = input.allowedKinds ?? (kind === KeyKind.Publishable ? [TelemetryKind.Event, TelemetryKind.Error, TelemetryKind.Span] : Object.values(TelemetryKind));
973
+ if (kind === KeyKind.Publishable && allowedKinds.includes(TelemetryKind.Usage)) {
974
+ throw new Error("telemetry: a publishable key may never write usage");
975
+ }
976
+ const id = `tk_${crypto.randomBytes(12).toString("hex")}`;
977
+ const secret = kind === KeyKind.Secret ? crypto.randomBytes(24).toString("hex") : void 0;
978
+ await KeyModel.create({
979
+ _id: id,
980
+ kind,
981
+ secretHash: secret ? hashSecret(secret) : void 0,
982
+ tenantMode,
983
+ tenantId,
984
+ service,
985
+ env,
986
+ origins,
987
+ allowedKinds,
988
+ allowedNames,
989
+ maxPerMinute,
990
+ createdAt: /* @__PURE__ */ new Date()
991
+ });
992
+ const prefix = kind === KeyKind.Publishable ? "pk" : "sk";
993
+ return { key: secret ? `${prefix}_${label2}_${id}_${secret}` : `${prefix}_${label2}_${id}`, id };
994
+ }
995
+ var BATCH_MAX = 100;
996
+ function createIngest(opts) {
997
+ const { telemetry: t, contextAdapter, maxRecords = BATCH_MAX, bodyLimit = "512kb", keyCacheMs = 6e4 } = opts;
998
+ const KeyModel = t.models.keys;
999
+ const registry = t.registry;
1000
+ const logger = t.logger;
1001
+ const keyCache = /* @__PURE__ */ new Map();
1002
+ const buckets = /* @__PURE__ */ new Map();
1003
+ const takeRecords = (keyId, maxPerMinute, n) => {
1004
+ const now = Date.now();
1005
+ let b = buckets.get(keyId);
1006
+ if (!b || now >= b.resetAt) {
1007
+ b = { n: 0, resetAt: now + 6e4 };
1008
+ buckets.set(keyId, b);
1009
+ }
1010
+ const room = Math.max(0, maxPerMinute - b.n);
1011
+ const taken = Math.min(room, n);
1012
+ b.n += taken;
1013
+ return taken;
1014
+ };
1015
+ const loadKey = async (id) => {
1016
+ const hit = keyCache.get(id);
1017
+ if (hit && Date.now() - hit.at < keyCacheMs) return hit.doc;
1018
+ const doc = await KeyModel.findById(id).lean();
1019
+ keyCache.set(id, { doc: doc ?? null, at: Date.now() });
1020
+ return doc ?? null;
1021
+ };
1022
+ const quarantine = (name, reason, raw) => {
1023
+ t.counters.rejected++;
1024
+ void t.collections.rejects().insertOne({ at: /* @__PURE__ */ new Date(), name, reason, raw: plain(raw) }).catch(() => {
1025
+ });
1026
+ };
1027
+ const router = express2__default.default.Router();
1028
+ router.options("/", (req, res) => {
1029
+ const origin = req.headers.origin;
1030
+ if (origin) {
1031
+ res.setHeader("Access-Control-Allow-Origin", origin);
1032
+ res.setHeader("Vary", "Origin");
1033
+ res.setHeader("Access-Control-Allow-Methods", "POST");
1034
+ res.setHeader("Access-Control-Allow-Headers", "authorization, content-type");
1035
+ res.setHeader("Access-Control-Max-Age", "600");
1036
+ }
1037
+ res.status(204).end();
1038
+ });
1039
+ router.post(
1040
+ "/",
1041
+ async (req, res, next) => {
1042
+ const bearer = /^Bearer\s+(.+)$/i.exec(String(req.headers.authorization ?? ""))?.[1];
1043
+ const fromQuery = typeof req.query.key === "string" ? req.query.key : void 0;
1044
+ const parsed = parseKeyString(bearer) ?? (fromQuery?.startsWith("pk_") ? parseKeyString(fromQuery) : null);
1045
+ const drop = () => res.status(202).json({ accepted: 0, rejected: 0 });
1046
+ if (!parsed) {
1047
+ if (bearer?.startsWith("sk_") || fromQuery?.startsWith("sk_")) {
1048
+ return res.status(401).json({ error: "invalid_key" });
1049
+ }
1050
+ return drop();
1051
+ }
1052
+ const doc = await loadKey(parsed.id);
1053
+ const skFail = (status, error) => res.status(status).json({ error });
1054
+ if (!doc || doc.revokedAt) {
1055
+ return parsed.kind === KeyKind.Secret ? skFail(401, doc ? "revoked_key" : "invalid_key") : drop();
1056
+ }
1057
+ if (doc.kind !== parsed.kind) {
1058
+ return parsed.kind === KeyKind.Secret ? skFail(401, "invalid_key") : drop();
1059
+ }
1060
+ if (parsed.kind === KeyKind.Secret && !verifySecret(parsed.secret, doc.secretHash)) {
1061
+ return skFail(401, "invalid_key");
1062
+ }
1063
+ const origin = req.headers.origin;
1064
+ if (parsed.kind === KeyKind.Publishable && origin) {
1065
+ if (!doc.origins?.includes(origin)) return drop();
1066
+ res.setHeader("Access-Control-Allow-Origin", origin);
1067
+ res.setHeader("Vary", "Origin");
1068
+ }
1069
+ if (!doc.lastUsedAt || Date.now() - new Date(doc.lastUsedAt).getTime() > 6e4) {
1070
+ doc.lastUsedAt = /* @__PURE__ */ new Date();
1071
+ void KeyModel.updateOne({ _id: doc._id }, { $set: { lastUsedAt: doc.lastUsedAt } }).catch(() => {
1072
+ });
1073
+ }
1074
+ req.telemetryKey = doc;
1075
+ next();
1076
+ },
1077
+ express2__default.default.json({ limit: bodyLimit }),
1078
+ async (req, res) => {
1079
+ const key = req.telemetryKey;
1080
+ const pk = key.kind === KeyKind.Publishable;
1081
+ const receivedAt = /* @__PURE__ */ new Date();
1082
+ const body = req.body ?? {};
1083
+ let accepted = 0;
1084
+ let rejected = 0;
1085
+ const respond = () => res.status(202).json({ accepted, rejected });
1086
+ let records = Array.isArray(body.records) ? body.records : [];
1087
+ if (records.length > maxRecords) {
1088
+ if (!pk) return res.status(413).json({ error: "batch_too_large", max: maxRecords });
1089
+ rejected += records.length - maxRecords;
1090
+ records = records.slice(0, maxRecords);
1091
+ }
1092
+ const granted = takeRecords(key._id, key.maxPerMinute, records.length);
1093
+ if (granted < records.length) {
1094
+ if (!pk) return res.status(429).json({ error: "rate_limited" });
1095
+ t.counters.capped += records.length - granted;
1096
+ rejected += records.length - granted;
1097
+ records = records.slice(0, granted);
1098
+ }
1099
+ let ctx = null;
1100
+ if (key.tenantMode === TenantMode.Fixed) {
1101
+ ctx = { tenantId: key.tenantId };
1102
+ } else if (key.tenantMode === TenantMode.Session) {
1103
+ if (!contextAdapter) {
1104
+ logger.warn("[telemetry] session-mode key but no contextAdapter configured");
1105
+ return pk ? respond() : res.status(500).json({ error: "no_context_adapter" });
1106
+ }
1107
+ ctx = await contextAdapter.resolveContext(req);
1108
+ if (!ctx) {
1109
+ rejected += records.length;
1110
+ return pk ? respond() : res.status(401).json({ error: "no_session" });
1111
+ }
1112
+ } else {
1113
+ const claimed = body.context?.tenantId;
1114
+ if (typeof claimed !== "string" || !claimed) {
1115
+ return res.status(400).json({ error: "tenant_required" });
1116
+ }
1117
+ ctx = { tenantId: claimed };
1118
+ }
1119
+ const tenantId = ctx.tenantId;
1120
+ if (isPlatformScope(tenantId)) {
1121
+ logger.warn(`[telemetry] ingest refused a batch: ${RESERVED_TENANT_MESSAGE}`);
1122
+ t.counters.rejected += records.length;
1123
+ rejected += records.length;
1124
+ return pk ? respond() : res.status(400).json({ error: "reserved_tenant" });
1125
+ }
1126
+ const sentAt = body.sentAt ? Date.parse(body.sentAt) : NaN;
1127
+ const clockSkewMs = Number.isFinite(sentAt) ? sentAt - receivedAt.getTime() : 0;
1128
+ const batchClient = {
1129
+ platform: "web",
1130
+ appVersion: String(body.release ?? "unknown"),
1131
+ ...body.client && typeof body.client === "object" ? body.client : {},
1132
+ clockSkewMs
1133
+ };
1134
+ const subjectsOf = (raw) => Array.isArray(raw) ? raw.filter((s) => s && typeof s.type === "string" && typeof s.id === "string").map((s) => ({ type: s.type, id: String(s.id), ...s.role ? { role: String(s.role) } : {} })) : [];
1135
+ const claimSubjects = subjectsOf(body.context?.subjects);
1136
+ const hostSubjects = subjectsOf(ctx.subjects);
1137
+ const mergeSubjects = (recordSubjects) => {
1138
+ const byType = /* @__PURE__ */ new Map();
1139
+ for (const layer of [claimSubjects, subjectsOf(recordSubjects), hostSubjects]) {
1140
+ const grouped = /* @__PURE__ */ new Map();
1141
+ for (const s of layer) {
1142
+ grouped.set(s.type, [...grouped.get(s.type) ?? [], s]);
1143
+ }
1144
+ for (const [type, entries] of grouped) byType.set(type, entries);
1145
+ }
1146
+ return [...byType.values()].flat();
1147
+ };
1148
+ const batchActor = typeof body.context?.actor === "string" ? body.context.actor : void 0;
1149
+ for (const rec of records) {
1150
+ const name = typeof rec?.name === "string" ? rec.name : "";
1151
+ const fail = (reason) => {
1152
+ rejected++;
1153
+ quarantine(name || "(unnamed)", reason, rec);
1154
+ };
1155
+ if (name === "$identify") {
1156
+ const anonRef = String(rec.anonRef ?? "");
1157
+ const userRef = String(rec.userRef ?? "");
1158
+ if (!/^.+:.+$/.test(anonRef) || !/^.+:.+$/.test(userRef)) {
1159
+ fail("malformed $identify");
1160
+ continue;
1161
+ }
1162
+ await t.collections.aliases().updateOne(
1163
+ { _id: `${tenantId}|${anonRef}` },
1164
+ { $set: { tenantId, anonRef, userRef, linkedAt: receivedAt } },
1165
+ { upsert: true }
1166
+ );
1167
+ accepted++;
1168
+ continue;
1169
+ }
1170
+ const spec = registry[name];
1171
+ if (!spec) {
1172
+ fail("unregistered event");
1173
+ continue;
1174
+ }
1175
+ if (!key.allowedKinds.includes(spec.kind)) {
1176
+ fail(`kind ${spec.kind} not allowed for this key`);
1177
+ continue;
1178
+ }
1179
+ if (spec.origin === "server" && pk) {
1180
+ fail("server-origin name over a publishable key");
1181
+ continue;
1182
+ }
1183
+ if (key.allowedNames?.length && !key.allowedNames.includes(name)) {
1184
+ fail("name not allowed for this key");
1185
+ continue;
1186
+ }
1187
+ const id = typeof rec._id === "string" && rec._id.length >= 16 && rec._id.length <= 64 ? rec._id : null;
1188
+ if (!id) {
1189
+ fail("missing client _id");
1190
+ continue;
1191
+ }
1192
+ const occurredRaw = rec.occurredAt ? Date.parse(rec.occurredAt) : NaN;
1193
+ const occurredAt = Number.isFinite(occurredRaw) ? new Date(occurredRaw - clockSkewMs) : receivedAt;
1194
+ const safeMap = (o) => o && typeof o === "object" ? new Map(Object.entries(o).map(([k, v]) => [k.replace(/\./g, "_"), v])) : /* @__PURE__ */ new Map();
1195
+ const Model = t.models.byKind[spec.kind];
1196
+ const d = new Model({
1197
+ // facts the wire may not assert: tenant, service, env, origin, plane
1198
+ // fields (forced/sampleRate stripped by construction — only the
1199
+ // allowlisted fields below ever reach the document)
1200
+ _id: id,
1201
+ name,
1202
+ tenantId,
1203
+ occurredAt,
1204
+ severity: typeof rec.severity === "string" ? rec.severity : void 0,
1205
+ subjects: mergeSubjects(rec.subjects),
1206
+ actor: ctx.actor ?? (typeof rec.actor === "string" ? rec.actor : batchActor),
1207
+ onBehalfOf: typeof rec.onBehalfOf === "string" ? rec.onBehalfOf : void 0,
1208
+ service: key.service,
1209
+ env: key.env,
1210
+ release: typeof body.release === "string" ? body.release : void 0,
1211
+ origin: "client",
1212
+ client: batchClient,
1213
+ traceId: typeof rec.traceId === "string" ? rec.traceId : void 0,
1214
+ spanId: typeof rec.spanId === "string" ? rec.spanId : void 0,
1215
+ parentId: typeof rec.parentId === "string" ? rec.parentId : void 0,
1216
+ durationMs: typeof rec.durationMs === "number" ? rec.durationMs : void 0,
1217
+ attrs: safeMap(rec.attrs),
1218
+ metrics: safeMap(rec.metrics),
1219
+ data: rec.data,
1220
+ body: typeof rec.body === "string" ? rec.body : void 0,
1221
+ error: rec.error,
1222
+ state: rec.state,
1223
+ usage: rec.usage
1224
+ });
1225
+ try {
1226
+ await d.validate();
1227
+ } catch (e) {
1228
+ fail(String(e));
1229
+ continue;
1230
+ }
1231
+ try {
1232
+ await d.save({
1233
+ validateBeforeSave: false,
1234
+ ...spec.kind === TelemetryKind.Usage ? { writeConcern: { w: "majority", j: true } } : {}
1235
+ });
1236
+ } catch (e) {
1237
+ if (e?.code === 11e3 || e?.cause?.code === 11e3) {
1238
+ accepted++;
1239
+ continue;
1240
+ }
1241
+ fail(String(e));
1242
+ continue;
1243
+ }
1244
+ for (const r of spec.rollups ?? []) {
1245
+ await recordRollup(t.models.rollups, d, name, r, t.counters).catch(
1246
+ (e) => quarantine(name, `rollup: ${e}`, rec)
1247
+ );
1248
+ }
1249
+ accepted++;
1250
+ }
1251
+ respond();
1252
+ }
1253
+ );
1254
+ router.use((err, req, res, _next) => {
1255
+ if (req.telemetryKey?.kind === KeyKind.Secret) {
1256
+ const status = err?.type === "entity.too.large" ? 413 : 400;
1257
+ return res.status(status).json({ error: err?.type ?? "bad_request" });
1258
+ }
1259
+ t.counters.rejected++;
1260
+ res.status(202).json({ accepted: 0, rejected: 1 });
1261
+ });
1262
+ return router;
1263
+ }
1264
+
1265
+ // src/server/funnel.ts
1266
+ var DAY_MS = 864e5;
1267
+ function median(values) {
1268
+ if (values.length === 0) return null;
1269
+ const s = [...values].sort((a, b) => a - b);
1270
+ const mid = s.length >> 1;
1271
+ return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
1272
+ }
1273
+ function summarizeStages(subjects, stages) {
1274
+ const firstKey = stages[0]?.key;
1275
+ const first = firstKey ? subjects.filter((s) => s.stages[firstKey]).length : 0;
1276
+ return stages.map((st, i) => {
1277
+ const prev = i > 0 ? stages[i - 1] : null;
1278
+ const next = i < stages.length - 1 ? stages[i + 1] : null;
1279
+ const reached = [];
1280
+ const fromAnchor = [];
1281
+ for (const s of subjects) {
1282
+ const at = s.stages[st.key];
1283
+ if (!at) continue;
1284
+ reached.push(s);
1285
+ if (s.anchorAt) fromAnchor.push((at.getTime() - s.anchorAt.getTime()) / DAY_MS);
1286
+ }
1287
+ let prevReached = 0;
1288
+ let notReached = 0;
1289
+ const fromPrevious = [];
1290
+ if (prev) {
1291
+ for (const s of subjects) {
1292
+ const p = s.stages[prev.key];
1293
+ if (!p) continue;
1294
+ prevReached += 1;
1295
+ const cur = s.stages[st.key];
1296
+ if (cur) fromPrevious.push((cur.getTime() - p.getTime()) / DAY_MS);
1297
+ else notReached += 1;
1298
+ }
1299
+ }
1300
+ return {
1301
+ order: st.order,
1302
+ key: st.key,
1303
+ as: st.as,
1304
+ label: st.label,
1305
+ ...st.description ? { description: st.description } : {},
1306
+ subjects: reached.length,
1307
+ pctOfFirst: first > 0 ? reached.length / first * 100 : null,
1308
+ pctOfPrevious: prev ? prevReached > 0 ? reached.length / prevReached * 100 : null : null,
1309
+ medianDaysFromAnchor: median(fromAnchor),
1310
+ medianDaysFromPrevious: prev ? median(fromPrevious) : null,
1311
+ notReached,
1312
+ // the divergence: maxed's `!next` clause makes every subject that reached
1313
+ // the terminal stage "stuck" there. null says "undefined", which is true.
1314
+ stalledAt: next ? subjects.filter((s) => s.stages[st.key] && !s.stages[next.key]).length : null
1315
+ };
1316
+ });
1317
+ }
1318
+ function findFamily(registry, as) {
1319
+ for (const [name, s] of Object.entries(registry)) {
1320
+ for (const r of s.rollups ?? []) {
1321
+ if ((r.as ?? name) === as) return { name, spec: r };
1322
+ }
1323
+ }
1324
+ return null;
1325
+ }
1326
+ function requireMilestoneFamily(registry, as, primitive) {
1327
+ const found = findFamily(registry, as);
1328
+ if (!found) {
1329
+ throw new Error(
1330
+ `telemetry: ${primitive} \u2014 no rollup family "${as}" is declared. Add a \`rollups: [{ as: '${as}', by: ['subject'], subjects: [...] }]\` block to the event that marks it.`
1331
+ );
1332
+ }
1333
+ const { name, spec } = found;
1334
+ const shape = `by: [${spec.by.map((d) => `'${d}'`).join(", ")}]${spec.bucket ? `, bucket: '${spec.bucket}'` : ""}`;
1335
+ if (spec.bucket) {
1336
+ throw new Error(
1337
+ `telemetry: ${primitive} \u2014 rollup family "${as}" (declared on "${name}") is BUCKETED (${shape}). A milestone needs a lifetime family so \`firstAt\` is the one moment the subject reached it; a bucketed family has one doc per period and would count the same subject repeatedly.`
1338
+ );
1339
+ }
1340
+ if (spec.by.length !== 1 || spec.by[0] !== "subject") {
1341
+ throw new Error(
1342
+ `telemetry: ${primitive} \u2014 rollup family "${as}" (declared on "${name}") is keyed ${shape}, but a milestone must be keyed by exactly one subject dim (\`by: ['subject']\`). Extra dims split one subject across several docs, which would over-count every stage.`
1343
+ );
1344
+ }
1345
+ return spec;
1346
+ }
1347
+ function cohortWindow(c) {
1348
+ const endInclusive = c.endInclusive === true;
1349
+ return { from: c.from, to: endInclusive ? new Date(c.to.getTime() + 1) : c.to, endInclusive };
1350
+ }
1351
+ async function runFunnel(ctx, scope, params) {
1352
+ if (!params.stages?.length) throw new Error("telemetry: funnel() needs at least one stage");
1353
+ const stages = params.stages.map((s, i) => ({
1354
+ order: i + 1,
1355
+ key: s.key ?? s.as,
1356
+ as: s.as,
1357
+ label: s.label ?? s.key ?? s.as,
1358
+ ...s.description ? { description: s.description } : {}
1359
+ }));
1360
+ const exits = (params.exits ?? []).map((s, i) => ({
1361
+ order: i + 1,
1362
+ key: s.key ?? s.as,
1363
+ as: s.as,
1364
+ label: s.label ?? s.key ?? s.as
1365
+ }));
1366
+ const anchor = params.anchor ?? stages[0].as;
1367
+ requireMilestoneFamily(ctx.registry, anchor, "funnel()");
1368
+ for (const s of [...stages, ...exits]) requireMilestoneFamily(ctx.registry, s.as, "funnel()");
1369
+ const { from, to, endInclusive } = cohortWindow(params.cohort);
1370
+ const cap = Math.min(Math.max(1, params.limit ?? ctx.cohortCap), ctx.cohortCap);
1371
+ const cohortMatch = {
1372
+ ...ctx.scopeMatch(scope),
1373
+ as: anchor,
1374
+ // AMBIGUOUS-2 (cohort-math): maxed's cohort read overwrites per row with no
1375
+ // ordering, so its `signupAt` is whichever row Mongo returned last —
1376
+ // nondeterministic. Unreachable here by construction: the rollup's `firstAt`
1377
+ // is a `$min` maintained on write, so the anchor timestamp is the EARLIEST
1378
+ // occurrence, always, matching the tie-break rule maxed uses everywhere else
1379
+ // (R12). We take min(at) and the storage enforces it.
1380
+ firstAt: { $gte: from, $lt: to }
1381
+ };
1382
+ if (params.subjectType) cohortMatch.subjectType = params.subjectType;
1383
+ const cohortRows = await ctx.RollupModel.find(cohortMatch).sort({ firstAt: 1 }).limit(cap + 1).lean();
1384
+ const truncated = cohortRows.length > cap;
1385
+ if (truncated) cohortRows.pop();
1386
+ const keyOf = (r) => `${r.tenantId ?? ""}|${r.dims?.[0]}`;
1387
+ const index = /* @__PURE__ */ new Map();
1388
+ const refs = /* @__PURE__ */ new Set();
1389
+ const tenants = /* @__PURE__ */ new Set();
1390
+ for (const r of cohortRows) {
1391
+ const ref = r.dims?.[0];
1392
+ if (!ref) continue;
1393
+ refs.add(ref);
1394
+ tenants.add(String(r.tenantId ?? ""));
1395
+ index.set(keyOf(r), { ref, anchorAt: r.firstAt, stages: {}, exits: {} });
1396
+ }
1397
+ if (refs.size) {
1398
+ const byAs = /* @__PURE__ */ new Map();
1399
+ for (const s of [...stages, ...exits]) {
1400
+ const list = byAs.get(s.as) ?? [];
1401
+ list.push(s);
1402
+ byAs.set(s.as, list);
1403
+ }
1404
+ const stageRows = await ctx.RollupModel.find({
1405
+ ...ctx.scopeMatch(scope),
1406
+ as: { $in: [...byAs.keys()] },
1407
+ dims: { $in: [...refs] },
1408
+ // AMBIGUOUS-1 (cohort-math): maxed collects stages with `at >= cohortStart`
1409
+ // and no upper bound. We keep the lower bound — reading (a), as-written.
1410
+ // Reasons: (1) it is what maxed does, and an equivalence test against a
1411
+ // table computed under the other reading would prove nothing; (2) over
1412
+ // rollup storage the predicate reads "first reached no earlier than the
1413
+ // cohort opened", which is the only reading under which a cohort's funnel
1414
+ // is a function of its own window — drop the bound and a backfill dated
1415
+ // before the window silently adds stages to a report already published.
1416
+ // No UPPER bound, deliberately: a conversion landing months after the
1417
+ // window still belongs to its cohort (R7).
1418
+ firstAt: { $gte: from }
1419
+ }).limit(refs.size * tenants.size * byAs.size + 1).lean();
1420
+ for (const r of stageRows) {
1421
+ const subject = index.get(keyOf(r));
1422
+ if (!subject) continue;
1423
+ for (const s of byAs.get(r.as) ?? []) {
1424
+ const bag = exits.includes(s) ? subject.exits : subject.stages;
1425
+ const prevAt = bag[s.key];
1426
+ if (!prevAt || r.firstAt < prevAt) bag[s.key] = r.firstAt;
1427
+ }
1428
+ }
1429
+ }
1430
+ const subjects = [...index.values()];
1431
+ const summary = summarizeStages(subjects, stages);
1432
+ let slices = null;
1433
+ if (params.interval) {
1434
+ const groups = /* @__PURE__ */ new Map();
1435
+ for (const s of subjects) {
1436
+ if (!s.anchorAt) continue;
1437
+ const at = truncate(s.anchorAt, params.interval);
1438
+ const k = at.getTime();
1439
+ groups.set(k, [...groups.get(k) ?? [], s]);
1440
+ }
1441
+ slices = [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([k, members]) => ({
1442
+ at: new Date(k),
1443
+ subjects: members.length,
1444
+ stages: summarizeStages(members, stages)
1445
+ }));
1446
+ }
1447
+ return {
1448
+ cohortSubjects: subjects.length,
1449
+ first: stages[0] ? subjects.filter((s) => s.stages[stages[0].key]).length : 0,
1450
+ stages: summary,
1451
+ exits: exits.map((e) => ({
1452
+ key: e.key,
1453
+ as: e.as,
1454
+ label: e.label,
1455
+ subjects: subjects.filter((s) => s.exits[e.key]).length
1456
+ })),
1457
+ slices,
1458
+ truncated,
1459
+ cohort: { from: params.cohort.from, to: params.cohort.to, endInclusive, anchor },
1460
+ dataSource: "rollups"
1461
+ };
1462
+ }
1463
+
1464
+ // src/server/query.ts
1465
+ var DEFAULT_LIMITS = {
1466
+ records: 200,
1467
+ series: 744,
1468
+ // a month of hourly buckets
1469
+ rollups: 500,
1470
+ trace: 500,
1471
+ journey: 500,
1472
+ distribution: 1e5,
1473
+ distinct: 1e5,
1474
+ funnel: 5e3
1475
+ };
1476
+ var esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1477
+ function buildMatch(scope, range, f) {
1478
+ const match = {
1479
+ // the ONLY place the tenant term is optional. Omitted under '*' — every
1480
+ // other term still applies, and the time range is still mandatory (§18).
1481
+ ...isPlatformScope(scope) ? {} : { tenantId: scope },
1482
+ occurredAt: { $gte: range.from, $lt: range.to }
1483
+ };
1484
+ for (const k of ["kind", "name", "severity", "env", "service", "release", "traceId"]) {
1485
+ if (f[k]) match[k] = f[k];
1486
+ }
1487
+ if (f.subject) match.subjectKeys = f.subject;
1488
+ for (const [k, v] of Object.entries(f.attrs ?? {})) match[`attrs.${k}`] = v;
1489
+ for (const [k, r] of Object.entries(f.metrics ?? {})) {
1490
+ const term = {};
1491
+ if (r.gte != null) term.$gte = r.gte;
1492
+ if (r.lte != null) term.$lte = r.lte;
1493
+ if (Object.keys(term).length) match[`metrics.${k}`] = term;
1494
+ }
1495
+ if (f.excludeActorTypes?.length) {
1496
+ match.$and = [
1497
+ ...match.$and ?? [],
1498
+ {
1499
+ $or: [
1500
+ { actor: { $exists: false } },
1501
+ { actor: { $not: new RegExp(`^(${f.excludeActorTypes.map(esc).join("|")}):`) } }
1502
+ ]
1503
+ }
1504
+ ];
1505
+ }
1506
+ return match;
1507
+ }
1508
+ var QueryCache = class {
1509
+ constructor(ttlMs, cap) {
1510
+ this.ttlMs = ttlMs;
1511
+ this.cap = cap;
1512
+ }
1513
+ ttlMs;
1514
+ cap;
1515
+ store = /* @__PURE__ */ new Map();
1516
+ get(key, produce) {
1517
+ const hit = this.store.get(key);
1518
+ if (hit && Date.now() - hit.at < this.ttlMs) return hit.value;
1519
+ const value = produce();
1520
+ value.catch(() => this.store.delete(key));
1521
+ if (this.store.size >= this.cap) {
1522
+ const oldest = [...this.store.entries()].sort((a, b) => a[1].at - b[1].at)[0];
1523
+ if (oldest) this.store.delete(oldest[0]);
1524
+ }
1525
+ this.store.set(key, { at: Date.now(), value });
1526
+ return value;
1527
+ }
1528
+ };
1529
+ function createQueries(ctx) {
1530
+ const limits = { ...DEFAULT_LIMITS, ...ctx.limits };
1531
+ const slowMs = ctx.slowMs ?? 500;
1532
+ const cache = new QueryCache(ctx.cacheTtlMs ?? 10 * 6e4, ctx.cacheSize ?? 60);
1533
+ const timed = async (op, params, run) => {
1534
+ const t0 = Date.now();
1535
+ try {
1536
+ return await run();
1537
+ } finally {
1538
+ const ms = Date.now() - t0;
1539
+ if (ms > slowMs) ctx.onSlowQuery?.({ op, ms, params });
1540
+ }
1541
+ };
1542
+ return {
1543
+ /** cursor-paged raw envelope reads — tables, lists, detail drawers */
1544
+ async records(scope, range, filter = {}, opts = {}) {
1545
+ const limit = Math.min(Math.max(1, opts.limit ?? limits.records), limits.records);
1546
+ const match = buildMatch(scope, range, filter);
1547
+ if (opts.cursor) {
1548
+ const [atIso, id] = JSON.parse(Buffer.from(opts.cursor, "base64url").toString());
1549
+ const at = new Date(atIso);
1550
+ match.$and = [
1551
+ ...match.$and ?? [],
1552
+ { $or: [{ occurredAt: { $lt: at } }, { occurredAt: at, _id: { $lt: id } }] }
1553
+ ];
1554
+ }
1555
+ return timed("records", { scope, filter }, async () => {
1556
+ const items = await ctx.TelemetryModel.find(match).sort({ occurredAt: -1, _id: -1 }).limit(limit + 1).lean();
1557
+ const more = items.length > limit;
1558
+ if (more) items.pop();
1559
+ const last = items[items.length - 1];
1560
+ return {
1561
+ items,
1562
+ nextCursor: more ? Buffer.from(JSON.stringify([new Date(last.occurredAt).toISOString(), last._id])).toString("base64url") : null,
1563
+ dataSource: "raw"
1564
+ };
1565
+ });
1566
+ },
1567
+ /** time-series at query time. count extrapolates by 1/sampleRate (§5.3) —
1568
+ * exact while rates sit at 1, still honest the day one drops.
1569
+ *
1570
+ * Under PLATFORM_SCOPE this aggregates ACROSS tenants into one bucket per
1571
+ * interval. That is the platform-wide chart, not a bug — the sum of every
1572
+ * tenant is the number a platform operator came for. A per-tenant
1573
+ * breakdown is a different question; ask it with rollups() or by scoping
1574
+ * to a tenant. Same for distribution() below. */
1575
+ series(scope, range, filter, opts = {}) {
1576
+ const { measure = "count", interval = "day" } = opts;
1577
+ const key = JSON.stringify(["series", scope, range.from, range.to, filter, measure, interval]);
1578
+ return cache.get(
1579
+ key,
1580
+ () => timed("series", { scope, filter, measure, interval }, async () => {
1581
+ const m = /^(sum|avg):(.+)$/.exec(measure);
1582
+ const value = !m ? { $sum: { $divide: [1, { $ifNull: ["$sampleRate", 1] }] } } : m[1] === "sum" ? { $sum: `$metrics.${m[2]}` } : { $avg: `$metrics.${m[2]}` };
1583
+ const buckets = await ctx.TelemetryModel.aggregate([
1584
+ { $match: buildMatch(scope, range, filter) },
1585
+ {
1586
+ $group: {
1587
+ _id: { $dateTrunc: { date: "$occurredAt", unit: interval, ...interval === "week" ? { startOfWeek: "monday" } : {} } },
1588
+ value
1589
+ }
1590
+ },
1591
+ { $sort: { _id: 1 } },
1592
+ { $limit: limits.series }
1593
+ ]);
1594
+ return { buckets: buckets.map((b) => ({ at: b._id, value: b.value })), dataSource: "raw" };
1595
+ })
1596
+ );
1597
+ },
1598
+ /**
1599
+ * Percentiles + histogram off raw. Keep-all makes the SAMPLE complete —
1600
+ * no sampling stands between the match and the math (§5.3) — but the
1601
+ * computation is not exact and this comment used to claim it was:
1602
+ * `$percentile` runs `method: 'approximate'` (t-digest), and the scan stops
1603
+ * at `limits.distribution`.
1604
+ *
1605
+ * So the ceiling is read as cap+1 and `truncated` reports whether it was
1606
+ * actually reached, the same way rollups/distinctCount/funnel do. A match
1607
+ * wider than the ceiling is an undercount, and an undercount the response
1608
+ * does not mention is the silent cap this package refuses everywhere else.
1609
+ * Mongo 7+.
1610
+ */
1611
+ distribution(scope, range, filter, opts = {}) {
1612
+ const measure = opts.measure ?? "durationMs";
1613
+ const path3 = measure === "durationMs" ? "$durationMs" : `$metrics.${measure.replace(/^metric:/, "")}`;
1614
+ const key = JSON.stringify(["distribution", scope, range.from, range.to, filter, measure]);
1615
+ return cache.get(
1616
+ key,
1617
+ () => timed("distribution", { scope, filter, measure }, async () => {
1618
+ const match = {
1619
+ ...buildMatch(scope, range, filter),
1620
+ [path3.slice(1)]: { $exists: true }
1621
+ };
1622
+ const cap = limits.distribution;
1623
+ const [summary] = await ctx.TelemetryModel.aggregate([
1624
+ { $match: match },
1625
+ { $limit: cap + 1 },
1626
+ {
1627
+ $group: {
1628
+ _id: null,
1629
+ p: { $percentile: { input: path3, p: [0.5, 0.9, 0.95, 0.99], method: "approximate" } },
1630
+ min: { $min: path3 },
1631
+ max: { $max: path3 },
1632
+ avg: { $avg: path3 },
1633
+ n: { $sum: 1 }
1634
+ }
1635
+ }
1636
+ ]);
1637
+ if (!summary) return { n: 0, truncated: false, dataSource: "raw" };
1638
+ const [p50, p90, p95, p99] = summary.p;
1639
+ const histogram = await ctx.TelemetryModel.aggregate([
1640
+ { $match: match },
1641
+ { $limit: cap + 1 },
1642
+ { $bucketAuto: { groupBy: path3, buckets: 20 } }
1643
+ ]);
1644
+ return {
1645
+ p50,
1646
+ p90,
1647
+ p95,
1648
+ p99,
1649
+ min: summary.min,
1650
+ max: summary.max,
1651
+ avg: summary.avg,
1652
+ n: summary.n,
1653
+ histogram: histogram.map((h) => ({ min: h._id.min, max: h._id.max, n: h.count })),
1654
+ truncated: summary.n > cap,
1655
+ dataSource: "raw"
1656
+ };
1657
+ })
1658
+ );
1659
+ },
1660
+ /** rollup family reads — issues, spend, activity, milestones, funnels */
1661
+ rollups(scope, params) {
1662
+ const key = JSON.stringify(["rollups", scope, params]);
1663
+ return cache.get(
1664
+ key,
1665
+ () => timed("rollups", { scope, params }, async () => {
1666
+ let bucketed = false;
1667
+ outer: for (const [name, s] of Object.entries(ctx.registry)) {
1668
+ for (const r of s.rollups ?? []) {
1669
+ if ((r.as ?? name) === params.as) {
1670
+ bucketed = !!r.bucket;
1671
+ break outer;
1672
+ }
1673
+ }
1674
+ }
1675
+ const match = {
1676
+ ...isPlatformScope(scope) ? {} : { tenantId: scope },
1677
+ as: params.as
1678
+ };
1679
+ if (params.dims) {
1680
+ match.dims = Array.isArray(params.dims) ? { $in: params.dims } : params.dims;
1681
+ }
1682
+ if (params.subjectType) match.subjectType = params.subjectType;
1683
+ if (params.range) {
1684
+ const on = params.on ?? (bucketed ? "bucketAt" : "lastAt");
1685
+ match[on] = { $gte: params.range.from, $lt: params.range.to };
1686
+ }
1687
+ const sortKey = params.sort ?? (bucketed ? "bucketAt" : "count");
1688
+ const limit = Math.min(Math.max(1, params.limit ?? limits.rollups), limits.rollups);
1689
+ const rows = await ctx.RollupModel.find(match).sort({ [sortKey]: sortKey === "firstAt" || sortKey === "bucketAt" ? 1 : -1 }).limit(limit + 1).lean();
1690
+ const truncated = rows.length > limit;
1691
+ if (truncated) rows.pop();
1692
+ return { rows, bucketed, truncated, dataSource: "rollups" };
1693
+ })
1694
+ );
1695
+ },
1696
+ /** one trace, every kind, one time axis — the first join view */
1697
+ trace(scope, traceId) {
1698
+ return timed("trace", { scope, traceId }, async () => {
1699
+ const items = await ctx.TelemetryModel.find({
1700
+ ...isPlatformScope(scope) ? {} : { tenantId: scope },
1701
+ traceId
1702
+ }).sort({ occurredAt: 1 }).limit(limits.trace).lean();
1703
+ return { items, dataSource: "raw" };
1704
+ });
1705
+ },
1706
+ /** one subject's whole story — records interleaved, milestones as markers */
1707
+ journey(scope, subjectRef, range, opts = {}) {
1708
+ return timed("journey", { scope, subjectRef }, async () => {
1709
+ const limit = Math.min(Math.max(1, opts.limit ?? limits.journey), limits.journey);
1710
+ const pin = isPlatformScope(scope) ? {} : { tenantId: scope };
1711
+ const [records, milestones] = await Promise.all([
1712
+ ctx.TelemetryModel.find({
1713
+ ...pin,
1714
+ subjectKeys: subjectRef,
1715
+ occurredAt: { $gte: range.from, $lt: range.to }
1716
+ }).sort({ occurredAt: -1 }).limit(limit).lean(),
1717
+ // lifetime families only — bucketed activity rows would drown the markers
1718
+ ctx.RollupModel.find({ ...pin, dims: subjectRef, bucketAt: { $exists: false } }).sort({ firstAt: 1 }).limit(100).lean()
1719
+ ]);
1720
+ return { records, milestones, dataSource: "raw+rollups" };
1721
+ });
1722
+ },
1723
+ /**
1724
+ * Distinct subjects per bucket, and over the whole range — DAU/MAU/WAU,
1725
+ * EXACTLY, with no sketch and no write-path change.
1726
+ *
1727
+ * The trick is that there is no trick. A family declared `by: ['subject']`
1728
+ * with a bucket already writes exactly ONE doc per (subject, bucket), which
1729
+ * is what the deterministic `_id` guarantees. So distinct-subjects-in-bucket
1730
+ * IS the doc count, and distinct-over-a-range is one `$group` on `dims`. An
1731
+ * HLL sketch would buy approximation we do not need and storage we would
1732
+ * have to maintain.
1733
+ *
1734
+ * `interval` may be COARSER than the family's own bucket (daily rows →
1735
+ * monthly MAU) — re-truncating bucket starts cannot split a bucket across
1736
+ * two periods, so the roll-up stays exact. Asking for finer than the family
1737
+ * writes cannot invent detail: it returns the family's own grain.
1738
+ */
1739
+ distinctCount(scope, params) {
1740
+ const spec = requireDistinctFamily(ctx.registry, params.as);
1741
+ const interval = params.interval ?? spec.bucket;
1742
+ const key = JSON.stringify(["distinctCount", scope, params]);
1743
+ return cache.get(
1744
+ key,
1745
+ () => timed("distinctCount", { scope, params }, async () => {
1746
+ const match = {
1747
+ ...isPlatformScope(scope) ? {} : { tenantId: scope },
1748
+ as: params.as,
1749
+ bucketAt: { $gte: params.range.from, $lt: params.range.to }
1750
+ };
1751
+ if (params.subjectType) match.subjectType = params.subjectType;
1752
+ const cap = limits.distinct;
1753
+ const [out] = await ctx.RollupModel.aggregate([
1754
+ { $match: match },
1755
+ // one scan ceiling, shared by both branches — and cap+1 so the
1756
+ // response can SAY it was truncated instead of quietly undercounting
1757
+ { $limit: cap + 1 },
1758
+ {
1759
+ $facet: {
1760
+ buckets: [
1761
+ {
1762
+ $group: {
1763
+ _id: {
1764
+ at: { $dateTrunc: { date: "$bucketAt", unit: interval, ...interval === "week" ? { startOfWeek: "monday" } : {} } },
1765
+ dims: "$dims"
1766
+ }
1767
+ }
1768
+ },
1769
+ { $group: { _id: "$_id.at", value: { $sum: 1 } } },
1770
+ { $sort: { _id: 1 } },
1771
+ { $limit: limits.series }
1772
+ ],
1773
+ distinct: [{ $group: { _id: "$dims" } }, { $count: "n" }],
1774
+ scanned: [{ $count: "n" }]
1775
+ }
1776
+ }
1777
+ ]);
1778
+ const scanned = out?.scanned?.[0]?.n ?? 0;
1779
+ return {
1780
+ buckets: (out?.buckets ?? []).map((b) => ({ at: b._id, value: b.value })),
1781
+ /** distinct subjects across the WHOLE range — never the sum of the buckets */
1782
+ distinct: out?.distinct?.[0]?.n ?? 0,
1783
+ interval,
1784
+ truncated: scanned > cap,
1785
+ dataSource: "rollups"
1786
+ };
1787
+ })
1788
+ );
1789
+ },
1790
+ /**
1791
+ * Cohort funnel over lifetime milestone families — stage counts, conversion,
1792
+ * and median time-to-step. See funnel.ts; the math lives there so it can be
1793
+ * unit-pinned without a database.
1794
+ */
1795
+ funnel(scope, params) {
1796
+ return timed(
1797
+ "funnel",
1798
+ { scope, params },
1799
+ () => runFunnel(
1800
+ {
1801
+ RollupModel: ctx.RollupModel,
1802
+ registry: ctx.registry,
1803
+ cohortCap: limits.funnel,
1804
+ scopeMatch: (s) => isPlatformScope(s) ? {} : { tenantId: s }
1805
+ },
1806
+ scope,
1807
+ params
1808
+ )
1809
+ );
1810
+ }
1811
+ };
1812
+ }
1813
+ function requireDistinctFamily(registry, as) {
1814
+ const found = findFamily(registry, as);
1815
+ if (!found) {
1816
+ throw new Error(
1817
+ `telemetry: distinctCount() \u2014 no rollup family "${as}" is declared. Add \`rollups: [{ as: '${as}', by: ['subject'], subjects: [...], bucket: 'day' }]\` to the events that count as activity.`
1818
+ );
1819
+ }
1820
+ const { name, spec } = found;
1821
+ const by = `by: [${spec.by.map((d) => `'${d}'`).join(", ")}]`;
1822
+ if (!spec.bucket) {
1823
+ throw new Error(
1824
+ `telemetry: distinctCount() \u2014 rollup family "${as}" (declared on "${name}") has no \`bucket\`. Distinct-per-period needs one doc per (subject, period); a lifetime family has one doc per subject forever, so every period would report the same number. Add \`bucket: 'day'\`, or ask this with rollups().`
1825
+ );
1826
+ }
1827
+ if (!spec.by.includes("subject")) {
1828
+ throw new Error(
1829
+ `telemetry: distinctCount() \u2014 rollup family "${as}" (declared on "${name}") is keyed ${by} with no \`subject\` dim, so its docs count OCCURRENCES, not subjects. Add 'subject' to \`by\` (with \`subjects: [...]\`).`
1830
+ );
1831
+ }
1832
+ if (spec.by.length !== 1) {
1833
+ throw new Error(
1834
+ `telemetry: distinctCount() \u2014 rollup family "${as}" (declared on "${name}") is keyed ${by}. Extra dims split one subject across several docs per period, so the count would exceed the true distinct total. Declare a second family with \`by: ['subject']\` for the distinct question.`
1835
+ );
1836
+ }
1837
+ return spec;
1838
+ }
1839
+ function buildViewModel(connection, modelName, collection) {
1840
+ const existing = connection.models?.[modelName];
1841
+ if (existing) return existing;
1842
+ const schema = new mongoose.Schema(
1843
+ {
1844
+ _id: { type: String, required: true },
1845
+ tenantId: { type: String, required: true },
1846
+ /** a person — forget() deletes private views, redacts this on shared ones */
1847
+ ownerRef: String,
1848
+ shared: { type: Boolean, default: false },
1849
+ spec: { type: mongoose.Schema.Types.Mixed, required: true },
1850
+ createdAt: { type: Date, required: true }
1851
+ },
1852
+ { collection, versionKey: false }
1853
+ );
1854
+ schema.index({ tenantId: 1, shared: 1 });
1855
+ schema.index({ tenantId: 1, ownerRef: 1 });
1856
+ return connection.model(modelName, schema);
1857
+ }
1858
+ var KIND_PAGE = {
1859
+ error: "errors",
1860
+ span: "traces",
1861
+ event: "events",
1862
+ state: "journeys",
1863
+ usage: "usage"
1864
+ };
1865
+ function deriveViews(registry) {
1866
+ const views = [];
1867
+ const families = /* @__PURE__ */ new Set();
1868
+ for (const [name, spec] of Object.entries(registry)) {
1869
+ views.push({
1870
+ origin: "derived",
1871
+ name,
1872
+ page: KIND_PAGE[spec.kind] ?? "events",
1873
+ query: { range: "7d", filters: { name }, display: spec.kind === "event" ? "series" : "table" }
1874
+ });
1875
+ for (const r of spec.rollups ?? []) families.add(r.as ?? name);
1876
+ }
1877
+ for (const as of families) {
1878
+ views.push({
1879
+ origin: "derived",
1880
+ name: `rollup: ${as}`,
1881
+ page: "journeys",
1882
+ query: { range: "30d", filters: { rollup: as }, display: "breakdown" }
1883
+ });
1884
+ }
1885
+ return views;
1886
+ }
1887
+ async function resolveViews(opts) {
1888
+ const byName = /* @__PURE__ */ new Map();
1889
+ for (const v of deriveViews(opts.registry)) byName.set(v.name, v);
1890
+ for (const v of opts.configured) byName.set(v.name, { ...v, origin: "configured" });
1891
+ const saved = await opts.ViewModel.find({
1892
+ tenantId: opts.tenantId,
1893
+ $or: [{ shared: true }, ...opts.viewerRef ? [{ ownerRef: opts.viewerRef }] : []]
1894
+ }).sort({ createdAt: 1 }).limit(200).lean();
1895
+ for (const doc of saved) {
1896
+ byName.set(doc.spec.name, {
1897
+ ...doc.spec,
1898
+ origin: "saved",
1899
+ id: doc._id,
1900
+ ownerRef: doc.ownerRef,
1901
+ shared: doc.shared
1902
+ });
1903
+ }
1904
+ return [...byName.values()];
1905
+ }
1906
+ async function saveView(opts) {
1907
+ if (!opts.shared && !opts.viewerRef) {
1908
+ throw Object.assign(new Error("private views need a viewer identity"), { status: 400 });
1909
+ }
1910
+ const id = newId();
1911
+ await opts.ViewModel.create({
1912
+ _id: id,
1913
+ tenantId: opts.tenantId,
1914
+ ownerRef: opts.viewerRef,
1915
+ shared: opts.shared,
1916
+ spec: opts.spec,
1917
+ createdAt: /* @__PURE__ */ new Date()
1918
+ });
1919
+ return { id };
1920
+ }
1921
+
1922
+ // src/server/dashboard.ts
1923
+ var here = path2__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
1924
+ var SPA_DIR_CANDIDATES = ["./ui", "../../dist/ui"];
1925
+ function defaultSpaDir() {
1926
+ for (const candidate of SPA_DIR_CANDIDATES) {
1927
+ const dir = path2__default.default.resolve(here, candidate);
1928
+ if (fs__default.default.existsSync(path2__default.default.join(dir, "index.html"))) return dir;
1929
+ }
1930
+ return path2__default.default.resolve(here, SPA_DIR_CANDIDATES[SPA_DIR_CANDIDATES.length - 1]);
1931
+ }
1932
+ function escapeJson(value) {
1933
+ return JSON.stringify(value).replace(/</g, "\\u003c");
1934
+ }
1935
+ var parseRange = (q) => {
1936
+ const to = q.to ? new Date(String(q.to)) : /* @__PURE__ */ new Date();
1937
+ const from = q.from ? new Date(String(q.from)) : new Date(to.getTime() - 7 * 864e5);
1938
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
1939
+ throw Object.assign(new Error("invalid time range"), { status: 400 });
1940
+ }
1941
+ return { from, to };
1942
+ };
1943
+ var parseFilter = (q) => {
1944
+ const f = {};
1945
+ for (const k of ["kind", "name", "severity", "env", "service", "release", "subject", "traceId"]) {
1946
+ if (typeof q[k] === "string" && q[k]) f[k] = q[k];
1947
+ }
1948
+ if (typeof q.attrs === "string" && q.attrs) {
1949
+ f.attrs = Object.fromEntries(
1950
+ String(q.attrs).split(",").map((p) => p.split(":")).filter((p) => p.length >= 2).map(([k, ...v]) => [k, v.join(":")])
1951
+ );
1952
+ }
1953
+ if (typeof q.metrics === "string" && q.metrics) {
1954
+ f.metrics = {};
1955
+ for (const term of String(q.metrics).split(",")) {
1956
+ const m = /^([\w.]+)([<>])(-?[\d.]+)$/.exec(term);
1957
+ if (m) f.metrics[m[1]] = m[2] === ">" ? { gte: Number(m[3]) } : { lte: Number(m[3]) };
1958
+ }
1959
+ }
1960
+ if (typeof q.excludeActors === "string" && q.excludeActors) {
1961
+ f.excludeActorTypes = String(q.excludeActors).split(",").filter(Boolean);
1962
+ }
1963
+ return f;
1964
+ };
1965
+ var ROLLUP_ON = ["firstAt", "lastAt", "bucketAt"];
1966
+ var ROLLUP_SORT = ["count", "lastAt", "firstAt", "bucketAt"];
1967
+ var MAX_DIMS = 100;
1968
+ var parseDims = (v) => {
1969
+ if (typeof v === "string") return v || void 0;
1970
+ if (!Array.isArray(v)) return void 0;
1971
+ if (v.length > MAX_DIMS) {
1972
+ throw Object.assign(new Error(`at most ${MAX_DIMS} dims values`), { status: 400 });
1973
+ }
1974
+ if (!v.every((d) => typeof d === "string" && d)) {
1975
+ throw Object.assign(new Error("dims must be non-empty strings"), { status: 400 });
1976
+ }
1977
+ return v.length ? v : void 0;
1978
+ };
1979
+ function registryProjection(t) {
1980
+ return Object.fromEntries(
1981
+ Object.entries(t.registry).map(([name, spec]) => [
1982
+ name,
1983
+ {
1984
+ kind: spec.kind,
1985
+ origin: spec.origin,
1986
+ subjects: spec.subjects,
1987
+ description: spec.description,
1988
+ attrKeys: spec.attrs ? Object.keys(spec.attrs.shape) : [],
1989
+ metricKeys: spec.metrics ? Object.keys(spec.metrics.shape) : [],
1990
+ indexedAttrs: spec.indexedAttrs ?? [],
1991
+ indexedMetrics: spec.indexedMetrics ?? [],
1992
+ rollups: (spec.rollups ?? []).map((r) => ({
1993
+ as: r.as ?? name,
1994
+ by: r.by,
1995
+ bucket: r.bucket ?? null,
1996
+ sum: r.sum ?? [],
1997
+ subjects: r.subjects ?? []
1998
+ }))
1999
+ }
2000
+ ])
2001
+ );
2002
+ }
2003
+ function createDashboard(opts) {
2004
+ const { telemetry: t, viewerAdapter, subjectAdapter, views: configured = [] } = opts;
2005
+ if (!viewerAdapter?.resolveViewer) {
2006
+ throw new Error(
2007
+ "telemetry: createDashboard requires a viewerAdapter \u2014 an unauthenticated telemetry dashboard is a data leak with charts"
2008
+ );
2009
+ }
2010
+ const mountPath = opts.mountPath ?? "/telemetry";
2011
+ const apiBase = opts.apiBase ?? mountPath;
2012
+ const title = opts.title ?? "Telemetry";
2013
+ const spaDir = opts.spaDir ?? defaultSpaDir();
2014
+ const conn = t.models.telemetry.db;
2015
+ const ViewModel = buildViewModel(
2016
+ conn,
2017
+ `${t.models.telemetry.modelName}View`,
2018
+ `${t.models.telemetry.collection.collectionName}_views`
2019
+ );
2020
+ const q = createQueries({
2021
+ TelemetryModel: t.models.telemetry,
2022
+ RollupModel: t.models.rollups,
2023
+ registry: t.registry,
2024
+ limits: opts.queryLimits,
2025
+ onSlowQuery: opts.onSlowQuery,
2026
+ // forwarded, not re-defaulted — an onSlowQuery pinned at the 500ms default
2027
+ // is a callback the host cannot aim
2028
+ slowMs: opts.slowMs,
2029
+ cacheTtlMs: opts.cacheTtlMs,
2030
+ cacheSize: opts.cacheSize
2031
+ });
2032
+ const api = express2__default.default.Router();
2033
+ api.use(express2__default.default.json({ limit: "64kb" }));
2034
+ api.use(async (req, res, next) => {
2035
+ try {
2036
+ const viewer = await viewerAdapter.resolveViewer(req);
2037
+ if (!viewer?.tenantId) return res.status(401).json({ error: "unauthenticated" });
2038
+ req.viewer = viewer;
2039
+ next();
2040
+ } catch (e) {
2041
+ next(e);
2042
+ }
2043
+ });
2044
+ const h = (fn) => (req, res, next) => {
2045
+ fn(req, res).then((body) => {
2046
+ if (!res.headersSent) res.json(body);
2047
+ }, next);
2048
+ };
2049
+ api.get("/registry", h(async (req) => ({
2050
+ registry: registryProjection(t),
2051
+ kinds: ["event", "error", "span", "state", "usage"],
2052
+ role: req.viewer.role,
2053
+ scope: req.viewer.tenantId,
2054
+ platform: isPlatformScope(req.viewer.tenantId)
2055
+ })));
2056
+ api.get("/records", h(
2057
+ async (req) => q.records(req.viewer.tenantId, parseRange(req.query), parseFilter(req.query), {
2058
+ limit: req.query.limit ? Number(req.query.limit) : void 0,
2059
+ cursor: typeof req.query.cursor === "string" ? req.query.cursor : void 0
2060
+ })
2061
+ ));
2062
+ api.get("/series", h(
2063
+ async (req) => q.series(req.viewer.tenantId, parseRange(req.query), parseFilter(req.query), {
2064
+ measure: typeof req.query.measure === "string" ? req.query.measure : void 0,
2065
+ interval: req.query.interval || void 0
2066
+ })
2067
+ ));
2068
+ api.get("/distribution", h(
2069
+ async (req) => q.distribution(req.viewer.tenantId, parseRange(req.query), parseFilter(req.query), {
2070
+ measure: typeof req.query.measure === "string" ? req.query.measure : void 0
2071
+ })
2072
+ ));
2073
+ api.get("/rollups", h(async (req) => {
2074
+ if (typeof req.query.as !== "string" || !req.query.as) {
2075
+ throw Object.assign(new Error("rollup family required"), { status: 400 });
2076
+ }
2077
+ const on = req.query.on;
2078
+ if (on !== void 0 && !ROLLUP_ON.includes(on)) {
2079
+ throw Object.assign(new Error(`on must be one of ${ROLLUP_ON.join(", ")}`), { status: 400 });
2080
+ }
2081
+ const sort = req.query.sort;
2082
+ if (sort !== void 0 && !ROLLUP_SORT.includes(sort)) {
2083
+ throw Object.assign(new Error(`sort must be one of ${ROLLUP_SORT.join(", ")}`), { status: 400 });
2084
+ }
2085
+ return q.rollups(req.viewer.tenantId, {
2086
+ as: req.query.as,
2087
+ dims: parseDims(req.query.dims),
2088
+ subjectType: typeof req.query.subjectType === "string" ? req.query.subjectType : void 0,
2089
+ // cohort selection wants firstAt; the default is still bucketAt when
2090
+ // bucketed, lastAt otherwise — see query.ts
2091
+ on,
2092
+ range: req.query.from || req.query.to ? parseRange(req.query) : void 0,
2093
+ sort,
2094
+ limit: req.query.limit ? Number(req.query.limit) : void 0
2095
+ });
2096
+ }));
2097
+ api.get("/trace/:traceId", h(async (req) => q.trace(req.viewer.tenantId, String(req.params.traceId))));
2098
+ api.get("/journey/:ref", h(
2099
+ async (req) => q.journey(req.viewer.tenantId, String(req.params.ref), parseRange(req.query), {
2100
+ limit: req.query.limit ? Number(req.query.limit) : void 0
2101
+ })
2102
+ ));
2103
+ const badRequest = async (run) => {
2104
+ try {
2105
+ return await run();
2106
+ } catch (e) {
2107
+ throw Object.assign(e, { status: e?.status ?? 400 });
2108
+ }
2109
+ };
2110
+ api.get("/funnel", h(async (req) => {
2111
+ const parseStages = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean).map((as) => ({ as }));
2112
+ const stages = parseStages(req.query.stages);
2113
+ if (!stages.length) {
2114
+ throw Object.assign(new Error("funnel needs `stages` \u2014 a comma-separated list of rollup families"), { status: 400 });
2115
+ }
2116
+ return badRequest(() => q.funnel(req.viewer.tenantId, {
2117
+ stages,
2118
+ exits: parseStages(req.query.exits),
2119
+ anchor: typeof req.query.anchor === "string" ? req.query.anchor : void 0,
2120
+ // the cohort window is the shell's own range, half-open like everything else
2121
+ cohort: { ...parseRange(req.query), endInclusive: req.query.endInclusive === "true" },
2122
+ subjectType: typeof req.query.subjectType === "string" ? req.query.subjectType : void 0,
2123
+ interval: req.query.interval || void 0,
2124
+ limit: req.query.limit ? Number(req.query.limit) : void 0
2125
+ }));
2126
+ }));
2127
+ api.get("/distinct", h(async (req) => {
2128
+ if (typeof req.query.as !== "string" || !req.query.as) {
2129
+ throw Object.assign(new Error("rollup family required"), { status: 400 });
2130
+ }
2131
+ return badRequest(() => q.distinctCount(req.viewer.tenantId, {
2132
+ as: req.query.as,
2133
+ subjectType: typeof req.query.subjectType === "string" ? req.query.subjectType : void 0,
2134
+ range: parseRange(req.query),
2135
+ interval: req.query.interval || void 0
2136
+ }));
2137
+ }));
2138
+ api.get("/subjects/describe", h(async (req) => {
2139
+ const refs = String(req.query.refs ?? "").split(",").filter(Boolean).slice(0, 100);
2140
+ if (!subjectAdapter) return { refs: {} };
2141
+ return { refs: await subjectAdapter.describe(refs) };
2142
+ }));
2143
+ api.get("/views", h(async (req) => ({
2144
+ views: await resolveViews({
2145
+ ViewModel,
2146
+ registry: t.registry,
2147
+ configured,
2148
+ tenantId: req.viewer.tenantId,
2149
+ viewerRef: req.viewer.viewerRef
2150
+ })
2151
+ })));
2152
+ api.post("/views", h(async (req) => {
2153
+ const { spec, shared = false } = req.body ?? {};
2154
+ if (!spec?.name || !spec?.page || typeof spec.query !== "object") {
2155
+ throw Object.assign(new Error("view spec required"), { status: 400 });
2156
+ }
2157
+ return saveView({
2158
+ ViewModel,
2159
+ tenantId: req.viewer.tenantId,
2160
+ viewerRef: req.viewer.viewerRef,
2161
+ spec,
2162
+ shared: !!shared
2163
+ });
2164
+ }));
2165
+ api.delete("/views/:id", h(async (req, res) => {
2166
+ const doc = await ViewModel.findOne({ _id: req.params.id, tenantId: req.viewer.tenantId }).lean();
2167
+ if (!doc) return { removed: 0 };
2168
+ const mine = doc.ownerRef && doc.ownerRef === req.viewer.viewerRef;
2169
+ if (!mine && req.viewer.role !== "admin") {
2170
+ res.status(403).json({ error: "forbidden" });
2171
+ return void 0;
2172
+ }
2173
+ await ViewModel.deleteOne({ _id: doc._id });
2174
+ return { removed: 1 };
2175
+ }));
2176
+ api.get("/system", h(async (req) => {
2177
+ const quarantine = await t.collections.rejects().find(
2178
+ isPlatformScope(req.viewer.tenantId) ? {} : { "raw.tenantId": req.viewer.tenantId },
2179
+ { sort: { at: -1 }, limit: 50 }
2180
+ ).toArray().catch(() => []);
2181
+ const indexes = await t.models.telemetry.collection.indexes().catch(() => []);
2182
+ const keys = req.viewer.role === "admin" ? await t.models.keys.find({}, { secretHash: 0 }).sort({ createdAt: -1 }).limit(100).lean() : [];
2183
+ return {
2184
+ counters: t.counters,
2185
+ quarantine,
2186
+ indexCount: indexes.length,
2187
+ indexBudget: INDEX_BUDGET,
2188
+ keys,
2189
+ role: req.viewer.role
2190
+ };
2191
+ }));
2192
+ api.post("/system/keys/:id/revoke", h(async (req, res) => {
2193
+ if (req.viewer.role !== "admin") {
2194
+ res.status(403).json({ error: "forbidden" });
2195
+ return void 0;
2196
+ }
2197
+ const r = await t.models.keys.updateOne(
2198
+ { _id: req.params.id, revokedAt: null },
2199
+ { $set: { revokedAt: /* @__PURE__ */ new Date() } }
2200
+ );
2201
+ return { revoked: r.modifiedCount ?? 0 };
2202
+ }));
2203
+ api.use((err, _req, res, _next) => {
2204
+ const status = err?.status ?? 500;
2205
+ if (status >= 500) t.logger.error("[telemetry:dashboard]", err);
2206
+ res.status(status).json({ error: status >= 500 ? "internal_error" : String(err?.message ?? "bad_request") });
2207
+ });
2208
+ const router = express2__default.default.Router();
2209
+ router.use("/api", api);
2210
+ router.use("/_assets", express2__default.default.static(path2__default.default.join(spaDir, "_assets"), {
2211
+ maxAge: "1y",
2212
+ immutable: true,
2213
+ index: false
2214
+ }));
2215
+ const base = `${mountPath.replace(/\/$/, "")}/`;
2216
+ router.get(/.*/, (_req, res) => {
2217
+ let html;
2218
+ try {
2219
+ html = fs__default.default.readFileSync(path2__default.default.join(spaDir, "index.html"), "utf8");
2220
+ } catch {
2221
+ return res.status(503).type("text/plain").send(
2222
+ "telemetry: UI bundle not found. Run `npm run build` in the package, or pass an explicit `spaDir`."
2223
+ );
2224
+ }
2225
+ const config = escapeJson({ apiBase: `${apiBase.replace(/\/$/, "")}/api`, mountPath, title });
2226
+ res.setHeader("Cache-Control", "no-store");
2227
+ res.type("html").send(
2228
+ html.replace(
2229
+ "<!--telemetry-config-->",
2230
+ `<base href="${base}" />
2231
+ <script>window.__TELEMETRY__=${config}</script>`
2232
+ )
2233
+ );
2234
+ });
2235
+ return router;
2236
+ }
2237
+
2238
+ // src/server/index.ts
2239
+ function createTelemetry(config) {
2240
+ const {
2241
+ registry,
2242
+ collection = "telemetry",
2243
+ modelName = "Telemetry",
2244
+ logger = noopLogger
2245
+ } = config;
2246
+ validateRegistry(registry);
2247
+ for (const [name, spec] of Object.entries(registry)) {
2248
+ if (!spec.data) continue;
2249
+ if (Object.prototype.hasOwnProperty.call(spec, "retentionDays")) continue;
2250
+ const days = RETENTION_DAYS[spec.kind];
2251
+ if (days == null) continue;
2252
+ logger.warn(
2253
+ `[telemetry] "${name}" declares \`data\` but inherits retentionDays=${days} from kind=${spec.kind} \u2014 its payloads are stamped to expire in ${days} days, and that cannot be undone after the write. Set an explicit retentionDays (null = immortal) to choose, and to silence this.`
2254
+ );
2255
+ }
2256
+ const conn = config.connection.connection ?? config.connection;
2257
+ const counters = newCounters();
2258
+ const { TelemetryModel, byKind } = buildTelemetryModels({
2259
+ connection: conn,
2260
+ registry,
2261
+ counters,
2262
+ modelName,
2263
+ collection,
2264
+ platforms: config.platforms,
2265
+ bodyMax: config.bodyMax
2266
+ });
2267
+ const RollupModel = buildRollupModel(conn, `${modelName}Rollup`, `${collection}_rollups`);
2268
+ const CheckpointModel = buildCheckpointModel(conn, `${modelName}Checkpoint`, `${collection}_checkpoints`);
2269
+ const KeyModel = buildKeyModel(conn, `${modelName}Key`, `${collection}_keys`);
2270
+ const rejects = () => conn.db.collection(`${collection}_rejects`);
2271
+ const aliases = () => conn.db.collection(`${collection}_aliases`);
2272
+ const views = () => conn.db.collection(`${collection}_views`);
2273
+ const inFlight = /* @__PURE__ */ new Set();
2274
+ const track = (p) => {
2275
+ inFlight.add(p);
2276
+ void p.finally(() => inFlight.delete(p));
2277
+ };
2278
+ const emit = createEmitter({ registry, byKind, RollupModel, rejects, counters, logger, track });
2279
+ const forget = createForget({
2280
+ TelemetryModel,
2281
+ RollupModel,
2282
+ rejects,
2283
+ aliases,
2284
+ views,
2285
+ pepper: () => {
2286
+ const p = config.pepper ?? process.env.TELEMETRY_PEPPER;
2287
+ if (!p) {
2288
+ throw new Error(
2289
+ "telemetry: forget() needs a pepper \u2014 pass `pepper` to createTelemetry() or set TELEMETRY_PEPPER"
2290
+ );
2291
+ }
2292
+ return p;
2293
+ },
2294
+ globalSubjectRefs: () => config.globalSubjectRefs === true
2295
+ });
2296
+ const syncIndexes = createSyncIndexes({
2297
+ registry,
2298
+ TelemetryModel,
2299
+ models: [TelemetryModel, ...Object.values(byKind), RollupModel, CheckpointModel, KeyModel],
2300
+ rejects
2301
+ });
2302
+ return {
2303
+ /** write — the only write */
2304
+ emit,
2305
+ /** erasure: delete sole-party rows, redact shared ones, rekey rollups, drop aliases */
2306
+ forget,
2307
+ /**
2308
+ * Tenant scope is not optional — force every read through here. The five
2309
+ * dashboard query primitives (records/series/distribution/rollups/journey)
2310
+ * build on these in the read layer.
2311
+ *
2312
+ * scoped() does NOT know about PLATFORM_SCOPE, and the omission is
2313
+ * deliberate. This is the host-facing isolation primitive, and its
2314
+ * guarantee is worth more unconditional: whatever string goes in, only rows
2315
+ * carrying that string come out. `scoped('*')` therefore scopes to the
2316
+ * literal '*' — and since '*' is reserved on the write side, it matches
2317
+ * nothing. The cross-tenant escape hatch lives one layer up, in the query
2318
+ * primitives behind viewerAdapter, where an authorization decision has
2319
+ * actually been made about who is asking.
2320
+ */
2321
+ scoped(tenantId) {
2322
+ return {
2323
+ find: (q = {}) => TelemetryModel.find({ ...q, tenantId }),
2324
+ aggregate: (stages) => TelemetryModel.aggregate([{ $match: { tenantId } }, ...stages]),
2325
+ rollups: (q = {}) => RollupModel.find({ ...q, tenantId }),
2326
+ rollupAggregate: (stages) => RollupModel.aggregate([{ $match: { tenantId } }, ...stages])
2327
+ };
2328
+ },
2329
+ /** pull-importer watermark — advisory; downstream writers must be idempotent */
2330
+ checkpoint: createCheckpointFactory(CheckpointModel, logger),
2331
+ /** boot: build declared + registry-driven indexes, await before first write */
2332
+ syncIndexes,
2333
+ /** await in-flight fire-and-forget writes (tests, graceful shutdown) */
2334
+ async flush() {
2335
+ while (inFlight.size) await Promise.allSettled([...inFlight]);
2336
+ },
2337
+ /** drop/default/cap counts — surface on /metrics so drops are never silent */
2338
+ counters,
2339
+ /** the registry, exposed for the router factories — hosts should import their own */
2340
+ registry,
2341
+ logger,
2342
+ /** mint an ingest key; the full key string is returned once, never again */
2343
+ createKey: (input) => createKey(KeyModel, input),
2344
+ /** the models, exposed for hosts and the router factories */
2345
+ models: {
2346
+ telemetry: TelemetryModel,
2347
+ byKind,
2348
+ rollups: RollupModel,
2349
+ checkpoints: CheckpointModel,
2350
+ keys: KeyModel
2351
+ },
2352
+ /** side collections (rejects/aliases live outside mongoose models) */
2353
+ collections: { rejects, aliases }
2354
+ };
2355
+ }
2356
+
2357
+ exports.BODY_MAX_CHARS = BODY_MAX_CHARS;
2358
+ exports.DEFAULT_LIMITS = DEFAULT_LIMITS;
2359
+ exports.Env = Env;
2360
+ exports.INDEX_BUDGET = INDEX_BUDGET;
2361
+ exports.KeyKind = KeyKind;
2362
+ exports.LogLevel = LogLevel;
2363
+ exports.Origin = Origin;
2364
+ exports.PLATFORM_SCOPE = PLATFORM_SCOPE;
2365
+ exports.RETENTION_DAYS = RETENTION_DAYS;
2366
+ exports.SAMPLE_RATE = SAMPLE_RATE;
2367
+ exports.SCHEMA_VERSION = SCHEMA_VERSION;
2368
+ exports.TelemetryKind = TelemetryKind;
2369
+ exports.TenantMode = TenantMode;
2370
+ exports.boundedMeta = boundedMeta;
2371
+ exports.createDashboard = createDashboard;
2372
+ exports.createIngest = createIngest;
2373
+ exports.createKey = createKey;
2374
+ exports.createQueries = createQueries;
2375
+ exports.createTelemetry = createTelemetry;
2376
+ exports.defaultSpaDir = defaultSpaDir;
2377
+ exports.defineRegistry = defineRegistry;
2378
+ exports.deriveViews = deriveViews;
2379
+ exports.findFamily = findFamily;
2380
+ exports.hashSecret = hashSecret;
2381
+ exports.isPlatformScope = isPlatformScope;
2382
+ exports.median = median;
2383
+ exports.newId = newId;
2384
+ exports.parseKeyString = parseKeyString;
2385
+ exports.plain = plain;
2386
+ exports.requireMilestoneFamily = requireMilestoneFamily;
2387
+ exports.resolveDim = resolveDim;
2388
+ exports.summarizeStages = summarizeStages;
2389
+ exports.traceKeep = traceKeep;
2390
+ exports.truncate = truncate;
2391
+ exports.validateRegistry = validateRegistry;
2392
+ exports.verifySecret = verifySecret;
2393
+ //# sourceMappingURL=index.cjs.map
2394
+ //# sourceMappingURL=index.cjs.map