@jeffjassky/telemetry 0.2.0 → 0.4.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 CHANGED
@@ -68,8 +68,24 @@ var newCounters = () => ({
68
68
  capped: 0,
69
69
  rollupSkipped: 0,
70
70
  deduped: 0,
71
- truncated: 0
71
+ truncated: 0,
72
+ rollupSkippedBy: {},
73
+ undeclaredAttrs: {}
72
74
  });
75
+ var COUNTER_MAP_MAX = 1e3;
76
+ var COUNTER_OVERFLOW_KEY = "(other)|(other)";
77
+ var bumpCounterMap = (map, key) => {
78
+ const seen = map[key];
79
+ if (seen !== void 0) {
80
+ map[key] = seen + 1;
81
+ return;
82
+ }
83
+ if (map[COUNTER_OVERFLOW_KEY] !== void 0 || Object.keys(map).length >= COUNTER_MAP_MAX) {
84
+ map[COUNTER_OVERFLOW_KEY] = (map[COUNTER_OVERFLOW_KEY] ?? 0) + 1;
85
+ return;
86
+ }
87
+ map[key] = 1;
88
+ };
73
89
  var traceKeep = (traceId, rate) => {
74
90
  if (rate >= 1) return true;
75
91
  if (!traceId) return Math.random() < rate;
@@ -397,15 +413,15 @@ function buildBaseSchema(collection, registry, counters, opts) {
397
413
  if (this.kind === TelemetryKind.State && !this.state?.to) {
398
414
  throw new Error("telemetry: state requires state.to");
399
415
  }
400
- const check = (label2, m, zschema) => {
416
+ const check = (label3, m, zschema) => {
401
417
  const obj = Object.fromEntries(m ?? []);
402
418
  if (!zschema) {
403
- if (Object.keys(obj).length) throw new Error(`telemetry: "${this.name}" declares no ${label2}`);
419
+ if (Object.keys(obj).length) throw new Error(`telemetry: "${this.name}" declares no ${label3}`);
404
420
  return;
405
421
  }
406
422
  const s = zschema.strict?.() ?? zschema;
407
423
  const r = s.safeParse(obj);
408
- if (!r.success) throw new Error(`telemetry: ${label2} invalid for "${this.name}": ${r.error.message}`);
424
+ if (!r.success) throw new Error(`telemetry: ${label3} invalid for "${this.name}": ${r.error.message}`);
409
425
  };
410
426
  check("attrs", this.attrs, spec.attrs);
411
427
  check("metrics", this.metrics, spec.metrics);
@@ -554,6 +570,7 @@ async function recordRollup(RollupModel, doc, name, spec, counters) {
554
570
  if (v == null || v === "") {
555
571
  if (spec.dimDefault === void 0) {
556
572
  counters.rollupSkipped++;
573
+ bumpCounterMap(counters.rollupSkippedBy, `${as}|${label(src)}`);
557
574
  return;
558
575
  }
559
576
  v = spec.dimDefault;
@@ -645,6 +662,16 @@ function createCheckpointFactory(CheckpointModel, logger) {
645
662
  }
646
663
 
647
664
  // src/server/emit.ts
665
+ function noteUndeclaredAttrs(counters, name, spec, attrs) {
666
+ if (!attrs || typeof attrs !== "object") return;
667
+ const keys = attrs instanceof Map ? [...attrs.keys()] : Object.keys(attrs);
668
+ const shape = spec.attrs?.shape;
669
+ for (const raw of keys) {
670
+ const key = String(raw).replace(/\./g, "_");
671
+ if (shape && Object.prototype.hasOwnProperty.call(shape, key)) continue;
672
+ bumpCounterMap(counters.undeclaredAttrs, `${name}|${key}`);
673
+ }
674
+ }
648
675
  function createEmitter(ctx) {
649
676
  const { registry, byKind, RollupModel, rejects, counters } = ctx;
650
677
  const burstBuckets = /* @__PURE__ */ new Map();
@@ -691,6 +718,7 @@ function createEmitter(ctx) {
691
718
  attrs: safe(doc.attrs),
692
719
  metrics: safe(doc.metrics)
693
720
  };
721
+ noteUndeclaredAttrs(counters, name, spec, doc.attrs);
694
722
  const onFail = async (e) => {
695
723
  counters.rejected++;
696
724
  await rejects().insertOne({ at: /* @__PURE__ */ new Date(), name, reason: String(e), raw: plain(doc) }).catch(() => {
@@ -896,10 +924,10 @@ function parseKeyString(raw) {
896
924
  if (!raw) return null;
897
925
  const m = KEY_RE.exec(raw.trim());
898
926
  if (!m) return null;
899
- const [, prefix, label2, id, secret] = m;
927
+ const [, prefix, label3, id, secret] = m;
900
928
  if (prefix === "sk" && !secret) return null;
901
929
  if (prefix === "pk" && secret) return null;
902
- return { kind: prefix === "pk" ? KeyKind.Publishable : KeyKind.Secret, label: label2, id, secret };
930
+ return { kind: prefix === "pk" ? KeyKind.Publishable : KeyKind.Secret, label: label3, id, secret };
903
931
  }
904
932
  var SCRYPT_VERSION = "scrypt1";
905
933
  var SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
@@ -957,7 +985,7 @@ async function createKey(KeyModel, input) {
957
985
  tenantId,
958
986
  service,
959
987
  env,
960
- label: label2 = "live",
988
+ label: label3 = "live",
961
989
  origins = [],
962
990
  allowedNames,
963
991
  maxPerMinute = 600
@@ -990,7 +1018,7 @@ async function createKey(KeyModel, input) {
990
1018
  createdAt: /* @__PURE__ */ new Date()
991
1019
  });
992
1020
  const prefix = kind === KeyKind.Publishable ? "pk" : "sk";
993
- return { key: secret ? `${prefix}_${label2}_${id}_${secret}` : `${prefix}_${label2}_${id}`, id };
1021
+ return { key: secret ? `${prefix}_${label3}_${id}_${secret}` : `${prefix}_${label3}_${id}`, id };
994
1022
  }
995
1023
  var BATCH_MAX = 100;
996
1024
  function createIngest(opts) {
@@ -1192,6 +1220,7 @@ function createIngest(opts) {
1192
1220
  const occurredRaw = rec.occurredAt ? Date.parse(rec.occurredAt) : NaN;
1193
1221
  const occurredAt = Number.isFinite(occurredRaw) ? new Date(occurredRaw - clockSkewMs) : receivedAt;
1194
1222
  const safeMap = (o) => o && typeof o === "object" ? new Map(Object.entries(o).map(([k, v]) => [k.replace(/\./g, "_"), v])) : /* @__PURE__ */ new Map();
1223
+ noteUndeclaredAttrs(t.counters, name, spec, rec.attrs);
1195
1224
  const Model = t.models.byKind[spec.kind];
1196
1225
  const d = new Model({
1197
1226
  // facts the wire may not assert: tenant, service, env, origin, plane
@@ -1262,6 +1291,292 @@ function createIngest(opts) {
1262
1291
  return router;
1263
1292
  }
1264
1293
 
1294
+ // src/server/catalog.ts
1295
+ var label2 = (src) => src.slice(src.indexOf(":") + 1);
1296
+ var LEAF_TYPES = {
1297
+ string: "string",
1298
+ number: "number",
1299
+ int: "number",
1300
+ bigint: "number",
1301
+ boolean: "boolean",
1302
+ date: "date"
1303
+ };
1304
+ function walkAttr(schema) {
1305
+ let node = schema;
1306
+ let optional = false;
1307
+ for (let depth = 0; node && depth < 20; depth++) {
1308
+ const def = node._zod?.def ?? node.def;
1309
+ if (!def?.type) break;
1310
+ switch (def.type) {
1311
+ // these three all mean "the value may be absent from a stored record",
1312
+ // which is the only thing `optional` claims
1313
+ case "optional":
1314
+ case "nullable":
1315
+ case "default":
1316
+ optional = true;
1317
+ node = def.innerType;
1318
+ continue;
1319
+ case "catch":
1320
+ case "readonly":
1321
+ node = def.innerType;
1322
+ continue;
1323
+ // a pipe is `in -> out`; the INPUT side is what a caller may send and so
1324
+ // what a stored value was validated as. The output of a transform is
1325
+ // frequently a shape no filter could ever be written against.
1326
+ case "pipe":
1327
+ node = def.in;
1328
+ continue;
1329
+ case "enum": {
1330
+ const options = Array.isArray(node.options) ? node.options : Object.values(def.entries ?? {});
1331
+ return { type: "enum", values: options.map(String), optional };
1332
+ }
1333
+ case "literal":
1334
+ return { type: "enum", values: [...def.values ?? []].map(String), optional };
1335
+ default:
1336
+ return { type: LEAF_TYPES[def.type] ?? "string", optional };
1337
+ }
1338
+ }
1339
+ return { type: "string", optional };
1340
+ }
1341
+ var dim = (key, type, o = {}) => ({
1342
+ key,
1343
+ // the two pseudo-dims are derived at query time from subjectKeys / actor, so
1344
+ // they carry no `field:` prefix and label to themselves
1345
+ label: key.startsWith("field:") ? key.slice(6) : key,
1346
+ type,
1347
+ ...o.values ? { values: [...o.values] } : {},
1348
+ optional: o.optional ?? false,
1349
+ indexed: o.indexed ?? false
1350
+ });
1351
+ var envelopeDims = (platforms) => [
1352
+ dim("field:kind", "enum", { values: TELEMETRY_KINDS, indexed: true }),
1353
+ dim("field:name", "string", { indexed: true }),
1354
+ dim("field:severity", "enum", { values: Object.values(LogLevel) }),
1355
+ dim("field:env", "enum", { values: Object.values(Env) }),
1356
+ dim("field:service", "string"),
1357
+ dim("field:release", "string"),
1358
+ dim("field:origin", "enum", { values: Object.values(Origin) }),
1359
+ // client context is absent on server-origin records, so both of its dims are optional
1360
+ dim("field:client.platform", "enum", { values: platforms, optional: true }),
1361
+ dim("field:client.appVersion", "string", { optional: true }),
1362
+ dim("subjectType", "string", { optional: true, indexed: true }),
1363
+ dim("actorType", "string", { optional: true })
1364
+ ];
1365
+ var kindDims = (kind) => {
1366
+ switch (kind) {
1367
+ case TelemetryKind.Usage:
1368
+ return [
1369
+ dim("field:usage.meter", "string", { indexed: true }),
1370
+ dim("field:usage.billedTo", "string"),
1371
+ dim("field:usage.unit", "string")
1372
+ ];
1373
+ case TelemetryKind.State:
1374
+ return [
1375
+ dim("field:state.key", "string", { indexed: true }),
1376
+ dim("field:state.to", "string", { indexed: true })
1377
+ ];
1378
+ case TelemetryKind.Error:
1379
+ return [dim("field:error.type", "string"), dim("field:error.handled", "boolean")];
1380
+ default:
1381
+ return [];
1382
+ }
1383
+ };
1384
+ var RAW_OPS = ["avg", "p50", "p95", "p99"];
1385
+ function deriveCatalog(registry, opts = {}) {
1386
+ const platforms = [.../* @__PURE__ */ new Set([...BUILTIN_PLATFORMS, ...opts.platforms ?? []])];
1387
+ const families = {};
1388
+ for (const [name, spec] of Object.entries(registry)) {
1389
+ for (const r of spec.rollups ?? []) {
1390
+ const as = r.as ?? name;
1391
+ const seen = families[as];
1392
+ if (!seen) {
1393
+ families[as] = {
1394
+ as,
1395
+ by: [...r.by],
1396
+ labels: r.by.map(label2),
1397
+ bucket: r.bucket ?? null,
1398
+ lifetime: !r.bucket,
1399
+ // `subjects` only means anything when there is a subject dim to
1400
+ // restrict; without one it selects nothing and claiming it would
1401
+ // offer a subject filter the family cannot answer
1402
+ subjectTypes: r.by.includes("subject") ? [...r.subjects ?? []] : [],
1403
+ sums: [...r.sum ?? []],
1404
+ capture: (r.capture ?? []).map(label2),
1405
+ feeders: [name],
1406
+ retentionDays: r.retentionDays ?? null
1407
+ };
1408
+ continue;
1409
+ }
1410
+ for (const k of r.sum ?? []) if (!seen.sums.includes(k)) seen.sums.push(k);
1411
+ for (const c of r.capture ?? []) {
1412
+ const l = label2(c);
1413
+ if (!seen.capture.includes(l)) seen.capture.push(l);
1414
+ }
1415
+ if (!seen.feeders.includes(name)) seen.feeders.push(name);
1416
+ }
1417
+ }
1418
+ const events = {};
1419
+ const namespaces = {};
1420
+ const subjectTypes = [];
1421
+ const noteSubject = (t) => {
1422
+ if (!subjectTypes.includes(t)) subjectTypes.push(t);
1423
+ };
1424
+ for (const [name, spec] of Object.entries(registry)) {
1425
+ const dot = name.indexOf(".");
1426
+ const namespace = dot === -1 ? name : name.slice(0, dot);
1427
+ (namespaces[namespace] ??= []).push(name);
1428
+ for (const s of spec.subjects) noteSubject(s);
1429
+ const indexedAttrs = [...spec.indexedAttrs ?? []];
1430
+ const dims = Object.entries(spec.attrs?.shape ?? {}).map(
1431
+ ([key, schema]) => {
1432
+ const walked = walkAttr(schema);
1433
+ return {
1434
+ key: `attr:${key}`,
1435
+ label: key,
1436
+ type: walked.type,
1437
+ ...walked.values ? { values: walked.values } : {},
1438
+ optional: walked.optional,
1439
+ indexed: indexedAttrs.includes(key)
1440
+ };
1441
+ }
1442
+ );
1443
+ dims.push(...kindDims(spec.kind));
1444
+ const eventFamilies = [];
1445
+ const ownSums = /* @__PURE__ */ new Map();
1446
+ for (const r of spec.rollups ?? []) {
1447
+ const as = r.as ?? name;
1448
+ if (!eventFamilies.includes(as)) eventFamilies.push(as);
1449
+ const set = ownSums.get(as) ?? /* @__PURE__ */ new Set();
1450
+ for (const k of r.sum ?? []) set.add(k);
1451
+ ownSums.set(as, set);
1452
+ for (const s of r.subjects ?? []) noteSubject(s);
1453
+ }
1454
+ const measures = [{ key: "count", exactVia: [] }];
1455
+ for (const k of Object.keys(spec.metrics?.shape ?? {})) {
1456
+ measures.push({
1457
+ key: `sum:${k}`,
1458
+ metric: k,
1459
+ exactVia: eventFamilies.filter((as) => ownSums.get(as)?.has(k))
1460
+ });
1461
+ for (const op of RAW_OPS) measures.push({ key: `${op}:${k}`, metric: k, exactVia: [] });
1462
+ }
1463
+ if (spec.kind === TelemetryKind.Span) {
1464
+ for (const op of RAW_OPS) {
1465
+ measures.push({ key: `${op}:durationMs`, metric: "durationMs", exactVia: [] });
1466
+ }
1467
+ }
1468
+ events[name] = {
1469
+ kind: spec.kind,
1470
+ origin: spec.origin,
1471
+ subjects: [...spec.subjects],
1472
+ description: spec.description,
1473
+ namespace,
1474
+ dims,
1475
+ measures,
1476
+ families: eventFamilies,
1477
+ indexedAttrs,
1478
+ indexedMetrics: [...spec.indexedMetrics ?? []],
1479
+ // `hasOwnProperty` rather than `??`, exactly as model.ts stamps expiresAt:
1480
+ // an explicit `retentionDays: null` means immortal and must not fall
1481
+ // through to the per-kind default
1482
+ retentionDays: Object.prototype.hasOwnProperty.call(spec, "retentionDays") ? spec.retentionDays ?? null : RETENTION_DAYS[spec.kind]
1483
+ };
1484
+ }
1485
+ return { events, families, namespaces, envelope: envelopeDims(platforms), subjectTypes };
1486
+ }
1487
+ function projectRegistry(catalog) {
1488
+ return Object.fromEntries(
1489
+ Object.entries(catalog.events).map(([name, e]) => [
1490
+ name,
1491
+ {
1492
+ kind: e.kind,
1493
+ origin: e.origin,
1494
+ subjects: e.subjects,
1495
+ description: e.description,
1496
+ attrKeys: e.dims.filter((d) => d.key.startsWith("attr:")).map((d) => d.label),
1497
+ // every metric key gets exactly one `sum:` measure and nothing else does
1498
+ metricKeys: e.measures.filter((m) => m.key.startsWith("sum:")).map((m) => m.metric),
1499
+ indexedAttrs: e.indexedAttrs,
1500
+ indexedMetrics: e.indexedMetrics,
1501
+ rollups: e.families.map((as) => {
1502
+ const f = catalog.families[as];
1503
+ return { as: f.as, by: f.by, bucket: f.bucket, sum: f.sums, subjects: f.subjectTypes };
1504
+ })
1505
+ }
1506
+ ])
1507
+ );
1508
+ }
1509
+
1510
+ // src/server/suggest.ts
1511
+ var UNREGISTERED_REASON = "unregistered event";
1512
+ var MAX_SUGGESTIONS = 50;
1513
+ var NAME_MAX = 120;
1514
+ var quote = (s) => /^[A-Za-z0-9_.:$-]+$/.test(s) ? `'${s}'` : JSON.stringify(s);
1515
+ var prop = (s) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : quote(s);
1516
+ var times = (n) => `${n} time${n === 1 ? "" : "s"}`;
1517
+ var split = (k) => {
1518
+ const i = k.indexOf("|");
1519
+ return i === -1 ? [k, ""] : [k.slice(0, i), k.slice(i + 1)];
1520
+ };
1521
+ function deriveSuggestions(input) {
1522
+ const { counters, catalog, quarantine = [] } = input;
1523
+ const out = [];
1524
+ for (const [k, count2] of Object.entries(counters.undeclaredAttrs ?? {})) {
1525
+ if (k === COUNTER_OVERFLOW_KEY || !count2) continue;
1526
+ const [name, key] = split(k);
1527
+ if (!name || !key) continue;
1528
+ const facet = catalog.events[name];
1529
+ const line = `${prop(key)}: z.string().max(64),`;
1530
+ const hasAttrs = !!facet?.dims.some((d) => d.key.startsWith("attr:"));
1531
+ out.push({
1532
+ kind: "undeclared_attr",
1533
+ target: name,
1534
+ key,
1535
+ count: count2,
1536
+ message: `\`${name}\` has been sent with attr \`${key}\` ${times(count2)} \u2014 not declared`,
1537
+ fix: hasAttrs ? line : `attrs: z.object({ ${prop(key)}: z.string().max(64) }),`
1538
+ });
1539
+ }
1540
+ for (const [k, count2] of Object.entries(counters.rollupSkippedBy ?? {})) {
1541
+ if (k === COUNTER_OVERFLOW_KEY || !count2) continue;
1542
+ const [as, dim2] = split(k);
1543
+ if (!as || !dim2) continue;
1544
+ const feeders = catalog.families[as]?.feeders ?? [];
1545
+ const where = feeders.length ? `// on the \`${as}\` rollup of ${feeders.map((f) => `\`${f}\``).join(", ")}
1546
+ ` : "";
1547
+ out.push({
1548
+ kind: "missing_dim_default",
1549
+ target: as,
1550
+ key: dim2,
1551
+ count: count2,
1552
+ message: `\`${as}\` skipped ${count2} record${count2 === 1 ? "" : "s"} with no \`${dim2}\` \u2014 declare \`dimDefault\``,
1553
+ fix: `${where}dimDefault: 'unknown',`
1554
+ });
1555
+ }
1556
+ const unregistered = /* @__PURE__ */ new Map();
1557
+ for (const row of quarantine) {
1558
+ if (typeof row?.reason !== "string" || !row.reason.includes(UNREGISTERED_REASON)) continue;
1559
+ const name = typeof row.name === "string" ? row.name.slice(0, NAME_MAX) : "";
1560
+ if (!name || name === "(unnamed)") continue;
1561
+ unregistered.set(name, (unregistered.get(name) ?? 0) + 1);
1562
+ }
1563
+ for (const [name, count2] of unregistered) {
1564
+ out.push({
1565
+ kind: "unregistered_event",
1566
+ target: name,
1567
+ count: count2,
1568
+ message: `\`${name}\` was rejected ${times(count2)} \u2014 not in the registry`,
1569
+ // the minimum that boots: validateRegistry wants a kind, an origin, and
1570
+ // a subjects array, and nothing here can guess the rest
1571
+ fix: `${quote(name)}: { kind: 'event', origin: 'client', subjects: [], description: '' },`
1572
+ });
1573
+ }
1574
+ out.sort(
1575
+ (a, b) => b.count - a.count || a.target.localeCompare(b.target) || (a.key ?? "").localeCompare(b.key ?? "")
1576
+ );
1577
+ return out.slice(0, MAX_SUGGESTIONS);
1578
+ }
1579
+
1265
1580
  // src/server/funnel.ts
1266
1581
  var DAY_MS = 864e5;
1267
1582
  function median(values) {
@@ -1469,6 +1784,10 @@ var DEFAULT_LIMITS = {
1469
1784
  rollups: 500,
1470
1785
  trace: 500,
1471
1786
  journey: 500,
1787
+ breakdown: 50,
1788
+ // top groups — a starting point, to be measured on real hosts
1789
+ values: 200,
1790
+ // top values of one dimension — a picker, not a table
1472
1791
  distribution: 1e5,
1473
1792
  distinct: 1e5,
1474
1793
  funnel: 5e3
@@ -1482,7 +1801,10 @@ function buildMatch(scope, range, f) {
1482
1801
  occurredAt: { $gte: range.from, $lt: range.to }
1483
1802
  };
1484
1803
  for (const k of ["kind", "name", "severity", "env", "service", "release", "traceId"]) {
1485
- if (f[k]) match[k] = f[k];
1804
+ const v = f[k];
1805
+ if (Array.isArray(v)) {
1806
+ if (v.length) match[k] = { $in: v };
1807
+ } else if (v) match[k] = v;
1486
1808
  }
1487
1809
  if (f.subject) match.subjectKeys = f.subject;
1488
1810
  for (const [k, v] of Object.entries(f.attrs ?? {})) match[`attrs.${k}`] = v;
@@ -1505,6 +1827,68 @@ function buildMatch(scope, range, f) {
1505
1827
  }
1506
1828
  return match;
1507
1829
  }
1830
+ var INTERVALS = ["hour", "day", "week", "month"];
1831
+ var truncTo = (path3, unit) => ({
1832
+ $dateTrunc: { date: path3, unit, ...unit === "week" ? { startOfWeek: "monday" } : {} }
1833
+ });
1834
+ function measureAccumulator(measure) {
1835
+ const m = /^(sum|avg):(.+)$/.exec(measure);
1836
+ if (!m) return { $sum: { $divide: [1, { $ifNull: ["$sampleRate", 1] }] } };
1837
+ const path3 = m[2] === "durationMs" ? "$durationMs" : `$metrics.${m[2]}`;
1838
+ return m[1] === "sum" ? { $sum: path3 } : { $avg: path3 };
1839
+ }
1840
+ var badRequest = (message) => Object.assign(new Error(`telemetry: breakdown() \u2014 ${message}`), { status: 400 });
1841
+ var BREAKDOWN_FIELDS = [
1842
+ "kind",
1843
+ "name",
1844
+ "severity",
1845
+ "env",
1846
+ "service",
1847
+ "release",
1848
+ "origin",
1849
+ "client.platform",
1850
+ "client.appVersion",
1851
+ "usage.meter",
1852
+ "usage.billedTo",
1853
+ "usage.unit",
1854
+ "state.key",
1855
+ "state.to",
1856
+ "error.type",
1857
+ "error.handled"
1858
+ ];
1859
+ var typePrefix = (ref) => ({
1860
+ $let: {
1861
+ vars: { ref },
1862
+ in: {
1863
+ $cond: [
1864
+ { $eq: [{ $type: "$$ref" }, "string"] },
1865
+ { $arrayElemAt: [{ $split: ["$$ref", ":"] }, 0] },
1866
+ null
1867
+ ]
1868
+ }
1869
+ }
1870
+ });
1871
+ function dimExpression(dim2) {
1872
+ if (dim2.startsWith("attr:")) {
1873
+ const key = dim2.slice(5);
1874
+ if (!key) throw badRequest('`attr:` needs a key, e.g. "attr:plan"');
1875
+ return { $ifNull: [`$attrs.${key}`, null] };
1876
+ }
1877
+ if (dim2.startsWith("field:")) {
1878
+ const path3 = dim2.slice(6);
1879
+ if (!BREAKDOWN_FIELDS.includes(path3)) {
1880
+ throw badRequest(
1881
+ `"field:${path3}" is not groupable. Allowed paths: ${BREAKDOWN_FIELDS.join(", ")}. Grouping by \`data.*\`, \`body\`, or an arbitrary path is refused \u2014 those are unindexed free-form content, and a $group over them scans it all. Use \`attr:<key>\` for a declared attr.`
1882
+ );
1883
+ }
1884
+ return { $ifNull: [`$${path3}`, null] };
1885
+ }
1886
+ if (dim2 === "subjectType") return typePrefix({ $arrayElemAt: ["$subjectKeys", 0] });
1887
+ if (dim2 === "actorType") return typePrefix("$actor");
1888
+ throw badRequest(
1889
+ `"${dim2}" is not a dimension. Use "attr:<key>", "field:<path>", "subjectType" or "actorType".`
1890
+ );
1891
+ }
1508
1892
  var QueryCache = class {
1509
1893
  constructor(ttlMs, cap) {
1510
1894
  this.ttlMs = ttlMs;
@@ -1578,16 +1962,9 @@ function createQueries(ctx) {
1578
1962
  return cache.get(
1579
1963
  key,
1580
1964
  () => 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
1965
  const buckets = await ctx.TelemetryModel.aggregate([
1584
1966
  { $match: buildMatch(scope, range, filter) },
1585
- {
1586
- $group: {
1587
- _id: { $dateTrunc: { date: "$occurredAt", unit: interval, ...interval === "week" ? { startOfWeek: "monday" } : {} } },
1588
- value
1589
- }
1590
- },
1967
+ { $group: { _id: truncTo("$occurredAt", interval), value: measureAccumulator(measure) } },
1591
1968
  { $sort: { _id: 1 } },
1592
1969
  { $limit: limits.series }
1593
1970
  ]);
@@ -1595,6 +1972,114 @@ function createQueries(ctx) {
1595
1972
  })
1596
1973
  );
1597
1974
  },
1975
+ /**
1976
+ * Top groups of a measure by one or two dimensions — "which models cost the
1977
+ * most", "errors by release", "events by platform per week". The primitive
1978
+ * that replaces a page's client-side grouping of whatever rows it happened
1979
+ * to have fetched, which answered "this page" while reading like it
1980
+ * answered the range (reports §6).
1981
+ *
1982
+ * THE CAP IS ON GROUPS RETURNED, NEVER ON ROWS SCANNED. Every `$limit`
1983
+ * below sits AFTER a `$group`, exactly as series() does: the scan is bounded
1984
+ * by buildMatch — tenant, range, filters, indexes — and nothing else, so a
1985
+ * quarter of a million records is one pass and 50 rows. Truncation
1986
+ * therefore keeps the TOP groups by measure, which is what a breakdown
1987
+ * table means; a cap on documents scanned would return an arbitrary prefix
1988
+ * and call it the top.
1989
+ *
1990
+ * With an `interval` this runs a SECOND aggregate restricted to the top
1991
+ * groups, rather than one pipeline that groups by (dims, bucket) and folds.
1992
+ * Two reasons: the ranking must be the measure over the WHOLE range (the
1993
+ * same number the no-interval call reports), and folding in one pass means
1994
+ * `$push`-ing every bucket of every group before the cap can apply — the
1995
+ * unbounded intermediate this primitive exists to avoid. The restriction is
1996
+ * an `$expr`/`$or` over the ≤ cap tuples because a dim can be a computed
1997
+ * expression (subjectType), which a plain `$in` on a path cannot address.
1998
+ *
1999
+ * Under PLATFORM_SCOPE it aggregates ACROSS tenants, like series() — one
2000
+ * set of groups with every tenant summed into it, which is the platform-wide
2001
+ * table a platform operator came for. Ask for a per-tenant split by scoping
2002
+ * to a tenant, or with rollups().
2003
+ */
2004
+ breakdown(scope, range, filter, opts) {
2005
+ const groupBy = opts.groupBy ?? [];
2006
+ if (groupBy.length < 1 || groupBy.length > 2) {
2007
+ throw badRequest(`groupBy takes 1 or 2 dimensions, got ${groupBy.length}`);
2008
+ }
2009
+ const measure = opts.measure ?? "count";
2010
+ const interval = opts.interval;
2011
+ if (interval && !INTERVALS.includes(interval)) {
2012
+ throw badRequest(`interval must be one of ${INTERVALS.join(", ")}`);
2013
+ }
2014
+ const dims = groupBy.map(dimExpression);
2015
+ const cap = Math.min(Math.max(1, opts.limit ?? limits.breakdown), limits.breakdown);
2016
+ const key = JSON.stringify([
2017
+ "breakdown",
2018
+ scope,
2019
+ range.from,
2020
+ range.to,
2021
+ filter,
2022
+ groupBy,
2023
+ measure,
2024
+ interval ?? null,
2025
+ cap
2026
+ ]);
2027
+ return cache.get(
2028
+ key,
2029
+ () => timed("breakdown", { scope, filter, groupBy, measure, interval }, async () => {
2030
+ const match = buildMatch(scope, range, filter);
2031
+ const dimId = Object.fromEntries(dims.map((expr, i) => [`d${i}`, expr]));
2032
+ const top = await ctx.TelemetryModel.aggregate([
2033
+ { $match: match },
2034
+ { $group: { _id: dimId, value: measureAccumulator(measure) } },
2035
+ { $sort: { value: -1, _id: 1 } },
2036
+ { $limit: cap + 1 }
2037
+ ]);
2038
+ const truncated = top.length > cap;
2039
+ if (truncated) top.pop();
2040
+ const tuples = top.map(
2041
+ (g) => groupBy.map((_, i) => g._id?.[`d${i}`] ?? null)
2042
+ );
2043
+ if (!interval) {
2044
+ return {
2045
+ rows: top.map((g, i) => ({ dims: tuples[i], value: g.value })),
2046
+ groups: top.length,
2047
+ truncated,
2048
+ bucketsTruncated: false,
2049
+ dataSource: "raw"
2050
+ };
2051
+ }
2052
+ if (!tuples.length) {
2053
+ return { rows: [], groups: 0, truncated, bucketsTruncated: false, dataSource: "raw" };
2054
+ }
2055
+ const inTop = {
2056
+ $or: tuples.map((t) => ({ $and: dims.map((expr, i) => ({ $eq: [expr, t[i] ?? null] })) }))
2057
+ };
2058
+ const bucketCap = limits.series * top.length;
2059
+ const perBucket = await ctx.TelemetryModel.aggregate([
2060
+ { $match: { ...match, $expr: inTop } },
2061
+ { $group: { _id: { at: truncTo("$occurredAt", interval), ...dimId }, value: measureAccumulator(measure) } },
2062
+ // `at` is the first key of `_id`, so one BSON sort orders by bucket
2063
+ // then by dims — deterministic without a second sort key
2064
+ { $sort: { _id: 1 } },
2065
+ { $limit: bucketCap + 1 }
2066
+ ]);
2067
+ const bucketsTruncated = perBucket.length > bucketCap;
2068
+ if (bucketsTruncated) perBucket.pop();
2069
+ return {
2070
+ rows: perBucket.map((b) => ({
2071
+ dims: groupBy.map((_, i) => b._id?.[`d${i}`] ?? null),
2072
+ at: b._id.at,
2073
+ value: b.value
2074
+ })),
2075
+ groups: top.length,
2076
+ truncated,
2077
+ bucketsTruncated,
2078
+ dataSource: "raw"
2079
+ };
2080
+ })
2081
+ );
2082
+ },
1598
2083
  /**
1599
2084
  * Percentiles + histogram off raw. Keep-all makes the SAMPLE complete —
1600
2085
  * no sampling stands between the match and the math (§5.3) — but the
@@ -1836,6 +2321,820 @@ function requireDistinctFamily(registry, as) {
1836
2321
  }
1837
2322
  return spec;
1838
2323
  }
2324
+
2325
+ // src/server/values.ts
2326
+ var empty = (source) => ({
2327
+ values: [],
2328
+ source,
2329
+ truncated: false,
2330
+ dataSource: source
2331
+ });
2332
+ var SAMPLED_COUNT = { $sum: { $divide: [1, { $ifNull: ["$sampleRate", 1] }] } };
2333
+ function createValues(ctx) {
2334
+ const limits = { ...DEFAULT_LIMITS, ...ctx.limits };
2335
+ const slowMs = ctx.slowMs ?? 500;
2336
+ const cache = new QueryCache(ctx.cacheTtlMs ?? 10 * 6e4, ctx.cacheSize ?? 60);
2337
+ const { catalog } = ctx;
2338
+ const timed = async (op, params, run) => {
2339
+ const t0 = Date.now();
2340
+ try {
2341
+ return await run();
2342
+ } finally {
2343
+ const ms = Date.now() - t0;
2344
+ if (ms > slowMs) ctx.onSlowQuery?.({ op, ms, params });
2345
+ }
2346
+ };
2347
+ const eventNames = (names) => names?.length ? names.filter((n) => catalog.events[n]) : Object.keys(catalog.events);
2348
+ function fromCatalog(dim2, names) {
2349
+ const facets = [];
2350
+ for (const d of catalog.envelope) if (d.key === dim2) facets.push(d);
2351
+ for (const name of eventNames(names)) {
2352
+ for (const d of catalog.events[name].dims) if (d.key === dim2) facets.push(d);
2353
+ }
2354
+ const out = [];
2355
+ for (const f of facets) for (const v of f.values ?? []) if (!out.includes(v)) out.push(v);
2356
+ return out;
2357
+ }
2358
+ function pickFamily(dim2, names) {
2359
+ if (dim2 === "subjectType" || dim2 === "actorType") return null;
2360
+ const matches = Object.values(catalog.families).map((f) => ({ f, index: f.by.indexOf(dim2) })).filter(({ f, index }) => index !== -1 && (!names?.length || f.feeders.some((n) => names.includes(n)))).sort((a, b) => a.f.by.length - b.f.by.length || a.f.as.localeCompare(b.f.as));
2361
+ const best = matches[0];
2362
+ return best ? { as: best.f.as, index: best.index, label: best.f.labels[best.index] } : null;
2363
+ }
2364
+ function rawReadable(dim2, names) {
2365
+ try {
2366
+ dimExpression(dim2);
2367
+ } catch {
2368
+ return false;
2369
+ }
2370
+ if (!dim2.startsWith("attr:")) return true;
2371
+ const key = dim2.slice(5);
2372
+ return eventNames(names).some((n) => catalog.events[n].indexedAttrs.includes(key));
2373
+ }
2374
+ return async function values(scope, params) {
2375
+ const { dim: dim2, names, range } = params;
2376
+ if (!dim2) return empty("none");
2377
+ const cap = Math.min(Math.max(1, params.limit ?? limits.values), limits.values);
2378
+ const key = JSON.stringify([
2379
+ "values",
2380
+ scope,
2381
+ dim2,
2382
+ names ?? null,
2383
+ range?.from ?? null,
2384
+ range?.to ?? null,
2385
+ cap
2386
+ ]);
2387
+ return cache.get(
2388
+ key,
2389
+ () => timed("values", { scope, dim: dim2, names }, async () => {
2390
+ const declared = fromCatalog(dim2, names);
2391
+ if (declared.length) {
2392
+ return { values: declared, source: "catalog", truncated: false, dataSource: "catalog" };
2393
+ }
2394
+ const family = pickFamily(dim2, names);
2395
+ if (family) {
2396
+ const rows2 = await ctx.RollupModel.aggregate([
2397
+ {
2398
+ $match: {
2399
+ ...isPlatformScope(scope) ? {} : { tenantId: scope },
2400
+ as: family.as
2401
+ }
2402
+ },
2403
+ { $project: { v: { $arrayElemAt: ["$dims", family.index] }, count: 1 } },
2404
+ { $match: { v: { $type: "string" } } },
2405
+ { $group: { _id: "$v", count: { $sum: "$count" } } },
2406
+ { $sort: { count: -1, _id: 1 } },
2407
+ { $limit: cap + 1 }
2408
+ ]);
2409
+ const truncated2 = rows2.length > cap;
2410
+ if (truncated2) rows2.pop();
2411
+ const prefix = `${family.label}=`;
2412
+ return {
2413
+ values: rows2.map(
2414
+ (r) => String(r._id).startsWith(prefix) ? String(r._id).slice(prefix.length) : String(r._id)
2415
+ ),
2416
+ counts: rows2.map((r) => r.count),
2417
+ source: "rollups",
2418
+ via: family.as,
2419
+ truncated: truncated2,
2420
+ dataSource: "rollups"
2421
+ };
2422
+ }
2423
+ if (!rawReadable(dim2, names)) return empty("none");
2424
+ if (!range) return empty("none");
2425
+ const rows = await ctx.TelemetryModel.aggregate([
2426
+ { $match: buildMatch(scope, range, names?.length ? { name: names } : {}) },
2427
+ { $group: { _id: dimExpression(dim2), count: SAMPLED_COUNT } },
2428
+ // a "no value" is not a value to pick — the null group is real
2429
+ // (breakdown reports it) but it is not something a filter can name
2430
+ { $match: { _id: { $ne: null } } },
2431
+ { $sort: { count: -1, _id: 1 } },
2432
+ { $limit: cap + 1 }
2433
+ ]);
2434
+ const truncated = rows.length > cap;
2435
+ if (truncated) rows.pop();
2436
+ return {
2437
+ values: rows.map((r) => String(r._id)),
2438
+ counts: rows.map((r) => r.count),
2439
+ source: "raw",
2440
+ truncated,
2441
+ dataSource: "raw"
2442
+ };
2443
+ })
2444
+ );
2445
+ };
2446
+ }
2447
+
2448
+ // src/server/report.ts
2449
+ var RANGE_MS = {
2450
+ "1h": 36e5,
2451
+ "24h": 864e5,
2452
+ "7d": 7 * 864e5,
2453
+ "30d": 30 * 864e5,
2454
+ "90d": 90 * 864e5
2455
+ };
2456
+ var badRequest2 = (message) => Object.assign(new Error(`telemetry: ${message}`), { status: 400 });
2457
+ function rangeOf(range, now = /* @__PURE__ */ new Date()) {
2458
+ if (typeof range === "string") {
2459
+ const ms = RANGE_MS[range] ?? spanOf(range);
2460
+ if (ms == null) {
2461
+ throw badRequest2(
2462
+ `range "${range}" is not a known shorthand \u2014 use one of ${Object.keys(RANGE_MS).join(", ")}, an \`<n>h\`/\`<n>d\` form, or an explicit { from, to } ISO pair`
2463
+ );
2464
+ }
2465
+ return { from: new Date(now.getTime() - ms), to: now };
2466
+ }
2467
+ const from = new Date(range.from);
2468
+ const to = new Date(range.to);
2469
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
2470
+ throw badRequest2(
2471
+ `range { from: "${range.from}", to: "${range.to}" } is not valid \u2014 \`from\` must be a valid ISO time strictly before \`to\` (the package is half-open everywhere)`
2472
+ );
2473
+ }
2474
+ return { from, to };
2475
+ }
2476
+ function spanOf(range) {
2477
+ const m = /^(\d+)([hd])$/.exec(range);
2478
+ if (!m) return null;
2479
+ return Number(m[1]) * (m[2] === "h" ? 36e5 : 864e5);
2480
+ }
2481
+ function intervalForRange(range, now = /* @__PURE__ */ new Date()) {
2482
+ if (typeof range === "string" && RANGE_MS[range] != null) {
2483
+ return range === "1h" || range === "24h" ? "hour" : range === "90d" ? "week" : "day";
2484
+ }
2485
+ const { from, to } = rangeOf(range, now);
2486
+ const ms = to.getTime() - from.getTime();
2487
+ if (ms <= 864e5) return "hour";
2488
+ if (ms < 90 * 864e5) return "day";
2489
+ return "week";
2490
+ }
2491
+ var INTERVAL_RANK = { hour: 0, day: 1, week: 2, month: 3 };
2492
+ var shift = (range) => ({
2493
+ from: new Date(range.from.getTime() - (range.to.getTime() - range.from.getTime())),
2494
+ to: range.from
2495
+ });
2496
+ function expandSource(source, catalog) {
2497
+ if ("event" in source) {
2498
+ if (!catalog.events[source.event]) {
2499
+ return unavailable(
2500
+ `no event named "${source.event}" is registered \u2014 the catalog knows ${count(Object.keys(catalog.events).length, "event")}, so either the name is a typo or the registry never declared it`
2501
+ );
2502
+ }
2503
+ return { form: "event", events: [source.event] };
2504
+ }
2505
+ if ("namespace" in source) {
2506
+ const events = catalog.namespaces[source.namespace];
2507
+ if (!events?.length) {
2508
+ return unavailable(
2509
+ `no event name starts with "${source.namespace}." \u2014 the registered namespaces are ${Object.keys(catalog.namespaces).join(", ")}`
2510
+ );
2511
+ }
2512
+ return { form: "namespace", events: [...events] };
2513
+ }
2514
+ if ("kind" in source) {
2515
+ const events = Object.keys(catalog.events).filter((n) => catalog.events[n].kind === source.kind);
2516
+ if (!events.length) {
2517
+ return unavailable(
2518
+ `no event is registered with kind "${source.kind}" \u2014 declare one, or pick a kind the registry uses`
2519
+ );
2520
+ }
2521
+ return { form: "kind", events, kind: source.kind };
2522
+ }
2523
+ const family = catalog.families[source.family];
2524
+ if (!family) {
2525
+ return unavailable(
2526
+ `no rollup family "${source.family}" is declared \u2014 add a \`rollups: [{ as: '${source.family}', by: [...] }]\` block to the event that should feed it`
2527
+ );
2528
+ }
2529
+ return { form: "family", events: [...family.feeders], family };
2530
+ }
2531
+ var FILTER_ONLY = {
2532
+ "field:subject": "subject",
2533
+ "field:traceId": "traceId"
2534
+ };
2535
+ function dimsFor(catalog, events) {
2536
+ const out = /* @__PURE__ */ new Map();
2537
+ for (const d of catalog.envelope) out.set(d.key, d);
2538
+ for (const name of events) {
2539
+ for (const d of catalog.events[name]?.dims ?? []) {
2540
+ const seen = out.get(d.key);
2541
+ out.set(d.key, seen ? { ...seen, indexed: seen.indexed && d.indexed } : d);
2542
+ }
2543
+ }
2544
+ return out;
2545
+ }
2546
+ var measureDeclared = (catalog, events, key) => key === "count" || events.some((n) => catalog.events[n]?.measures.some((m) => m.key === key));
2547
+ var MEASURE_OP = /^(sum|avg|p50|p90|p95|p99):(.+)$/;
2548
+ var unavailable = (why) => ({ unavailable: true, why });
2549
+ var count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
2550
+ function resolveReport(report, catalog, opts = {}) {
2551
+ const now = opts.now ?? /* @__PURE__ */ new Date();
2552
+ const limits = opts.limits ?? {};
2553
+ const src = expandSource(report.source, catalog);
2554
+ if ("unavailable" in src) return src;
2555
+ const measure = report.measure ?? "count";
2556
+ const groupBy = report.groupBy ?? [];
2557
+ if (groupBy.length > 2) {
2558
+ return unavailable(
2559
+ `groupBy takes at most 2 dimensions, got ${groupBy.length} (${groupBy.join(", ")}) \u2014 three dims is a pivot table nobody can read and a group count that multiplies`
2560
+ );
2561
+ }
2562
+ if (report.interval && INTERVAL_RANK[report.interval] == null) {
2563
+ return unavailable(`interval "${report.interval}" is not one of hour, day, week, month`);
2564
+ }
2565
+ if (measure === "funnel") return planFunnel(report, catalog, now, limits);
2566
+ if (measure.startsWith("distinct:")) return planDistinct(report, catalog, src, measure, now);
2567
+ const opMatch = MEASURE_OP.exec(measure);
2568
+ if (measure !== "count" && !opMatch) {
2569
+ return unavailable(
2570
+ `measure "${measure}" is not a measure \u2014 use 'count', 'sum:<metric>', 'avg:<metric>', 'p50|p95|p99:<metric>', 'distinct:<subjectType>' or 'funnel'`
2571
+ );
2572
+ }
2573
+ if (opMatch && !measureDeclared(catalog, src.events, measure)) {
2574
+ return unavailable(
2575
+ `"${measure}" names a metric no source event declares \u2014 add \`${opMatch[2]}\` to the \`metrics\` object of ${src.events.join(", ")}, or pick one of ${metricList(catalog, src.events)}`
2576
+ );
2577
+ }
2578
+ const exact = planRollups(report, catalog, src, measure, groupBy, now, limits);
2579
+ if (exact) return exact;
2580
+ const filter = toRecordFilter(report, catalog, src);
2581
+ if ("unavailable" in filter) return filter;
2582
+ const range = rangeOf(report.range, now);
2583
+ const dims = dimsFor(catalog, src.events);
2584
+ const touched = [...groupBy, ...(report.filters ?? []).map((f) => f.dim)];
2585
+ const unindexed = touched.find((k) => !(dims.get(k)?.indexed ?? FILTER_ONLY[k] != null));
2586
+ const scanWhy = unindexed ? ` \u2014 "${unindexed}" has no index behind it, so this is a collection scan bounded only by the range` + (unindexed.startsWith("attr:") ? `; add "${unindexed.slice(5)}" to \`indexedAttrs\` to make it a lookup` : "") : "";
2587
+ const exactness = unindexed != null ? "scan" : "raw";
2588
+ if (opMatch && opMatch[1] !== "sum" && opMatch[1] !== "avg") {
2589
+ if (groupBy.length) {
2590
+ return unavailable(
2591
+ `percentiles per group are not offered yet (reports \xA713) \u2014 "${measure}" with groupBy ${groupBy.join(", ")} would be one distribution() read per group. Drop the groupBy, or filter to one group and ask again`
2592
+ );
2593
+ }
2594
+ return withCompare(report, {
2595
+ primitive: "distribution",
2596
+ args: [range, filter, { measure: opMatch[2] }],
2597
+ exactness,
2598
+ why: `${measure} is approximate by construction ($percentile t-digest over the matched records)${scanWhy}`
2599
+ });
2600
+ }
2601
+ if (groupBy.length) {
2602
+ const bad = groupBy.find((k) => !dims.has(k));
2603
+ if (bad) {
2604
+ return unavailable(
2605
+ `"${bad}" is not a dimension of ${describe(src)} \u2014 group by one of ${[...dims.keys()].join(", ")}`
2606
+ );
2607
+ }
2608
+ return withCompare(report, {
2609
+ primitive: "breakdown",
2610
+ args: [
2611
+ range,
2612
+ filter,
2613
+ {
2614
+ groupBy,
2615
+ measure,
2616
+ ...report.interval ? { interval: report.interval } : {},
2617
+ ...report.limit ? { limit: capped(report.limit, limits.breakdown) } : {}
2618
+ }
2619
+ ],
2620
+ exactness,
2621
+ why: `raw ${measure} by ${groupBy.join(" \xD7 ")} over the range${scanWhy}`
2622
+ });
2623
+ }
2624
+ if (!report.measure && !report.interval) {
2625
+ return withCompare(report, {
2626
+ primitive: "records",
2627
+ args: [range, filter, report.limit ? { limit: capped(report.limit, limits.records) } : {}],
2628
+ exactness,
2629
+ why: `the matching records themselves, newest first${scanWhy}`
2630
+ });
2631
+ }
2632
+ const interval = report.interval ?? intervalForRange(report.range, now);
2633
+ return withCompare(report, {
2634
+ primitive: "series",
2635
+ args: [range, filter, { measure, interval }],
2636
+ exactness,
2637
+ why: `raw ${measure} per ${interval} over the range${scanWhy}`
2638
+ });
2639
+ }
2640
+ var capped = (limit, cap) => cap == null ? limit : Math.max(1, Math.min(limit, cap));
2641
+ var describe = (src) => src.form === "family" ? `family "${src.family.as}"` : src.events.join(", ");
2642
+ var metricList = (catalog, events) => {
2643
+ const keys = /* @__PURE__ */ new Set();
2644
+ for (const n of events) for (const m of catalog.events[n]?.measures ?? []) keys.add(m.key);
2645
+ return keys.size ? [...keys].join(", ") : "nothing but count";
2646
+ };
2647
+ function planFunnel(report, catalog, now, limits) {
2648
+ const stages = report.stages ?? [];
2649
+ if (!stages.length) {
2650
+ return unavailable(
2651
+ "`measure: 'funnel'` needs `stages` \u2014 one or more lifetime `by: ['subject']` rollup family names, in the order a subject reaches them"
2652
+ );
2653
+ }
2654
+ const anchor = report.anchor ?? stages[0];
2655
+ const exits = report.exits ?? [];
2656
+ for (const as of [...stages, anchor, ...exits]) {
2657
+ const refusal = milestoneRefusal(catalog, as);
2658
+ if (refusal) return unavailable(refusal);
2659
+ }
2660
+ const first = catalog.families[stages[0]];
2661
+ for (const as of [...stages.slice(1), anchor]) {
2662
+ const f = catalog.families[as];
2663
+ if (!sameSet(f.subjectTypes, first.subjectTypes)) {
2664
+ return unavailable(
2665
+ `funnel stages must share one subject type: "${stages[0]}" is declared \`subjects: [${first.subjectTypes.join(", ")}]\` and "${as}" is \`subjects: [${f.subjectTypes.join(", ")}]\` \u2014 two populations cannot convert into each other`
2666
+ );
2667
+ }
2668
+ }
2669
+ if (report.subjectType && !first.subjectTypes.includes(report.subjectType)) {
2670
+ return unavailable(
2671
+ `subjectType "${report.subjectType}" is not one of the stages' subjects (${first.subjectTypes.join(", ")}) \u2014 the cohort would be empty`
2672
+ );
2673
+ }
2674
+ if (report.interval === "hour") {
2675
+ return unavailable(
2676
+ "funnel slices are day, week or month \u2014 an hourly cohort slice is not offered, because a cohort is assembled from lifetime milestones with no hourly grain to slice on"
2677
+ );
2678
+ }
2679
+ const params = {
2680
+ stages: stages.map((as) => ({ as })),
2681
+ anchor,
2682
+ ...exits.length ? { exits: exits.map((as) => ({ as })) } : {},
2683
+ cohort: rangeOf(report.range, now),
2684
+ ...report.subjectType ? { subjectType: report.subjectType } : {},
2685
+ ...report.interval ? { interval: report.interval } : {},
2686
+ ...report.limit ? { limit: capped(report.limit, limits.funnel) } : {}
2687
+ };
2688
+ return withCompare(report, {
2689
+ primitive: "funnel",
2690
+ args: [params],
2691
+ exactness: "exact",
2692
+ via: anchor,
2693
+ why: `cohort funnel over ${count(stages.length, "lifetime milestone family")}, anchored on "${anchor}" \u2014 rollups only, no raw scan`
2694
+ });
2695
+ }
2696
+ function milestoneRefusal(catalog, as) {
2697
+ const f = catalog.families[as];
2698
+ if (!f) {
2699
+ return `no rollup family "${as}" is declared. Add a \`rollups: [{ as: '${as}', by: ['subject'], subjects: [...] }]\` block to the event that marks it`;
2700
+ }
2701
+ const shape = `by: [${f.by.map((d) => `'${d}'`).join(", ")}]${f.bucket ? `, bucket: '${f.bucket}'` : ""}`;
2702
+ if (!f.lifetime) {
2703
+ return `rollup family "${as}" (declared on "${f.feeders[0]}") 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`;
2704
+ }
2705
+ if (f.by.length !== 1 || f.by[0] !== "subject") {
2706
+ return `rollup family "${as}" (declared on "${f.feeders[0]}") 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`;
2707
+ }
2708
+ return null;
2709
+ }
2710
+ function planDistinct(report, catalog, src, measure, now) {
2711
+ const subjectType = measure.slice("distinct:".length);
2712
+ if (!subjectType) {
2713
+ return unavailable(
2714
+ `"${measure}" needs a subject type \u2014 'distinct:account', one of ${catalog.subjectTypes.join(", ")}`
2715
+ );
2716
+ }
2717
+ if (!catalog.subjectTypes.includes(subjectType)) {
2718
+ return unavailable(
2719
+ `no event or rollup declares the subject type "${subjectType}" \u2014 the registry knows ${catalog.subjectTypes.join(", ") || "no subject types at all"}`
2720
+ );
2721
+ }
2722
+ if (report.groupBy?.length) {
2723
+ return unavailable(
2724
+ `distinct counts take no groupBy: distinctCount() answers one series per family, and "${report.groupBy.join(", ")}" would need a family keyed by those dims AND \`by: ['subject']\`, which cannot count subjects exactly`
2725
+ );
2726
+ }
2727
+ const wanted = new Set(src.events);
2728
+ const fits = Object.values(catalog.families).filter(
2729
+ (f) => f.bucket != null && f.by.length === 1 && f.by[0] === "subject" && f.subjectTypes.includes(subjectType) && src.events.every((e) => f.feeders.includes(e))
2730
+ );
2731
+ const family = fits.find((f) => sameSet(f.feeders, [...wanted])) ?? fits[0];
2732
+ if (!family) {
2733
+ return unavailable(
2734
+ `no bucketed \`by: ['subject']\` family covers ${src.events.join(", ")} for subject type "${subjectType}" \u2014 declare \`rollups: [{ as: 'activity', by: ['subject'], subjects: ['${subjectType}'], bucket: 'day' }]\` on the events that count as activity`
2735
+ );
2736
+ }
2737
+ const superset = !sameSet(family.feeders, [...wanted]);
2738
+ return withCompare(report, {
2739
+ primitive: "distinctCount",
2740
+ args: [
2741
+ {
2742
+ as: family.as,
2743
+ subjectType,
2744
+ range: rangeOf(report.range, now),
2745
+ ...report.interval ? { interval: report.interval } : {}
2746
+ }
2747
+ ],
2748
+ exactness: "exact",
2749
+ via: family.as,
2750
+ why: `"${family.as}" writes exactly one doc per (subject, ${family.bucket}), so distinct subjects IS the doc count \u2014 exact, no sketch` + (superset ? `. Its feeders (${family.feeders.join(", ")}) are a SUPERSET of the source (${src.events.join(", ")}), so the count includes subjects who only did the others` : "")
2751
+ });
2752
+ }
2753
+ var famDims = (f) => f.by.map((b) => b === "subject" ? "subjectType" : b);
2754
+ function planRollups(report, catalog, src, measure, groupBy, now, limits) {
2755
+ if (report.excludeActorTypes?.length) return null;
2756
+ const candidates = src.family ? [src.family] : Object.values(catalog.families).filter((f) => sameSet(f.feeders, src.events));
2757
+ const op = MEASURE_OP.exec(measure);
2758
+ const filters = report.filters ?? [];
2759
+ const fits = candidates.filter((f) => {
2760
+ const dims2 = famDims(f);
2761
+ if (!groupBy.every((k) => dims2.includes(k))) return false;
2762
+ if (report.interval && (!f.bucket || INTERVAL_RANK[f.bucket] > INTERVAL_RANK[report.interval])) return false;
2763
+ if (op) {
2764
+ if (op[1] !== "sum" && op[1] !== "avg" || !f.sums.includes(op[2])) return false;
2765
+ }
2766
+ return filters.every(
2767
+ (t) => dims2.includes(t.dim) && (t.op === "eq" || t.op === "in") || nameFilterCovers(t, f)
2768
+ );
2769
+ });
2770
+ const family = fits.sort((a, b) => a.by.length - b.by.length)[0];
2771
+ if (!family) return null;
2772
+ const dims = famDims(family);
2773
+ const range = rangeOf(report.range, now);
2774
+ const on = family.lifetime ? "firstAt" : "bucketAt";
2775
+ const fold = filters.filter((t) => dims.includes(t.dim));
2776
+ return withCompare(report, {
2777
+ primitive: "rollups",
2778
+ args: [
2779
+ {
2780
+ as: family.as,
2781
+ on,
2782
+ range,
2783
+ sort: family.lifetime ? "count" : "bucketAt",
2784
+ ...report.limit ? { limit: capped(report.limit, limits.rollups) } : {}
2785
+ }
2786
+ ],
2787
+ exactness: "exact",
2788
+ via: family.as,
2789
+ shape: {
2790
+ groupBy,
2791
+ labels: groupBy.map((k) => family.labels[dims.indexOf(k)]),
2792
+ measure,
2793
+ ...report.interval ? { interval: report.interval } : {},
2794
+ ...fold.length ? { filters: fold.map((t) => ({ ...t, label: family.labels[dims.indexOf(t.dim)] })) } : {}
2795
+ },
2796
+ why: `family "${family.as}" is keyed by ${dims.join(", ") || "nothing but its feeders"} and maintained on write, so this is one indexed rollup read over \`${on}\` instead of a raw scan` + (op?.[1] === "avg" ? `. avg:${op[2]} is exact off a rollup: the executor divides sums.${op[2]} by count` : "") + (report.interval && family.bucket !== report.interval ? `. Its ${family.bucket} buckets roll up into ${report.interval} without splitting one` : "")
2797
+ });
2798
+ }
2799
+ function nameFilterCovers(t, f) {
2800
+ if (t.dim !== "field:name") return false;
2801
+ const admitted = t.op === "eq" ? [String(t.value)] : t.op === "in" ? [...t.value].map(String) : null;
2802
+ return admitted != null && f.feeders.every((n) => admitted.includes(n));
2803
+ }
2804
+ var FIELD_TERMS = {
2805
+ "field:kind": "kind",
2806
+ "field:name": "name",
2807
+ "field:severity": "severity",
2808
+ "field:env": "env",
2809
+ "field:service": "service",
2810
+ "field:release": "release",
2811
+ ...FILTER_ONLY
2812
+ };
2813
+ function toRecordFilter(report, catalog, src) {
2814
+ const filter = {};
2815
+ if (src.events.length === 1) {
2816
+ filter.name = src.events[0];
2817
+ } else if (src.kind) {
2818
+ filter.kind = src.kind;
2819
+ } else {
2820
+ filter.name = [...src.events];
2821
+ }
2822
+ for (const t of report.filters ?? []) {
2823
+ const term = FIELD_TERMS[t.dim];
2824
+ if (term) {
2825
+ if (t.op !== "eq") {
2826
+ return unavailable(
2827
+ `"${t.dim}" supports equality only on the raw path \u2014 RecordFilter.${String(term)} is one string, and "${t.op}" would need a term query.ts does not build`
2828
+ );
2829
+ }
2830
+ filter[term] = String(t.value);
2831
+ continue;
2832
+ }
2833
+ if (t.dim.startsWith("attr:")) {
2834
+ const key = t.dim.slice(5);
2835
+ if (t.op === "eq") {
2836
+ (filter.attrs ??= {})[key] = String(t.value);
2837
+ continue;
2838
+ }
2839
+ if (t.op === "gte" || t.op === "lte") {
2840
+ if (!measureDeclared(catalog, src.events, `sum:${key}`)) {
2841
+ return unavailable(
2842
+ `"${t.dim} ${t.op}" is a numeric bound, which only a declared metric can carry \u2014 add \`${key}\` to the \`metrics\` object of ${src.events.join(", ")}, or filter it as an equality`
2843
+ );
2844
+ }
2845
+ const range = (filter.metrics ??= {})[key] ??= {};
2846
+ range[t.op] = Number(t.value);
2847
+ continue;
2848
+ }
2849
+ return unavailable(
2850
+ `"${t.dim}" supports equality (or a gte/lte bound on a metric) \u2014 an \`in\` over attrs would need a \`$in\` term buildMatch does not write`
2851
+ );
2852
+ }
2853
+ if (t.dim === "subjectType" || t.dim === "actorType") {
2854
+ return unavailable(
2855
+ `"${t.dim}" is derived at query time from ${t.dim === "subjectType" ? "`subjectKeys`" : "`actor`"} and RecordFilter has no term for it \u2014 group by it instead, pin one subject with \`field:subject\`` + (t.dim === "actorType" ? ", or use `excludeActorTypes`" : "")
2856
+ );
2857
+ }
2858
+ return unavailable(
2859
+ `"${t.dim}" is not filterable on the raw path \u2014 RecordFilter carries ${Object.keys(FIELD_TERMS).join(", ")}, \`attr:<key>\` and metric bounds`
2860
+ );
2861
+ }
2862
+ if (report.excludeActorTypes?.length) filter.excludeActorTypes = [...report.excludeActorTypes];
2863
+ return filter;
2864
+ }
2865
+ function withCompare(report, plan) {
2866
+ if (report.compare !== "previous") return plan;
2867
+ const [first, ...rest] = plan.args;
2868
+ if (plan.primitive === "rollups" || plan.primitive === "distinctCount" || plan.primitive === "funnel") {
2869
+ const params = first;
2870
+ const key = plan.primitive === "funnel" ? "cohort" : "range";
2871
+ const window = params[key];
2872
+ if (!window) return plan;
2873
+ return { ...plan, previous: { args: [{ ...params, [key]: { ...window, ...shift(window) } }, ...rest] } };
2874
+ }
2875
+ return { ...plan, previous: { args: [shift(first), ...rest] } };
2876
+ }
2877
+ var sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
2878
+ var FILTER_OPS = /* @__PURE__ */ new Set(["eq", "in", "gte", "lte"]);
2879
+ var SORTS = /* @__PURE__ */ new Set(["value", "label", "time"]);
2880
+ function parseReportQuery(q) {
2881
+ const str = (k) => {
2882
+ const v = Array.isArray(q[k]) ? q[k][0] : q[k];
2883
+ return typeof v === "string" && v ? v : void 0;
2884
+ };
2885
+ const list = (k) => (str(k) ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2886
+ const raw = str("source");
2887
+ if (!raw) {
2888
+ throw badRequest2(
2889
+ "`source` is required \u2014 one of source=event:<name>, namespace:<ns>, kind:<kind>, family:<as>"
2890
+ );
2891
+ }
2892
+ const cut = raw.indexOf(":");
2893
+ const form = cut > 0 ? raw.slice(0, cut) : "";
2894
+ const named = cut > 0 ? raw.slice(cut + 1) : "";
2895
+ if (!named || !["event", "namespace", "kind", "family"].includes(form)) {
2896
+ throw badRequest2(
2897
+ `\`source\` must be "event:<name>", "namespace:<ns>", "kind:<kind>" or "family:<as>" \u2014 got "${raw}"`
2898
+ );
2899
+ }
2900
+ const source = form === "event" ? { event: named } : form === "namespace" ? { namespace: named } : form === "kind" ? { kind: named } : { family: named };
2901
+ const shorthand = str("range");
2902
+ const from = str("from");
2903
+ const to = str("to");
2904
+ if (!shorthand && !(from && to)) {
2905
+ throw badRequest2("a range is required \u2014 either `range=7d` or both `from` and `to` as ISO times");
2906
+ }
2907
+ const range = shorthand ?? { from, to };
2908
+ const interval = str("interval");
2909
+ if (interval && INTERVAL_RANK[interval] == null) {
2910
+ throw badRequest2(`\`interval\` must be one of hour, day, week, month \u2014 got "${interval}"`);
2911
+ }
2912
+ const sort = str("sort");
2913
+ if (sort && !SORTS.has(sort)) {
2914
+ throw badRequest2(`\`sort\` must be one of value, label, time \u2014 got "${sort}"`);
2915
+ }
2916
+ const compare = str("compare");
2917
+ if (compare && compare !== "previous") {
2918
+ throw badRequest2(`\`compare\` takes only "previous" \u2014 got "${compare}"`);
2919
+ }
2920
+ const limitRaw = str("limit");
2921
+ const limit = limitRaw == null ? void 0 : Number(limitRaw);
2922
+ if (limit != null && (!Number.isInteger(limit) || limit < 1)) {
2923
+ throw badRequest2(`\`limit\` must be a positive integer \u2014 got "${limitRaw}"`);
2924
+ }
2925
+ const measure = str("measure");
2926
+ const groupBy = list("groupBy");
2927
+ const excludeActorTypes = list("excludeActors");
2928
+ const stages = list("stages");
2929
+ const exits = list("exits");
2930
+ const anchor = str("anchor");
2931
+ const subjectType = str("subjectType");
2932
+ const filters = (q.filter == null ? [] : Array.isArray(q.filter) ? q.filter.map(String) : [String(q.filter)]).map(parseFilterTerm);
2933
+ return {
2934
+ source,
2935
+ range,
2936
+ ...interval ? { interval } : {},
2937
+ ...measure ? { measure } : {},
2938
+ ...groupBy.length ? { groupBy } : {},
2939
+ ...filters.length ? { filters } : {},
2940
+ ...excludeActorTypes.length ? { excludeActorTypes } : {},
2941
+ ...sort ? { sort } : {},
2942
+ ...limit != null ? { limit } : {},
2943
+ ...compare ? { compare: "previous" } : {},
2944
+ ...stages.length ? { stages } : {},
2945
+ ...anchor ? { anchor } : {},
2946
+ ...exits.length ? { exits } : {},
2947
+ ...subjectType ? { subjectType } : {}
2948
+ };
2949
+ }
2950
+ function parseFilterTerm(term) {
2951
+ const parts = term.split(":");
2952
+ const i = parts.findIndex((p) => FILTER_OPS.has(p));
2953
+ const rest = i < 0 ? "" : parts.slice(i + 1).join(":");
2954
+ if (i < 1 || !rest) {
2955
+ throw badRequest2(
2956
+ `\`filter\` must be "<dim>:<op>:<value>" with op one of eq, in, gte, lte \u2014 got "${term}"`
2957
+ );
2958
+ }
2959
+ const dim2 = parts.slice(0, i).join(":");
2960
+ const op = parts[i];
2961
+ if (op === "in") {
2962
+ const values = rest.split(",").map((s) => s.trim()).filter(Boolean);
2963
+ if (!values.length) throw badRequest2(`\`filter\` "${term}" has an empty \`in\` list`);
2964
+ return { dim: dim2, op, value: values };
2965
+ }
2966
+ if (op === "gte" || op === "lte") {
2967
+ const n = Number(rest);
2968
+ if (Number.isNaN(n)) throw badRequest2(`\`filter\` bound "${term}" is not a number`);
2969
+ return { dim: dim2, op, value: n };
2970
+ }
2971
+ return { dim: dim2, op, value: rest };
2972
+ }
2973
+ function reportToQuery(report) {
2974
+ const s = report.source;
2975
+ const q = {
2976
+ source: "event" in s ? `event:${s.event}` : "namespace" in s ? `namespace:${s.namespace}` : "kind" in s ? `kind:${s.kind}` : `family:${s.family}`
2977
+ };
2978
+ if (typeof report.range === "string") q.range = report.range;
2979
+ else {
2980
+ q.from = report.range.from;
2981
+ q.to = report.range.to;
2982
+ }
2983
+ if (report.interval) q.interval = report.interval;
2984
+ if (report.measure) q.measure = report.measure;
2985
+ if (report.groupBy?.length) q.groupBy = report.groupBy.join(",");
2986
+ if (report.filters?.length) {
2987
+ const terms = report.filters.map(
2988
+ (f) => `${f.dim}:${f.op}:${Array.isArray(f.value) ? f.value.join(",") : String(f.value)}`
2989
+ );
2990
+ q.filter = terms.length === 1 ? terms[0] : terms;
2991
+ }
2992
+ if (report.excludeActorTypes?.length) q.excludeActors = report.excludeActorTypes.join(",");
2993
+ if (report.sort) q.sort = report.sort;
2994
+ if (report.limit != null) q.limit = String(report.limit);
2995
+ if (report.compare) q.compare = report.compare;
2996
+ if (report.stages?.length) q.stages = report.stages.join(",");
2997
+ if (report.anchor) q.anchor = report.anchor;
2998
+ if (report.exits?.length) q.exits = report.exits.join(",");
2999
+ if (report.subjectType) q.subjectType = report.subjectType;
3000
+ return q;
3001
+ }
3002
+ var LEGACY_DIMS = {
3003
+ kind: "field:kind",
3004
+ name: "field:name",
3005
+ severity: "field:severity",
3006
+ env: "field:env",
3007
+ service: "field:service",
3008
+ release: "field:release",
3009
+ subject: "field:subject",
3010
+ traceId: "field:traceId"
3011
+ };
3012
+ function normalizeQuery(query) {
3013
+ if (!query || typeof query !== "object") return null;
3014
+ if ("source" in query && query.source) return query;
3015
+ const q = query;
3016
+ const filters = q.filters ?? {};
3017
+ const str = (v) => typeof v === "string" && v ? v : null;
3018
+ const name = str(filters.name);
3019
+ const family = str(filters.rollup);
3020
+ const kind = str(filters.kind);
3021
+ const source = name ? { event: name } : family ? { family } : kind ? { kind } : null;
3022
+ if (!source) return null;
3023
+ const consumed = name ? "name" : family ? "rollup" : "kind";
3024
+ const terms = [];
3025
+ for (const [k, v] of Object.entries(filters)) {
3026
+ if (k === consumed || k === "rollup") continue;
3027
+ if (k === "excludeActorTypes") continue;
3028
+ if (k === "attrs") {
3029
+ const entries = typeof v === "string" ? v.split(",").map((pair) => pair.split(":").map((s) => s.trim())) : Object.entries(v ?? {}).map(([a, b]) => [a, String(b)]);
3030
+ for (const [key, value2] of entries) {
3031
+ if (key && value2 != null) terms.push({ dim: `attr:${key}`, op: "eq", value: String(value2) });
3032
+ }
3033
+ continue;
3034
+ }
3035
+ const dim2 = LEGACY_DIMS[k];
3036
+ const value = str(v);
3037
+ if (dim2 && value) terms.push({ dim: dim2, op: "eq", value });
3038
+ }
3039
+ const groupBy = (q.groupBy ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3040
+ const sort = q.sort === "value" || q.sort === "label" || q.sort === "time" ? q.sort : void 0;
3041
+ const actors = filters.excludeActorTypes;
3042
+ return {
3043
+ source,
3044
+ range: q.range ?? "7d",
3045
+ ...terms.length ? { filters: terms } : {},
3046
+ ...groupBy.length ? { groupBy } : {},
3047
+ ...sort ? { sort } : {},
3048
+ ...Array.isArray(actors) && actors.length ? { excludeActorTypes: actors.map(String) } : {}
3049
+ };
3050
+ }
3051
+
3052
+ // src/server/execute.ts
3053
+ async function executeReport(q, scope, report, catalog, opts = {}) {
3054
+ const plan = resolveReport(report, catalog, { now: opts.now, limits: opts.limits });
3055
+ if ("unavailable" in plan) throw Object.assign(new Error(plan.why), { status: 400 });
3056
+ const run = async (args) => {
3057
+ const raw = await q[plan.primitive](scope, ...args);
3058
+ if (plan.primitive === "rollups" && plan.shape) {
3059
+ return foldRollups(raw?.rows ?? [], plan.shape, !!raw?.truncated);
3060
+ }
3061
+ if (plan.primitive === "records" && opts.redact) {
3062
+ return { ...raw, items: opts.redact(raw?.items ?? []) };
3063
+ }
3064
+ return raw;
3065
+ };
3066
+ const [result, previous] = await Promise.all([
3067
+ run(plan.args),
3068
+ plan.previous ? run(plan.previous.args) : Promise.resolve(void 0)
3069
+ ]);
3070
+ return {
3071
+ report,
3072
+ plan,
3073
+ result,
3074
+ ...plan.previous ? { previous } : {},
3075
+ dataSource: result?.dataSource ?? "raw"
3076
+ };
3077
+ }
3078
+ var MEASURE_OP2 = /^(sum|avg):(.+)$/;
3079
+ function foldRollups(rows, shape, truncated = false) {
3080
+ const op = MEASURE_OP2.exec(shape.measure);
3081
+ const groups = /* @__PURE__ */ new Map();
3082
+ for (const doc of rows) {
3083
+ const dims = doc?.dims ?? [];
3084
+ if (!(shape.filters ?? []).every((f) => admits(f, dimValue(dims, f.label)))) continue;
3085
+ const tuple = shape.labels.map((label3) => dimValue(dims, label3));
3086
+ const at = shape.interval && doc.bucketAt ? truncate(new Date(doc.bucketAt), shape.interval) : void 0;
3087
+ const key = `${JSON.stringify(tuple)}|${at ? at.getTime() : ""}`;
3088
+ let g = groups.get(key);
3089
+ if (!g) groups.set(key, g = { dims: tuple, ...at ? { at } : {}, sum: 0, count: 0 });
3090
+ g.count += typeof doc.count === "number" ? doc.count : 0;
3091
+ if (op) g.sum += sumOf(doc.sums, op[2]);
3092
+ }
3093
+ const rowsOut = [...groups.values()].map((g) => ({
3094
+ dims: g.dims,
3095
+ ...g.at ? { at: g.at } : {},
3096
+ // avg is sums[k]/count off the SAME doc, which is exact — not an average of
3097
+ // averages, which is what folding a per-bucket mean would have produced
3098
+ value: !op ? g.count : op[1] === "sum" ? g.sum : g.count ? g.sum / g.count : 0
3099
+ }));
3100
+ rowsOut.sort(
3101
+ shape.interval ? (a, b) => (a.at?.getTime() ?? 0) - (b.at?.getTime() ?? 0) || byDims(a, b) : (a, b) => b.value - a.value || byDims(a, b)
3102
+ );
3103
+ return {
3104
+ rows: rowsOut,
3105
+ groups: new Set([...groups.values()].map((g) => JSON.stringify(g.dims))).size,
3106
+ truncated,
3107
+ dataSource: "rollups"
3108
+ };
3109
+ }
3110
+ function dimValue(dims, label3) {
3111
+ const prefix = `${label3}=`;
3112
+ for (const d of dims) if (d.startsWith(prefix)) return d.slice(prefix.length);
3113
+ for (const d of dims) if (!d.includes("=")) return d;
3114
+ return null;
3115
+ }
3116
+ function admits(f, value) {
3117
+ if (f.op === "in") return f.value.map(String).includes(String(value));
3118
+ if (f.op === "gte" || f.op === "lte") {
3119
+ const n = Number(value);
3120
+ if (Number.isNaN(n)) return false;
3121
+ return f.op === "gte" ? n >= Number(f.value) : n <= Number(f.value);
3122
+ }
3123
+ return String(value) === String(f.value);
3124
+ }
3125
+ function sumOf(sums, key) {
3126
+ if (!sums) return 0;
3127
+ const v = sums instanceof Map ? sums.get(key) : sums[key];
3128
+ return typeof v === "number" ? v : 0;
3129
+ }
3130
+ function byDims(a, b) {
3131
+ for (let i = 0; i < a.dims.length; i++) {
3132
+ const x = a.dims[i] ?? "";
3133
+ const y = b.dims[i] ?? "";
3134
+ if (x !== y) return x < y ? -1 : 1;
3135
+ }
3136
+ return 0;
3137
+ }
1839
3138
  function buildViewModel(connection, modelName, collection) {
1840
3139
  const existing = connection.models?.[modelName];
1841
3140
  if (existing) return existing;
@@ -1862,31 +3161,58 @@ var KIND_PAGE = {
1862
3161
  state: "journeys",
1863
3162
  usage: "usage"
1864
3163
  };
1865
- function deriveViews(registry) {
3164
+ function deriveViews(registry, catalog = deriveCatalog(registry)) {
1866
3165
  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" }
3166
+ const derived = (name, page, query) => views.push({ origin: "derived", name, page, query });
3167
+ for (const [name, e] of Object.entries(catalog.events)) {
3168
+ derived(name, KIND_PAGE[e.kind] ?? "events", {
3169
+ source: { event: name },
3170
+ range: "7d",
3171
+ interval: intervalForRange("7d")
1874
3172
  });
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" }
3173
+ }
3174
+ for (const as of Object.keys(catalog.families)) {
3175
+ derived(`rollup: ${as}`, "journeys", { source: { family: as }, range: "30d" });
3176
+ }
3177
+ for (const [ns, names] of Object.entries(catalog.namespaces)) {
3178
+ if (names.length < 2) continue;
3179
+ derived(`namespace: ${ns}`, "explore", {
3180
+ source: { namespace: ns },
3181
+ range: "30d",
3182
+ interval: "day",
3183
+ groupBy: ["field:name"]
3184
+ });
3185
+ }
3186
+ for (const [name, e] of Object.entries(catalog.events)) {
3187
+ if (e.kind !== "usage") continue;
3188
+ const money = e.measures.find((m) => m.key.startsWith("sum:") && m.key.endsWith("_usd"));
3189
+ if (!money) continue;
3190
+ derived(`spend: ${name}`, "usage", {
3191
+ source: { event: name },
3192
+ range: "30d",
3193
+ interval: "day",
3194
+ measure: money.key
3195
+ });
3196
+ }
3197
+ for (const subjectType of catalog.subjectTypes) {
3198
+ const stages = Object.values(catalog.families).filter((f) => f.lifetime && f.by.length === 1 && f.by[0] === "subject" && f.subjectTypes.includes(subjectType)).map((f) => f.as);
3199
+ if (stages.length < 2) continue;
3200
+ derived(`funnel: ${subjectType}`, "journeys", {
3201
+ // any source expands; the family the funnel is anchored on is the honest one
3202
+ source: { family: stages[0] },
3203
+ range: "30d",
3204
+ interval: "week",
3205
+ measure: "funnel",
3206
+ stages,
3207
+ anchor: stages[0],
3208
+ subjectType
1883
3209
  });
1884
3210
  }
1885
3211
  return views;
1886
3212
  }
1887
3213
  async function resolveViews(opts) {
1888
3214
  const byName = /* @__PURE__ */ new Map();
1889
- for (const v of deriveViews(opts.registry)) byName.set(v.name, v);
3215
+ for (const v of deriveViews(opts.registry, opts.catalog)) byName.set(v.name, v);
1890
3216
  for (const v of opts.configured) byName.set(v.name, { ...v, origin: "configured" });
1891
3217
  const saved = await opts.ViewModel.find({
1892
3218
  tenantId: opts.tenantId,
@@ -1945,6 +3271,10 @@ var parseFilter = (q) => {
1945
3271
  for (const k of ["kind", "name", "severity", "env", "service", "release", "subject", "traceId"]) {
1946
3272
  if (typeof q[k] === "string" && q[k]) f[k] = q[k];
1947
3273
  }
3274
+ if (typeof q.name === "string" && q.name.includes(",")) {
3275
+ const names = q.name.split(",").map((s) => s.trim()).filter(Boolean);
3276
+ if (names.length) f.name = names.length === 1 ? names[0] : names;
3277
+ }
1948
3278
  if (typeof q.attrs === "string" && q.attrs) {
1949
3279
  f.attrs = Object.fromEntries(
1950
3280
  String(q.attrs).split(",").map((p) => p.split(":")).filter((p) => p.length >= 2).map(([k, ...v]) => [k, v.join(":")])
@@ -1976,30 +3306,6 @@ var parseDims = (v) => {
1976
3306
  }
1977
3307
  return v.length ? v : void 0;
1978
3308
  };
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
3309
  function createDashboard(opts) {
2004
3310
  const { telemetry: t, viewerAdapter, subjectAdapter, views: configured = [] } = opts;
2005
3311
  if (!viewerAdapter?.resolveViewer) {
@@ -2029,6 +3335,20 @@ function createDashboard(opts) {
2029
3335
  cacheTtlMs: opts.cacheTtlMs,
2030
3336
  cacheSize: opts.cacheSize
2031
3337
  });
3338
+ const catalog = deriveCatalog(t.registry, {
3339
+ platforms: t.models.telemetry.schema.path("client")?.schema?.path("platform")?.enumValues
3340
+ });
3341
+ const registry = projectRegistry(catalog);
3342
+ const values = createValues({
3343
+ catalog,
3344
+ TelemetryModel: t.models.telemetry,
3345
+ RollupModel: t.models.rollups,
3346
+ limits: opts.queryLimits,
3347
+ onSlowQuery: opts.onSlowQuery,
3348
+ slowMs: opts.slowMs,
3349
+ cacheTtlMs: opts.cacheTtlMs,
3350
+ cacheSize: opts.cacheSize
3351
+ });
2032
3352
  const api = express2__default.default.Router();
2033
3353
  api.use(express2__default.default.json({ limit: "64kb" }));
2034
3354
  api.use(async (req, res, next) => {
@@ -2047,7 +3367,8 @@ function createDashboard(opts) {
2047
3367
  }, next);
2048
3368
  };
2049
3369
  api.get("/registry", h(async (req) => ({
2050
- registry: registryProjection(t),
3370
+ registry,
3371
+ catalog,
2051
3372
  kinds: ["event", "error", "span", "state", "usage"],
2052
3373
  role: req.viewer.role,
2053
3374
  scope: req.viewer.tenantId,
@@ -2070,6 +3391,14 @@ function createDashboard(opts) {
2070
3391
  measure: typeof req.query.measure === "string" ? req.query.measure : void 0
2071
3392
  })
2072
3393
  ));
3394
+ api.get("/breakdown", h(
3395
+ async (req) => q.breakdown(req.viewer.tenantId, parseRange(req.query), parseFilter(req.query), {
3396
+ groupBy: String(req.query.groupBy ?? "").split(",").map((s) => s.trim()).filter(Boolean),
3397
+ measure: typeof req.query.measure === "string" ? req.query.measure : void 0,
3398
+ interval: req.query.interval || void 0,
3399
+ limit: req.query.limit ? Number(req.query.limit) : void 0
3400
+ })
3401
+ ));
2073
3402
  api.get("/rollups", h(async (req) => {
2074
3403
  if (typeof req.query.as !== "string" || !req.query.as) {
2075
3404
  throw Object.assign(new Error("rollup family required"), { status: 400 });
@@ -2100,7 +3429,7 @@ function createDashboard(opts) {
2100
3429
  limit: req.query.limit ? Number(req.query.limit) : void 0
2101
3430
  })
2102
3431
  ));
2103
- const badRequest = async (run) => {
3432
+ const badRequest3 = async (run) => {
2104
3433
  try {
2105
3434
  return await run();
2106
3435
  } catch (e) {
@@ -2113,7 +3442,7 @@ function createDashboard(opts) {
2113
3442
  if (!stages.length) {
2114
3443
  throw Object.assign(new Error("funnel needs `stages` \u2014 a comma-separated list of rollup families"), { status: 400 });
2115
3444
  }
2116
- return badRequest(() => q.funnel(req.viewer.tenantId, {
3445
+ return badRequest3(() => q.funnel(req.viewer.tenantId, {
2117
3446
  stages,
2118
3447
  exits: parseStages(req.query.exits),
2119
3448
  anchor: typeof req.query.anchor === "string" ? req.query.anchor : void 0,
@@ -2128,22 +3457,46 @@ function createDashboard(opts) {
2128
3457
  if (typeof req.query.as !== "string" || !req.query.as) {
2129
3458
  throw Object.assign(new Error("rollup family required"), { status: 400 });
2130
3459
  }
2131
- return badRequest(() => q.distinctCount(req.viewer.tenantId, {
3460
+ return badRequest3(() => q.distinctCount(req.viewer.tenantId, {
2132
3461
  as: req.query.as,
2133
3462
  subjectType: typeof req.query.subjectType === "string" ? req.query.subjectType : void 0,
2134
3463
  range: parseRange(req.query),
2135
3464
  interval: req.query.interval || void 0
2136
3465
  }));
2137
3466
  }));
3467
+ api.get("/values", h(async (req) => {
3468
+ const dim2 = typeof req.query.dim === "string" ? req.query.dim.trim() : "";
3469
+ if (!dim2) {
3470
+ throw Object.assign(new Error("dim required"), { status: 400 });
3471
+ }
3472
+ const names = String(req.query.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3473
+ return values(req.viewer.tenantId, {
3474
+ dim: dim2,
3475
+ names: names.length ? names : void 0,
3476
+ range: req.query.from || req.query.to ? parseRange(req.query) : void 0,
3477
+ limit: req.query.limit ? Number(req.query.limit) : void 0
3478
+ });
3479
+ }));
2138
3480
  api.get("/subjects/describe", h(async (req) => {
2139
3481
  const refs = String(req.query.refs ?? "").split(",").filter(Boolean).slice(0, 100);
2140
3482
  if (!subjectAdapter) return { refs: {} };
2141
3483
  return { refs: await subjectAdapter.describe(refs) };
2142
3484
  }));
3485
+ api.get("/report", h(
3486
+ async (req) => badRequest3(
3487
+ () => executeReport(q, req.viewer.tenantId, parseReportQuery(req.query), catalog)
3488
+ )
3489
+ ));
3490
+ api.get("/report/plan", h(
3491
+ async (req) => badRequest3(
3492
+ async () => resolveReport(parseReportQuery(req.query), catalog)
3493
+ )
3494
+ ));
2143
3495
  api.get("/views", h(async (req) => ({
2144
3496
  views: await resolveViews({
2145
3497
  ViewModel,
2146
3498
  registry: t.registry,
3499
+ catalog,
2147
3500
  configured,
2148
3501
  tenantId: req.viewer.tenantId,
2149
3502
  viewerRef: req.viewer.viewerRef
@@ -2186,7 +3539,13 @@ function createDashboard(opts) {
2186
3539
  indexCount: indexes.length,
2187
3540
  indexBudget: INDEX_BUDGET,
2188
3541
  keys,
2189
- role: req.viewer.role
3542
+ role: req.viewer.role,
3543
+ // The same three sources, read the other way round: what the data says
3544
+ // the registry is missing, each with the line that would fix it. Derived
3545
+ // from the counters and the quarantine ALREADY fetched above, so the
3546
+ // page costs no extra read. Nothing is written — the host still edits
3547
+ // the registry by hand (reports §9).
3548
+ suggestions: deriveSuggestions({ counters: t.counters, catalog, quarantine })
2190
3549
  };
2191
3550
  }));
2192
3551
  api.post("/system/keys/:id/revoke", h(async (req, res) => {
@@ -2355,11 +3714,14 @@ function createTelemetry(config) {
2355
3714
  }
2356
3715
 
2357
3716
  exports.BODY_MAX_CHARS = BODY_MAX_CHARS;
3717
+ exports.COUNTER_MAP_MAX = COUNTER_MAP_MAX;
3718
+ exports.COUNTER_OVERFLOW_KEY = COUNTER_OVERFLOW_KEY;
2358
3719
  exports.DEFAULT_LIMITS = DEFAULT_LIMITS;
2359
3720
  exports.Env = Env;
2360
3721
  exports.INDEX_BUDGET = INDEX_BUDGET;
2361
3722
  exports.KeyKind = KeyKind;
2362
3723
  exports.LogLevel = LogLevel;
3724
+ exports.MAX_SUGGESTIONS = MAX_SUGGESTIONS;
2363
3725
  exports.Origin = Origin;
2364
3726
  exports.PLATFORM_SCOPE = PLATFORM_SCOPE;
2365
3727
  exports.RETENTION_DAYS = RETENTION_DAYS;
@@ -2373,18 +3735,30 @@ exports.createIngest = createIngest;
2373
3735
  exports.createKey = createKey;
2374
3736
  exports.createQueries = createQueries;
2375
3737
  exports.createTelemetry = createTelemetry;
3738
+ exports.createValues = createValues;
2376
3739
  exports.defaultSpaDir = defaultSpaDir;
2377
3740
  exports.defineRegistry = defineRegistry;
3741
+ exports.deriveCatalog = deriveCatalog;
3742
+ exports.deriveSuggestions = deriveSuggestions;
2378
3743
  exports.deriveViews = deriveViews;
3744
+ exports.executeReport = executeReport;
2379
3745
  exports.findFamily = findFamily;
3746
+ exports.foldRollups = foldRollups;
2380
3747
  exports.hashSecret = hashSecret;
3748
+ exports.intervalForRange = intervalForRange;
2381
3749
  exports.isPlatformScope = isPlatformScope;
2382
3750
  exports.median = median;
2383
3751
  exports.newId = newId;
3752
+ exports.normalizeQuery = normalizeQuery;
2384
3753
  exports.parseKeyString = parseKeyString;
3754
+ exports.parseReportQuery = parseReportQuery;
2385
3755
  exports.plain = plain;
3756
+ exports.projectRegistry = projectRegistry;
3757
+ exports.rangeOf = rangeOf;
3758
+ exports.reportToQuery = reportToQuery;
2386
3759
  exports.requireMilestoneFamily = requireMilestoneFamily;
2387
3760
  exports.resolveDim = resolveDim;
3761
+ exports.resolveReport = resolveReport;
2388
3762
  exports.summarizeStages = summarizeStages;
2389
3763
  exports.traceKeep = traceKeep;
2390
3764
  exports.truncate = truncate;