@jeffjassky/telemetry 0.3.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/mcp.cjs CHANGED
@@ -5,8 +5,36 @@ require('uuidv7');
5
5
  var mongoose = require('mongoose');
6
6
 
7
7
  // src/server/mcp.ts
8
+ var TelemetryKind = {
9
+ Event: "event",
10
+ Error: "error",
11
+ Span: "span",
12
+ State: "state",
13
+ Usage: "usage"
14
+ };
15
+ var TELEMETRY_KINDS = Object.values(TelemetryKind);
16
+ var LogLevel = {
17
+ Debug: "debug",
18
+ Info: "info",
19
+ Warn: "warn",
20
+ Error: "error",
21
+ Fatal: "fatal"
22
+ };
23
+ var Env = { Prod: "prod", Staging: "staging", Dev: "dev" };
24
+ var Origin = { Server: "server", Client: "client" };
8
25
  var PLATFORM_SCOPE = "*";
9
26
  var isPlatformScope = (tenantId) => tenantId === PLATFORM_SCOPE;
27
+ var RETENTION_DAYS = {
28
+ // keep-all + 90d is cheap at this scale, and it makes p95-by-route a raw
29
+ // query instead of a rollup design (schema §2.1)
30
+ [TelemetryKind.Span]: 90,
31
+ [TelemetryKind.Error]: 90,
32
+ [TelemetryKind.Event]: 730,
33
+ [TelemetryKind.State]: 730,
34
+ [TelemetryKind.Usage]: null
35
+ // money is immortal
36
+ };
37
+ var COUNTER_OVERFLOW_KEY = "(other)|(other)";
10
38
  var truncate = (d, b) => {
11
39
  if (!b) return void 0;
12
40
  if (b === "hour") return new Date(Math.floor(d.getTime() / 36e5) * 36e5);
@@ -225,6 +253,10 @@ var DEFAULT_LIMITS = {
225
253
  rollups: 500,
226
254
  trace: 500,
227
255
  journey: 500,
256
+ breakdown: 50,
257
+ // top groups — a starting point, to be measured on real hosts
258
+ values: 200,
259
+ // top values of one dimension — a picker, not a table
228
260
  distribution: 1e5,
229
261
  distinct: 1e5,
230
262
  funnel: 5e3
@@ -238,7 +270,10 @@ function buildMatch(scope, range, f) {
238
270
  occurredAt: { $gte: range.from, $lt: range.to }
239
271
  };
240
272
  for (const k of ["kind", "name", "severity", "env", "service", "release", "traceId"]) {
241
- if (f[k]) match[k] = f[k];
273
+ const v = f[k];
274
+ if (Array.isArray(v)) {
275
+ if (v.length) match[k] = { $in: v };
276
+ } else if (v) match[k] = v;
242
277
  }
243
278
  if (f.subject) match.subjectKeys = f.subject;
244
279
  for (const [k, v] of Object.entries(f.attrs ?? {})) match[`attrs.${k}`] = v;
@@ -261,6 +296,68 @@ function buildMatch(scope, range, f) {
261
296
  }
262
297
  return match;
263
298
  }
299
+ var INTERVALS = ["hour", "day", "week", "month"];
300
+ var truncTo = (path, unit) => ({
301
+ $dateTrunc: { date: path, unit, ...unit === "week" ? { startOfWeek: "monday" } : {} }
302
+ });
303
+ function measureAccumulator(measure) {
304
+ const m = /^(sum|avg):(.+)$/.exec(measure);
305
+ if (!m) return { $sum: { $divide: [1, { $ifNull: ["$sampleRate", 1] }] } };
306
+ const path = m[2] === "durationMs" ? "$durationMs" : `$metrics.${m[2]}`;
307
+ return m[1] === "sum" ? { $sum: path } : { $avg: path };
308
+ }
309
+ var badRequest = (message) => Object.assign(new Error(`telemetry: breakdown() \u2014 ${message}`), { status: 400 });
310
+ var BREAKDOWN_FIELDS = [
311
+ "kind",
312
+ "name",
313
+ "severity",
314
+ "env",
315
+ "service",
316
+ "release",
317
+ "origin",
318
+ "client.platform",
319
+ "client.appVersion",
320
+ "usage.meter",
321
+ "usage.billedTo",
322
+ "usage.unit",
323
+ "state.key",
324
+ "state.to",
325
+ "error.type",
326
+ "error.handled"
327
+ ];
328
+ var typePrefix = (ref) => ({
329
+ $let: {
330
+ vars: { ref },
331
+ in: {
332
+ $cond: [
333
+ { $eq: [{ $type: "$$ref" }, "string"] },
334
+ { $arrayElemAt: [{ $split: ["$$ref", ":"] }, 0] },
335
+ null
336
+ ]
337
+ }
338
+ }
339
+ });
340
+ function dimExpression(dim2) {
341
+ if (dim2.startsWith("attr:")) {
342
+ const key = dim2.slice(5);
343
+ if (!key) throw badRequest('`attr:` needs a key, e.g. "attr:plan"');
344
+ return { $ifNull: [`$attrs.${key}`, null] };
345
+ }
346
+ if (dim2.startsWith("field:")) {
347
+ const path = dim2.slice(6);
348
+ if (!BREAKDOWN_FIELDS.includes(path)) {
349
+ throw badRequest(
350
+ `"field:${path}" 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.`
351
+ );
352
+ }
353
+ return { $ifNull: [`$${path}`, null] };
354
+ }
355
+ if (dim2 === "subjectType") return typePrefix({ $arrayElemAt: ["$subjectKeys", 0] });
356
+ if (dim2 === "actorType") return typePrefix("$actor");
357
+ throw badRequest(
358
+ `"${dim2}" is not a dimension. Use "attr:<key>", "field:<path>", "subjectType" or "actorType".`
359
+ );
360
+ }
264
361
  var QueryCache = class {
265
362
  constructor(ttlMs, cap) {
266
363
  this.ttlMs = ttlMs;
@@ -334,16 +431,9 @@ function createQueries(ctx) {
334
431
  return cache.get(
335
432
  key,
336
433
  () => timed("series", { scope, filter, measure, interval }, async () => {
337
- const m = /^(sum|avg):(.+)$/.exec(measure);
338
- const value = !m ? { $sum: { $divide: [1, { $ifNull: ["$sampleRate", 1] }] } } : m[1] === "sum" ? { $sum: `$metrics.${m[2]}` } : { $avg: `$metrics.${m[2]}` };
339
434
  const buckets = await ctx.TelemetryModel.aggregate([
340
435
  { $match: buildMatch(scope, range, filter) },
341
- {
342
- $group: {
343
- _id: { $dateTrunc: { date: "$occurredAt", unit: interval, ...interval === "week" ? { startOfWeek: "monday" } : {} } },
344
- value
345
- }
346
- },
436
+ { $group: { _id: truncTo("$occurredAt", interval), value: measureAccumulator(measure) } },
347
437
  { $sort: { _id: 1 } },
348
438
  { $limit: limits.series }
349
439
  ]);
@@ -351,6 +441,114 @@ function createQueries(ctx) {
351
441
  })
352
442
  );
353
443
  },
444
+ /**
445
+ * Top groups of a measure by one or two dimensions — "which models cost the
446
+ * most", "errors by release", "events by platform per week". The primitive
447
+ * that replaces a page's client-side grouping of whatever rows it happened
448
+ * to have fetched, which answered "this page" while reading like it
449
+ * answered the range (reports §6).
450
+ *
451
+ * THE CAP IS ON GROUPS RETURNED, NEVER ON ROWS SCANNED. Every `$limit`
452
+ * below sits AFTER a `$group`, exactly as series() does: the scan is bounded
453
+ * by buildMatch — tenant, range, filters, indexes — and nothing else, so a
454
+ * quarter of a million records is one pass and 50 rows. Truncation
455
+ * therefore keeps the TOP groups by measure, which is what a breakdown
456
+ * table means; a cap on documents scanned would return an arbitrary prefix
457
+ * and call it the top.
458
+ *
459
+ * With an `interval` this runs a SECOND aggregate restricted to the top
460
+ * groups, rather than one pipeline that groups by (dims, bucket) and folds.
461
+ * Two reasons: the ranking must be the measure over the WHOLE range (the
462
+ * same number the no-interval call reports), and folding in one pass means
463
+ * `$push`-ing every bucket of every group before the cap can apply — the
464
+ * unbounded intermediate this primitive exists to avoid. The restriction is
465
+ * an `$expr`/`$or` over the ≤ cap tuples because a dim can be a computed
466
+ * expression (subjectType), which a plain `$in` on a path cannot address.
467
+ *
468
+ * Under PLATFORM_SCOPE it aggregates ACROSS tenants, like series() — one
469
+ * set of groups with every tenant summed into it, which is the platform-wide
470
+ * table a platform operator came for. Ask for a per-tenant split by scoping
471
+ * to a tenant, or with rollups().
472
+ */
473
+ breakdown(scope, range, filter, opts) {
474
+ const groupBy = opts.groupBy ?? [];
475
+ if (groupBy.length < 1 || groupBy.length > 2) {
476
+ throw badRequest(`groupBy takes 1 or 2 dimensions, got ${groupBy.length}`);
477
+ }
478
+ const measure = opts.measure ?? "count";
479
+ const interval = opts.interval;
480
+ if (interval && !INTERVALS.includes(interval)) {
481
+ throw badRequest(`interval must be one of ${INTERVALS.join(", ")}`);
482
+ }
483
+ const dims = groupBy.map(dimExpression);
484
+ const cap = Math.min(Math.max(1, opts.limit ?? limits.breakdown), limits.breakdown);
485
+ const key = JSON.stringify([
486
+ "breakdown",
487
+ scope,
488
+ range.from,
489
+ range.to,
490
+ filter,
491
+ groupBy,
492
+ measure,
493
+ interval ?? null,
494
+ cap
495
+ ]);
496
+ return cache.get(
497
+ key,
498
+ () => timed("breakdown", { scope, filter, groupBy, measure, interval }, async () => {
499
+ const match = buildMatch(scope, range, filter);
500
+ const dimId = Object.fromEntries(dims.map((expr, i) => [`d${i}`, expr]));
501
+ const top = await ctx.TelemetryModel.aggregate([
502
+ { $match: match },
503
+ { $group: { _id: dimId, value: measureAccumulator(measure) } },
504
+ { $sort: { value: -1, _id: 1 } },
505
+ { $limit: cap + 1 }
506
+ ]);
507
+ const truncated = top.length > cap;
508
+ if (truncated) top.pop();
509
+ const tuples = top.map(
510
+ (g) => groupBy.map((_, i) => g._id?.[`d${i}`] ?? null)
511
+ );
512
+ if (!interval) {
513
+ return {
514
+ rows: top.map((g, i) => ({ dims: tuples[i], value: g.value })),
515
+ groups: top.length,
516
+ truncated,
517
+ bucketsTruncated: false,
518
+ dataSource: "raw"
519
+ };
520
+ }
521
+ if (!tuples.length) {
522
+ return { rows: [], groups: 0, truncated, bucketsTruncated: false, dataSource: "raw" };
523
+ }
524
+ const inTop = {
525
+ $or: tuples.map((t) => ({ $and: dims.map((expr, i) => ({ $eq: [expr, t[i] ?? null] })) }))
526
+ };
527
+ const bucketCap = limits.series * top.length;
528
+ const perBucket = await ctx.TelemetryModel.aggregate([
529
+ { $match: { ...match, $expr: inTop } },
530
+ { $group: { _id: { at: truncTo("$occurredAt", interval), ...dimId }, value: measureAccumulator(measure) } },
531
+ // `at` is the first key of `_id`, so one BSON sort orders by bucket
532
+ // then by dims — deterministic without a second sort key
533
+ { $sort: { _id: 1 } },
534
+ { $limit: bucketCap + 1 }
535
+ ]);
536
+ const bucketsTruncated = perBucket.length > bucketCap;
537
+ if (bucketsTruncated) perBucket.pop();
538
+ return {
539
+ rows: perBucket.map((b) => ({
540
+ dims: groupBy.map((_, i) => b._id?.[`d${i}`] ?? null),
541
+ at: b._id.at,
542
+ value: b.value
543
+ })),
544
+ groups: top.length,
545
+ truncated,
546
+ bucketsTruncated,
547
+ dataSource: "raw"
548
+ };
549
+ })
550
+ );
551
+ },
354
552
  /**
355
553
  * Percentiles + histogram off raw. Keep-all makes the SAMPLE complete —
356
554
  * no sampling stands between the match and the math (§5.3) — but the
@@ -592,6 +790,771 @@ function requireDistinctFamily(registry, as) {
592
790
  }
593
791
  return spec;
594
792
  }
793
+ new mongoose.Schema(
794
+ {
795
+ type: { type: String, required: true },
796
+ // user | org | team | session
797
+ id: { type: String, required: true },
798
+ /** disambiguates same-type parties: sender | recipient | impersonated */
799
+ role: { type: String }
800
+ },
801
+ { _id: false }
802
+ );
803
+ var BUILTIN_PLATFORMS = ["web", "electron", "ios", "android", "server", "cli"];
804
+ var StackFrameSchema = new mongoose.Schema(
805
+ {
806
+ filename: String,
807
+ fn: String,
808
+ lineno: Number,
809
+ colno: Number,
810
+ inApp: Boolean,
811
+ context: [String]
812
+ },
813
+ { _id: false }
814
+ );
815
+ new mongoose.Schema(
816
+ {
817
+ type: { type: String, required: true },
818
+ message: { type: String, required: true },
819
+ handled: { type: Boolean, required: true, default: false },
820
+ /** grouping key */
821
+ fingerprint: { type: String, required: true },
822
+ frames: [StackFrameSchema]
823
+ },
824
+ { _id: false }
825
+ );
826
+ new mongoose.Schema(
827
+ {
828
+ key: { type: String, required: true },
829
+ // 'lifecycle' | 'onboarding_step'
830
+ from: String,
831
+ to: { type: String, required: true },
832
+ /** how long the subject sat in `from` — answers "where do they stall" */
833
+ previousSinceMs: Number
834
+ },
835
+ { _id: false }
836
+ );
837
+ new mongoose.Schema(
838
+ {
839
+ meter: { type: String, required: true },
840
+ quantity: { type: Number, required: true },
841
+ unit: { type: String, required: true },
842
+ /**
843
+ * Authoritative money. `metrics.cost_usd` is a BSON double — fine as a
844
+ * measure, wrong as the thing that becomes an invoice. Money is
845
+ * authoritative only on kind=usage; the metric is a lossy copy.
846
+ */
847
+ amount: mongoose.Schema.Types.Decimal128,
848
+ currency: String,
849
+ /** at-least-once dedupe. Deterministic, e.g. `${traceId}:${spanId}` */
850
+ idempotencyKey: { type: String, required: true },
851
+ /** who pays — 'org:o_9'. Distinct from subject and from actor. */
852
+ billedTo: { type: String, required: true },
853
+ billable: { type: Boolean, required: true, default: true },
854
+ priceVersion: String,
855
+ /** corrections are new reversing rows; never UPDATE a billed row */
856
+ reverses: String
857
+ },
858
+ { _id: false }
859
+ );
860
+
861
+ // src/server/catalog.ts
862
+ var label = (src) => src.slice(src.indexOf(":") + 1);
863
+ var LEAF_TYPES = {
864
+ string: "string",
865
+ number: "number",
866
+ int: "number",
867
+ bigint: "number",
868
+ boolean: "boolean",
869
+ date: "date"
870
+ };
871
+ function walkAttr(schema) {
872
+ let node = schema;
873
+ let optional = false;
874
+ for (let depth = 0; node && depth < 20; depth++) {
875
+ const def = node._zod?.def ?? node.def;
876
+ if (!def?.type) break;
877
+ switch (def.type) {
878
+ // these three all mean "the value may be absent from a stored record",
879
+ // which is the only thing `optional` claims
880
+ case "optional":
881
+ case "nullable":
882
+ case "default":
883
+ optional = true;
884
+ node = def.innerType;
885
+ continue;
886
+ case "catch":
887
+ case "readonly":
888
+ node = def.innerType;
889
+ continue;
890
+ // a pipe is `in -> out`; the INPUT side is what a caller may send and so
891
+ // what a stored value was validated as. The output of a transform is
892
+ // frequently a shape no filter could ever be written against.
893
+ case "pipe":
894
+ node = def.in;
895
+ continue;
896
+ case "enum": {
897
+ const options = Array.isArray(node.options) ? node.options : Object.values(def.entries ?? {});
898
+ return { type: "enum", values: options.map(String), optional };
899
+ }
900
+ case "literal":
901
+ return { type: "enum", values: [...def.values ?? []].map(String), optional };
902
+ default:
903
+ return { type: LEAF_TYPES[def.type] ?? "string", optional };
904
+ }
905
+ }
906
+ return { type: "string", optional };
907
+ }
908
+ var dim = (key, type, o = {}) => ({
909
+ key,
910
+ // the two pseudo-dims are derived at query time from subjectKeys / actor, so
911
+ // they carry no `field:` prefix and label to themselves
912
+ label: key.startsWith("field:") ? key.slice(6) : key,
913
+ type,
914
+ ...o.values ? { values: [...o.values] } : {},
915
+ optional: o.optional ?? false,
916
+ indexed: o.indexed ?? false
917
+ });
918
+ var envelopeDims = (platforms) => [
919
+ dim("field:kind", "enum", { values: TELEMETRY_KINDS, indexed: true }),
920
+ dim("field:name", "string", { indexed: true }),
921
+ dim("field:severity", "enum", { values: Object.values(LogLevel) }),
922
+ dim("field:env", "enum", { values: Object.values(Env) }),
923
+ dim("field:service", "string"),
924
+ dim("field:release", "string"),
925
+ dim("field:origin", "enum", { values: Object.values(Origin) }),
926
+ // client context is absent on server-origin records, so both of its dims are optional
927
+ dim("field:client.platform", "enum", { values: platforms, optional: true }),
928
+ dim("field:client.appVersion", "string", { optional: true }),
929
+ dim("subjectType", "string", { optional: true, indexed: true }),
930
+ dim("actorType", "string", { optional: true })
931
+ ];
932
+ var kindDims = (kind) => {
933
+ switch (kind) {
934
+ case TelemetryKind.Usage:
935
+ return [
936
+ dim("field:usage.meter", "string", { indexed: true }),
937
+ dim("field:usage.billedTo", "string"),
938
+ dim("field:usage.unit", "string")
939
+ ];
940
+ case TelemetryKind.State:
941
+ return [
942
+ dim("field:state.key", "string", { indexed: true }),
943
+ dim("field:state.to", "string", { indexed: true })
944
+ ];
945
+ case TelemetryKind.Error:
946
+ return [dim("field:error.type", "string"), dim("field:error.handled", "boolean")];
947
+ default:
948
+ return [];
949
+ }
950
+ };
951
+ var RAW_OPS = ["avg", "p50", "p95", "p99"];
952
+ function deriveCatalog(registry, opts = {}) {
953
+ const platforms = [.../* @__PURE__ */ new Set([...BUILTIN_PLATFORMS, ...opts.platforms ?? []])];
954
+ const families = {};
955
+ for (const [name, spec] of Object.entries(registry)) {
956
+ for (const r of spec.rollups ?? []) {
957
+ const as = r.as ?? name;
958
+ const seen = families[as];
959
+ if (!seen) {
960
+ families[as] = {
961
+ as,
962
+ by: [...r.by],
963
+ labels: r.by.map(label),
964
+ bucket: r.bucket ?? null,
965
+ lifetime: !r.bucket,
966
+ // `subjects` only means anything when there is a subject dim to
967
+ // restrict; without one it selects nothing and claiming it would
968
+ // offer a subject filter the family cannot answer
969
+ subjectTypes: r.by.includes("subject") ? [...r.subjects ?? []] : [],
970
+ sums: [...r.sum ?? []],
971
+ capture: (r.capture ?? []).map(label),
972
+ feeders: [name],
973
+ retentionDays: r.retentionDays ?? null
974
+ };
975
+ continue;
976
+ }
977
+ for (const k of r.sum ?? []) if (!seen.sums.includes(k)) seen.sums.push(k);
978
+ for (const c of r.capture ?? []) {
979
+ const l = label(c);
980
+ if (!seen.capture.includes(l)) seen.capture.push(l);
981
+ }
982
+ if (!seen.feeders.includes(name)) seen.feeders.push(name);
983
+ }
984
+ }
985
+ const events = {};
986
+ const namespaces = {};
987
+ const subjectTypes = [];
988
+ const noteSubject = (t) => {
989
+ if (!subjectTypes.includes(t)) subjectTypes.push(t);
990
+ };
991
+ for (const [name, spec] of Object.entries(registry)) {
992
+ const dot = name.indexOf(".");
993
+ const namespace = dot === -1 ? name : name.slice(0, dot);
994
+ (namespaces[namespace] ??= []).push(name);
995
+ for (const s of spec.subjects) noteSubject(s);
996
+ const indexedAttrs = [...spec.indexedAttrs ?? []];
997
+ const dims = Object.entries(spec.attrs?.shape ?? {}).map(
998
+ ([key, schema]) => {
999
+ const walked = walkAttr(schema);
1000
+ return {
1001
+ key: `attr:${key}`,
1002
+ label: key,
1003
+ type: walked.type,
1004
+ ...walked.values ? { values: walked.values } : {},
1005
+ optional: walked.optional,
1006
+ indexed: indexedAttrs.includes(key)
1007
+ };
1008
+ }
1009
+ );
1010
+ dims.push(...kindDims(spec.kind));
1011
+ const eventFamilies = [];
1012
+ const ownSums = /* @__PURE__ */ new Map();
1013
+ for (const r of spec.rollups ?? []) {
1014
+ const as = r.as ?? name;
1015
+ if (!eventFamilies.includes(as)) eventFamilies.push(as);
1016
+ const set = ownSums.get(as) ?? /* @__PURE__ */ new Set();
1017
+ for (const k of r.sum ?? []) set.add(k);
1018
+ ownSums.set(as, set);
1019
+ for (const s of r.subjects ?? []) noteSubject(s);
1020
+ }
1021
+ const measures = [{ key: "count", exactVia: [] }];
1022
+ for (const k of Object.keys(spec.metrics?.shape ?? {})) {
1023
+ measures.push({
1024
+ key: `sum:${k}`,
1025
+ metric: k,
1026
+ exactVia: eventFamilies.filter((as) => ownSums.get(as)?.has(k))
1027
+ });
1028
+ for (const op of RAW_OPS) measures.push({ key: `${op}:${k}`, metric: k, exactVia: [] });
1029
+ }
1030
+ if (spec.kind === TelemetryKind.Span) {
1031
+ for (const op of RAW_OPS) {
1032
+ measures.push({ key: `${op}:durationMs`, metric: "durationMs", exactVia: [] });
1033
+ }
1034
+ }
1035
+ events[name] = {
1036
+ kind: spec.kind,
1037
+ origin: spec.origin,
1038
+ subjects: [...spec.subjects],
1039
+ description: spec.description,
1040
+ namespace,
1041
+ dims,
1042
+ measures,
1043
+ families: eventFamilies,
1044
+ indexedAttrs,
1045
+ indexedMetrics: [...spec.indexedMetrics ?? []],
1046
+ // `hasOwnProperty` rather than `??`, exactly as model.ts stamps expiresAt:
1047
+ // an explicit `retentionDays: null` means immortal and must not fall
1048
+ // through to the per-kind default
1049
+ retentionDays: Object.prototype.hasOwnProperty.call(spec, "retentionDays") ? spec.retentionDays ?? null : RETENTION_DAYS[spec.kind]
1050
+ };
1051
+ }
1052
+ return { events, families, namespaces, envelope: envelopeDims(platforms), subjectTypes };
1053
+ }
1054
+ function projectRegistry(catalog) {
1055
+ return Object.fromEntries(
1056
+ Object.entries(catalog.events).map(([name, e]) => [
1057
+ name,
1058
+ {
1059
+ kind: e.kind,
1060
+ origin: e.origin,
1061
+ subjects: e.subjects,
1062
+ description: e.description,
1063
+ attrKeys: e.dims.filter((d) => d.key.startsWith("attr:")).map((d) => d.label),
1064
+ // every metric key gets exactly one `sum:` measure and nothing else does
1065
+ metricKeys: e.measures.filter((m) => m.key.startsWith("sum:")).map((m) => m.metric),
1066
+ indexedAttrs: e.indexedAttrs,
1067
+ indexedMetrics: e.indexedMetrics,
1068
+ rollups: e.families.map((as) => {
1069
+ const f = catalog.families[as];
1070
+ return { as: f.as, by: f.by, bucket: f.bucket, sum: f.sums, subjects: f.subjectTypes };
1071
+ })
1072
+ }
1073
+ ])
1074
+ );
1075
+ }
1076
+
1077
+ // src/server/report.ts
1078
+ var RANGE_MS = {
1079
+ "1h": 36e5,
1080
+ "24h": 864e5,
1081
+ "7d": 7 * 864e5,
1082
+ "30d": 30 * 864e5,
1083
+ "90d": 90 * 864e5
1084
+ };
1085
+ var badRequest2 = (message) => Object.assign(new Error(`telemetry: ${message}`), { status: 400 });
1086
+ function rangeOf(range, now = /* @__PURE__ */ new Date()) {
1087
+ if (typeof range === "string") {
1088
+ const ms = RANGE_MS[range] ?? spanOf(range);
1089
+ if (ms == null) {
1090
+ throw badRequest2(
1091
+ `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`
1092
+ );
1093
+ }
1094
+ return { from: new Date(now.getTime() - ms), to: now };
1095
+ }
1096
+ const from = new Date(range.from);
1097
+ const to = new Date(range.to);
1098
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
1099
+ throw badRequest2(
1100
+ `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)`
1101
+ );
1102
+ }
1103
+ return { from, to };
1104
+ }
1105
+ function spanOf(range) {
1106
+ const m = /^(\d+)([hd])$/.exec(range);
1107
+ if (!m) return null;
1108
+ return Number(m[1]) * (m[2] === "h" ? 36e5 : 864e5);
1109
+ }
1110
+ function intervalForRange(range, now = /* @__PURE__ */ new Date()) {
1111
+ if (typeof range === "string" && RANGE_MS[range] != null) {
1112
+ return range === "1h" || range === "24h" ? "hour" : range === "90d" ? "week" : "day";
1113
+ }
1114
+ const { from, to } = rangeOf(range, now);
1115
+ const ms = to.getTime() - from.getTime();
1116
+ if (ms <= 864e5) return "hour";
1117
+ if (ms < 90 * 864e5) return "day";
1118
+ return "week";
1119
+ }
1120
+ var INTERVAL_RANK = { hour: 0, day: 1, week: 2, month: 3 };
1121
+ var shift = (range) => ({
1122
+ from: new Date(range.from.getTime() - (range.to.getTime() - range.from.getTime())),
1123
+ to: range.from
1124
+ });
1125
+ function expandSource(source, catalog) {
1126
+ if ("event" in source) {
1127
+ if (!catalog.events[source.event]) {
1128
+ return unavailable(
1129
+ `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`
1130
+ );
1131
+ }
1132
+ return { form: "event", events: [source.event] };
1133
+ }
1134
+ if ("namespace" in source) {
1135
+ const events = catalog.namespaces[source.namespace];
1136
+ if (!events?.length) {
1137
+ return unavailable(
1138
+ `no event name starts with "${source.namespace}." \u2014 the registered namespaces are ${Object.keys(catalog.namespaces).join(", ")}`
1139
+ );
1140
+ }
1141
+ return { form: "namespace", events: [...events] };
1142
+ }
1143
+ if ("kind" in source) {
1144
+ const events = Object.keys(catalog.events).filter((n) => catalog.events[n].kind === source.kind);
1145
+ if (!events.length) {
1146
+ return unavailable(
1147
+ `no event is registered with kind "${source.kind}" \u2014 declare one, or pick a kind the registry uses`
1148
+ );
1149
+ }
1150
+ return { form: "kind", events, kind: source.kind };
1151
+ }
1152
+ const family = catalog.families[source.family];
1153
+ if (!family) {
1154
+ return unavailable(
1155
+ `no rollup family "${source.family}" is declared \u2014 add a \`rollups: [{ as: '${source.family}', by: [...] }]\` block to the event that should feed it`
1156
+ );
1157
+ }
1158
+ return { form: "family", events: [...family.feeders], family };
1159
+ }
1160
+ var FILTER_ONLY = {
1161
+ "field:subject": "subject",
1162
+ "field:traceId": "traceId"
1163
+ };
1164
+ function dimsFor(catalog, events) {
1165
+ const out = /* @__PURE__ */ new Map();
1166
+ for (const d of catalog.envelope) out.set(d.key, d);
1167
+ for (const name of events) {
1168
+ for (const d of catalog.events[name]?.dims ?? []) {
1169
+ const seen = out.get(d.key);
1170
+ out.set(d.key, seen ? { ...seen, indexed: seen.indexed && d.indexed } : d);
1171
+ }
1172
+ }
1173
+ return out;
1174
+ }
1175
+ var measureDeclared = (catalog, events, key) => key === "count" || events.some((n) => catalog.events[n]?.measures.some((m) => m.key === key));
1176
+ var MEASURE_OP = /^(sum|avg|p50|p90|p95|p99):(.+)$/;
1177
+ var unavailable = (why) => ({ unavailable: true, why });
1178
+ var count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
1179
+ function resolveReport(report, catalog, opts = {}) {
1180
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1181
+ const limits = opts.limits ?? {};
1182
+ const src = expandSource(report.source, catalog);
1183
+ if ("unavailable" in src) return src;
1184
+ const measure = report.measure ?? "count";
1185
+ const groupBy = report.groupBy ?? [];
1186
+ if (groupBy.length > 2) {
1187
+ return unavailable(
1188
+ `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`
1189
+ );
1190
+ }
1191
+ if (report.interval && INTERVAL_RANK[report.interval] == null) {
1192
+ return unavailable(`interval "${report.interval}" is not one of hour, day, week, month`);
1193
+ }
1194
+ if (measure === "funnel") return planFunnel(report, catalog, now, limits);
1195
+ if (measure.startsWith("distinct:")) return planDistinct(report, catalog, src, measure, now);
1196
+ const opMatch = MEASURE_OP.exec(measure);
1197
+ if (measure !== "count" && !opMatch) {
1198
+ return unavailable(
1199
+ `measure "${measure}" is not a measure \u2014 use 'count', 'sum:<metric>', 'avg:<metric>', 'p50|p95|p99:<metric>', 'distinct:<subjectType>' or 'funnel'`
1200
+ );
1201
+ }
1202
+ if (opMatch && !measureDeclared(catalog, src.events, measure)) {
1203
+ return unavailable(
1204
+ `"${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)}`
1205
+ );
1206
+ }
1207
+ const exact = planRollups(report, catalog, src, measure, groupBy, now, limits);
1208
+ if (exact) return exact;
1209
+ const filter = toRecordFilter(report, catalog, src);
1210
+ if ("unavailable" in filter) return filter;
1211
+ const range = rangeOf(report.range, now);
1212
+ const dims = dimsFor(catalog, src.events);
1213
+ const touched = [...groupBy, ...(report.filters ?? []).map((f) => f.dim)];
1214
+ const unindexed = touched.find((k) => !(dims.get(k)?.indexed ?? FILTER_ONLY[k] != null));
1215
+ 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` : "") : "";
1216
+ const exactness = unindexed != null ? "scan" : "raw";
1217
+ if (opMatch && opMatch[1] !== "sum" && opMatch[1] !== "avg") {
1218
+ if (groupBy.length) {
1219
+ return unavailable(
1220
+ `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`
1221
+ );
1222
+ }
1223
+ return withCompare(report, {
1224
+ primitive: "distribution",
1225
+ args: [range, filter, { measure: opMatch[2] }],
1226
+ exactness,
1227
+ why: `${measure} is approximate by construction ($percentile t-digest over the matched records)${scanWhy}`
1228
+ });
1229
+ }
1230
+ if (groupBy.length) {
1231
+ const bad = groupBy.find((k) => !dims.has(k));
1232
+ if (bad) {
1233
+ return unavailable(
1234
+ `"${bad}" is not a dimension of ${describe(src)} \u2014 group by one of ${[...dims.keys()].join(", ")}`
1235
+ );
1236
+ }
1237
+ return withCompare(report, {
1238
+ primitive: "breakdown",
1239
+ args: [
1240
+ range,
1241
+ filter,
1242
+ {
1243
+ groupBy,
1244
+ measure,
1245
+ ...report.interval ? { interval: report.interval } : {},
1246
+ ...report.limit ? { limit: capped(report.limit, limits.breakdown) } : {}
1247
+ }
1248
+ ],
1249
+ exactness,
1250
+ why: `raw ${measure} by ${groupBy.join(" \xD7 ")} over the range${scanWhy}`
1251
+ });
1252
+ }
1253
+ if (!report.measure && !report.interval) {
1254
+ return withCompare(report, {
1255
+ primitive: "records",
1256
+ args: [range, filter, report.limit ? { limit: capped(report.limit, limits.records) } : {}],
1257
+ exactness,
1258
+ why: `the matching records themselves, newest first${scanWhy}`
1259
+ });
1260
+ }
1261
+ const interval = report.interval ?? intervalForRange(report.range, now);
1262
+ return withCompare(report, {
1263
+ primitive: "series",
1264
+ args: [range, filter, { measure, interval }],
1265
+ exactness,
1266
+ why: `raw ${measure} per ${interval} over the range${scanWhy}`
1267
+ });
1268
+ }
1269
+ var capped = (limit, cap) => cap == null ? limit : Math.max(1, Math.min(limit, cap));
1270
+ var describe = (src) => src.form === "family" ? `family "${src.family.as}"` : src.events.join(", ");
1271
+ var metricList = (catalog, events) => {
1272
+ const keys = /* @__PURE__ */ new Set();
1273
+ for (const n of events) for (const m of catalog.events[n]?.measures ?? []) keys.add(m.key);
1274
+ return keys.size ? [...keys].join(", ") : "nothing but count";
1275
+ };
1276
+ function planFunnel(report, catalog, now, limits) {
1277
+ const stages = report.stages ?? [];
1278
+ if (!stages.length) {
1279
+ return unavailable(
1280
+ "`measure: 'funnel'` needs `stages` \u2014 one or more lifetime `by: ['subject']` rollup family names, in the order a subject reaches them"
1281
+ );
1282
+ }
1283
+ const anchor = report.anchor ?? stages[0];
1284
+ const exits = report.exits ?? [];
1285
+ for (const as of [...stages, anchor, ...exits]) {
1286
+ const refusal = milestoneRefusal(catalog, as);
1287
+ if (refusal) return unavailable(refusal);
1288
+ }
1289
+ const first = catalog.families[stages[0]];
1290
+ for (const as of [...stages.slice(1), anchor]) {
1291
+ const f = catalog.families[as];
1292
+ if (!sameSet(f.subjectTypes, first.subjectTypes)) {
1293
+ return unavailable(
1294
+ `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`
1295
+ );
1296
+ }
1297
+ }
1298
+ if (report.subjectType && !first.subjectTypes.includes(report.subjectType)) {
1299
+ return unavailable(
1300
+ `subjectType "${report.subjectType}" is not one of the stages' subjects (${first.subjectTypes.join(", ")}) \u2014 the cohort would be empty`
1301
+ );
1302
+ }
1303
+ if (report.interval === "hour") {
1304
+ return unavailable(
1305
+ "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"
1306
+ );
1307
+ }
1308
+ const params = {
1309
+ stages: stages.map((as) => ({ as })),
1310
+ anchor,
1311
+ ...exits.length ? { exits: exits.map((as) => ({ as })) } : {},
1312
+ cohort: rangeOf(report.range, now),
1313
+ ...report.subjectType ? { subjectType: report.subjectType } : {},
1314
+ ...report.interval ? { interval: report.interval } : {},
1315
+ ...report.limit ? { limit: capped(report.limit, limits.funnel) } : {}
1316
+ };
1317
+ return withCompare(report, {
1318
+ primitive: "funnel",
1319
+ args: [params],
1320
+ exactness: "exact",
1321
+ via: anchor,
1322
+ why: `cohort funnel over ${count(stages.length, "lifetime milestone family")}, anchored on "${anchor}" \u2014 rollups only, no raw scan`
1323
+ });
1324
+ }
1325
+ function milestoneRefusal(catalog, as) {
1326
+ const f = catalog.families[as];
1327
+ if (!f) {
1328
+ return `no rollup family "${as}" is declared. Add a \`rollups: [{ as: '${as}', by: ['subject'], subjects: [...] }]\` block to the event that marks it`;
1329
+ }
1330
+ const shape = `by: [${f.by.map((d) => `'${d}'`).join(", ")}]${f.bucket ? `, bucket: '${f.bucket}'` : ""}`;
1331
+ if (!f.lifetime) {
1332
+ 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`;
1333
+ }
1334
+ if (f.by.length !== 1 || f.by[0] !== "subject") {
1335
+ 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`;
1336
+ }
1337
+ return null;
1338
+ }
1339
+ function planDistinct(report, catalog, src, measure, now) {
1340
+ const subjectType = measure.slice("distinct:".length);
1341
+ if (!subjectType) {
1342
+ return unavailable(
1343
+ `"${measure}" needs a subject type \u2014 'distinct:account', one of ${catalog.subjectTypes.join(", ")}`
1344
+ );
1345
+ }
1346
+ if (!catalog.subjectTypes.includes(subjectType)) {
1347
+ return unavailable(
1348
+ `no event or rollup declares the subject type "${subjectType}" \u2014 the registry knows ${catalog.subjectTypes.join(", ") || "no subject types at all"}`
1349
+ );
1350
+ }
1351
+ if (report.groupBy?.length) {
1352
+ return unavailable(
1353
+ `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`
1354
+ );
1355
+ }
1356
+ const wanted = new Set(src.events);
1357
+ const fits = Object.values(catalog.families).filter(
1358
+ (f) => f.bucket != null && f.by.length === 1 && f.by[0] === "subject" && f.subjectTypes.includes(subjectType) && src.events.every((e) => f.feeders.includes(e))
1359
+ );
1360
+ const family = fits.find((f) => sameSet(f.feeders, [...wanted])) ?? fits[0];
1361
+ if (!family) {
1362
+ return unavailable(
1363
+ `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`
1364
+ );
1365
+ }
1366
+ const superset = !sameSet(family.feeders, [...wanted]);
1367
+ return withCompare(report, {
1368
+ primitive: "distinctCount",
1369
+ args: [
1370
+ {
1371
+ as: family.as,
1372
+ subjectType,
1373
+ range: rangeOf(report.range, now),
1374
+ ...report.interval ? { interval: report.interval } : {}
1375
+ }
1376
+ ],
1377
+ exactness: "exact",
1378
+ via: family.as,
1379
+ 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` : "")
1380
+ });
1381
+ }
1382
+ var famDims = (f) => f.by.map((b) => b === "subject" ? "subjectType" : b);
1383
+ function planRollups(report, catalog, src, measure, groupBy, now, limits) {
1384
+ if (report.excludeActorTypes?.length) return null;
1385
+ const candidates = src.family ? [src.family] : Object.values(catalog.families).filter((f) => sameSet(f.feeders, src.events));
1386
+ const op = MEASURE_OP.exec(measure);
1387
+ const filters = report.filters ?? [];
1388
+ const fits = candidates.filter((f) => {
1389
+ const dims2 = famDims(f);
1390
+ if (!groupBy.every((k) => dims2.includes(k))) return false;
1391
+ if (report.interval && (!f.bucket || INTERVAL_RANK[f.bucket] > INTERVAL_RANK[report.interval])) return false;
1392
+ if (op) {
1393
+ if (op[1] !== "sum" && op[1] !== "avg" || !f.sums.includes(op[2])) return false;
1394
+ }
1395
+ return filters.every(
1396
+ (t) => dims2.includes(t.dim) && (t.op === "eq" || t.op === "in") || nameFilterCovers(t, f)
1397
+ );
1398
+ });
1399
+ const family = fits.sort((a, b) => a.by.length - b.by.length)[0];
1400
+ if (!family) return null;
1401
+ const dims = famDims(family);
1402
+ const range = rangeOf(report.range, now);
1403
+ const on = family.lifetime ? "firstAt" : "bucketAt";
1404
+ const fold = filters.filter((t) => dims.includes(t.dim));
1405
+ return withCompare(report, {
1406
+ primitive: "rollups",
1407
+ args: [
1408
+ {
1409
+ as: family.as,
1410
+ on,
1411
+ range,
1412
+ sort: family.lifetime ? "count" : "bucketAt",
1413
+ ...report.limit ? { limit: capped(report.limit, limits.rollups) } : {}
1414
+ }
1415
+ ],
1416
+ exactness: "exact",
1417
+ via: family.as,
1418
+ shape: {
1419
+ groupBy,
1420
+ labels: groupBy.map((k) => family.labels[dims.indexOf(k)]),
1421
+ measure,
1422
+ ...report.interval ? { interval: report.interval } : {},
1423
+ ...fold.length ? { filters: fold.map((t) => ({ ...t, label: family.labels[dims.indexOf(t.dim)] })) } : {}
1424
+ },
1425
+ 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` : "")
1426
+ });
1427
+ }
1428
+ function nameFilterCovers(t, f) {
1429
+ if (t.dim !== "field:name") return false;
1430
+ const admitted = t.op === "eq" ? [String(t.value)] : t.op === "in" ? [...t.value].map(String) : null;
1431
+ return admitted != null && f.feeders.every((n) => admitted.includes(n));
1432
+ }
1433
+ var FIELD_TERMS = {
1434
+ "field:kind": "kind",
1435
+ "field:name": "name",
1436
+ "field:severity": "severity",
1437
+ "field:env": "env",
1438
+ "field:service": "service",
1439
+ "field:release": "release",
1440
+ ...FILTER_ONLY
1441
+ };
1442
+ function toRecordFilter(report, catalog, src) {
1443
+ const filter = {};
1444
+ if (src.events.length === 1) {
1445
+ filter.name = src.events[0];
1446
+ } else if (src.kind) {
1447
+ filter.kind = src.kind;
1448
+ } else {
1449
+ filter.name = [...src.events];
1450
+ }
1451
+ for (const t of report.filters ?? []) {
1452
+ const term = FIELD_TERMS[t.dim];
1453
+ if (term) {
1454
+ if (t.op !== "eq") {
1455
+ return unavailable(
1456
+ `"${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`
1457
+ );
1458
+ }
1459
+ filter[term] = String(t.value);
1460
+ continue;
1461
+ }
1462
+ if (t.dim.startsWith("attr:")) {
1463
+ const key = t.dim.slice(5);
1464
+ if (t.op === "eq") {
1465
+ (filter.attrs ??= {})[key] = String(t.value);
1466
+ continue;
1467
+ }
1468
+ if (t.op === "gte" || t.op === "lte") {
1469
+ if (!measureDeclared(catalog, src.events, `sum:${key}`)) {
1470
+ return unavailable(
1471
+ `"${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`
1472
+ );
1473
+ }
1474
+ const range = (filter.metrics ??= {})[key] ??= {};
1475
+ range[t.op] = Number(t.value);
1476
+ continue;
1477
+ }
1478
+ return unavailable(
1479
+ `"${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`
1480
+ );
1481
+ }
1482
+ if (t.dim === "subjectType" || t.dim === "actorType") {
1483
+ return unavailable(
1484
+ `"${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`" : "")
1485
+ );
1486
+ }
1487
+ return unavailable(
1488
+ `"${t.dim}" is not filterable on the raw path \u2014 RecordFilter carries ${Object.keys(FIELD_TERMS).join(", ")}, \`attr:<key>\` and metric bounds`
1489
+ );
1490
+ }
1491
+ if (report.excludeActorTypes?.length) filter.excludeActorTypes = [...report.excludeActorTypes];
1492
+ return filter;
1493
+ }
1494
+ function withCompare(report, plan) {
1495
+ if (report.compare !== "previous") return plan;
1496
+ const [first, ...rest] = plan.args;
1497
+ if (plan.primitive === "rollups" || plan.primitive === "distinctCount" || plan.primitive === "funnel") {
1498
+ const params = first;
1499
+ const key = plan.primitive === "funnel" ? "cohort" : "range";
1500
+ const window = params[key];
1501
+ if (!window) return plan;
1502
+ return { ...plan, previous: { args: [{ ...params, [key]: { ...window, ...shift(window) } }, ...rest] } };
1503
+ }
1504
+ return { ...plan, previous: { args: [shift(first), ...rest] } };
1505
+ }
1506
+ var sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
1507
+ var LEGACY_DIMS = {
1508
+ kind: "field:kind",
1509
+ name: "field:name",
1510
+ severity: "field:severity",
1511
+ env: "field:env",
1512
+ service: "field:service",
1513
+ release: "field:release",
1514
+ subject: "field:subject",
1515
+ traceId: "field:traceId"
1516
+ };
1517
+ function normalizeQuery(query) {
1518
+ if (!query || typeof query !== "object") return null;
1519
+ if ("source" in query && query.source) return query;
1520
+ const q = query;
1521
+ const filters = q.filters ?? {};
1522
+ const str = (v) => typeof v === "string" && v ? v : null;
1523
+ const name = str(filters.name);
1524
+ const family = str(filters.rollup);
1525
+ const kind = str(filters.kind);
1526
+ const source = name ? { event: name } : family ? { family } : kind ? { kind } : null;
1527
+ if (!source) return null;
1528
+ const consumed = name ? "name" : family ? "rollup" : "kind";
1529
+ const terms = [];
1530
+ for (const [k, v] of Object.entries(filters)) {
1531
+ if (k === consumed || k === "rollup") continue;
1532
+ if (k === "excludeActorTypes") continue;
1533
+ if (k === "attrs") {
1534
+ const entries = typeof v === "string" ? v.split(",").map((pair) => pair.split(":").map((s) => s.trim())) : Object.entries(v ?? {}).map(([a, b]) => [a, String(b)]);
1535
+ for (const [key, value2] of entries) {
1536
+ if (key && value2 != null) terms.push({ dim: `attr:${key}`, op: "eq", value: String(value2) });
1537
+ }
1538
+ continue;
1539
+ }
1540
+ const dim2 = LEGACY_DIMS[k];
1541
+ const value = str(v);
1542
+ if (dim2 && value) terms.push({ dim: dim2, op: "eq", value });
1543
+ }
1544
+ const groupBy = (q.groupBy ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1545
+ const sort = q.sort === "value" || q.sort === "label" || q.sort === "time" ? q.sort : void 0;
1546
+ const actors = filters.excludeActorTypes;
1547
+ return {
1548
+ source,
1549
+ range: q.range ?? "7d",
1550
+ ...terms.length ? { filters: terms } : {},
1551
+ ...groupBy.length ? { groupBy } : {},
1552
+ ...sort ? { sort } : {},
1553
+ ...Array.isArray(actors) && actors.length ? { excludeActorTypes: actors.map(String) } : {}
1554
+ };
1555
+ }
1556
+
1557
+ // src/server/views.ts
595
1558
  function buildViewModel(connection, modelName, collection) {
596
1559
  const existing = connection.models?.[modelName];
597
1560
  if (existing) return existing;
@@ -618,31 +1581,58 @@ var KIND_PAGE = {
618
1581
  state: "journeys",
619
1582
  usage: "usage"
620
1583
  };
621
- function deriveViews(registry) {
1584
+ function deriveViews(registry, catalog = deriveCatalog(registry)) {
622
1585
  const views = [];
623
- const families = /* @__PURE__ */ new Set();
624
- for (const [name, spec] of Object.entries(registry)) {
625
- views.push({
626
- origin: "derived",
627
- name,
628
- page: KIND_PAGE[spec.kind] ?? "events",
629
- query: { range: "7d", filters: { name }, display: spec.kind === "event" ? "series" : "table" }
1586
+ const derived = (name, page, query) => views.push({ origin: "derived", name, page, query });
1587
+ for (const [name, e] of Object.entries(catalog.events)) {
1588
+ derived(name, KIND_PAGE[e.kind] ?? "events", {
1589
+ source: { event: name },
1590
+ range: "7d",
1591
+ interval: intervalForRange("7d")
630
1592
  });
631
- for (const r of spec.rollups ?? []) families.add(r.as ?? name);
632
- }
633
- for (const as of families) {
634
- views.push({
635
- origin: "derived",
636
- name: `rollup: ${as}`,
637
- page: "journeys",
638
- query: { range: "30d", filters: { rollup: as }, display: "breakdown" }
1593
+ }
1594
+ for (const as of Object.keys(catalog.families)) {
1595
+ derived(`rollup: ${as}`, "journeys", { source: { family: as }, range: "30d" });
1596
+ }
1597
+ for (const [ns, names] of Object.entries(catalog.namespaces)) {
1598
+ if (names.length < 2) continue;
1599
+ derived(`namespace: ${ns}`, "explore", {
1600
+ source: { namespace: ns },
1601
+ range: "30d",
1602
+ interval: "day",
1603
+ groupBy: ["field:name"]
1604
+ });
1605
+ }
1606
+ for (const [name, e] of Object.entries(catalog.events)) {
1607
+ if (e.kind !== "usage") continue;
1608
+ const money = e.measures.find((m) => m.key.startsWith("sum:") && m.key.endsWith("_usd"));
1609
+ if (!money) continue;
1610
+ derived(`spend: ${name}`, "usage", {
1611
+ source: { event: name },
1612
+ range: "30d",
1613
+ interval: "day",
1614
+ measure: money.key
1615
+ });
1616
+ }
1617
+ for (const subjectType of catalog.subjectTypes) {
1618
+ 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);
1619
+ if (stages.length < 2) continue;
1620
+ derived(`funnel: ${subjectType}`, "journeys", {
1621
+ // any source expands; the family the funnel is anchored on is the honest one
1622
+ source: { family: stages[0] },
1623
+ range: "30d",
1624
+ interval: "week",
1625
+ measure: "funnel",
1626
+ stages,
1627
+ anchor: stages[0],
1628
+ subjectType
639
1629
  });
640
1630
  }
641
1631
  return views;
642
1632
  }
643
1633
  async function resolveViews(opts) {
644
1634
  const byName = /* @__PURE__ */ new Map();
645
- for (const v of deriveViews(opts.registry)) byName.set(v.name, v);
1635
+ for (const v of deriveViews(opts.registry, opts.catalog)) byName.set(v.name, v);
646
1636
  for (const v of opts.configured) byName.set(v.name, { ...v, origin: "configured" });
647
1637
  const saved = await opts.ViewModel.find({
648
1638
  tenantId: opts.tenantId,
@@ -660,6 +1650,286 @@ async function resolveViews(opts) {
660
1650
  return [...byName.values()];
661
1651
  }
662
1652
 
1653
+ // src/server/suggest.ts
1654
+ var UNREGISTERED_REASON = "unregistered event";
1655
+ var MAX_SUGGESTIONS = 50;
1656
+ var NAME_MAX = 120;
1657
+ var quote = (s) => /^[A-Za-z0-9_.:$-]+$/.test(s) ? `'${s}'` : JSON.stringify(s);
1658
+ var prop = (s) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : quote(s);
1659
+ var times = (n) => `${n} time${n === 1 ? "" : "s"}`;
1660
+ var split = (k) => {
1661
+ const i = k.indexOf("|");
1662
+ return i === -1 ? [k, ""] : [k.slice(0, i), k.slice(i + 1)];
1663
+ };
1664
+ function deriveSuggestions(input) {
1665
+ const { counters, catalog, quarantine = [] } = input;
1666
+ const out = [];
1667
+ for (const [k, count2] of Object.entries(counters.undeclaredAttrs ?? {})) {
1668
+ if (k === COUNTER_OVERFLOW_KEY || !count2) continue;
1669
+ const [name, key] = split(k);
1670
+ if (!name || !key) continue;
1671
+ const facet = catalog.events[name];
1672
+ const line = `${prop(key)}: z.string().max(64),`;
1673
+ const hasAttrs = !!facet?.dims.some((d) => d.key.startsWith("attr:"));
1674
+ out.push({
1675
+ kind: "undeclared_attr",
1676
+ target: name,
1677
+ key,
1678
+ count: count2,
1679
+ message: `\`${name}\` has been sent with attr \`${key}\` ${times(count2)} \u2014 not declared`,
1680
+ fix: hasAttrs ? line : `attrs: z.object({ ${prop(key)}: z.string().max(64) }),`
1681
+ });
1682
+ }
1683
+ for (const [k, count2] of Object.entries(counters.rollupSkippedBy ?? {})) {
1684
+ if (k === COUNTER_OVERFLOW_KEY || !count2) continue;
1685
+ const [as, dim2] = split(k);
1686
+ if (!as || !dim2) continue;
1687
+ const feeders = catalog.families[as]?.feeders ?? [];
1688
+ const where = feeders.length ? `// on the \`${as}\` rollup of ${feeders.map((f) => `\`${f}\``).join(", ")}
1689
+ ` : "";
1690
+ out.push({
1691
+ kind: "missing_dim_default",
1692
+ target: as,
1693
+ key: dim2,
1694
+ count: count2,
1695
+ message: `\`${as}\` skipped ${count2} record${count2 === 1 ? "" : "s"} with no \`${dim2}\` \u2014 declare \`dimDefault\``,
1696
+ fix: `${where}dimDefault: 'unknown',`
1697
+ });
1698
+ }
1699
+ const unregistered = /* @__PURE__ */ new Map();
1700
+ for (const row of quarantine) {
1701
+ if (typeof row?.reason !== "string" || !row.reason.includes(UNREGISTERED_REASON)) continue;
1702
+ const name = typeof row.name === "string" ? row.name.slice(0, NAME_MAX) : "";
1703
+ if (!name || name === "(unnamed)") continue;
1704
+ unregistered.set(name, (unregistered.get(name) ?? 0) + 1);
1705
+ }
1706
+ for (const [name, count2] of unregistered) {
1707
+ out.push({
1708
+ kind: "unregistered_event",
1709
+ target: name,
1710
+ count: count2,
1711
+ message: `\`${name}\` was rejected ${times(count2)} \u2014 not in the registry`,
1712
+ // the minimum that boots: validateRegistry wants a kind, an origin, and
1713
+ // a subjects array, and nothing here can guess the rest
1714
+ fix: `${quote(name)}: { kind: 'event', origin: 'client', subjects: [], description: '' },`
1715
+ });
1716
+ }
1717
+ out.sort(
1718
+ (a, b) => b.count - a.count || a.target.localeCompare(b.target) || (a.key ?? "").localeCompare(b.key ?? "")
1719
+ );
1720
+ return out.slice(0, MAX_SUGGESTIONS);
1721
+ }
1722
+
1723
+ // src/server/values.ts
1724
+ var empty = (source) => ({
1725
+ values: [],
1726
+ source,
1727
+ truncated: false,
1728
+ dataSource: source
1729
+ });
1730
+ var SAMPLED_COUNT = { $sum: { $divide: [1, { $ifNull: ["$sampleRate", 1] }] } };
1731
+ function createValues(ctx) {
1732
+ const limits = { ...DEFAULT_LIMITS, ...ctx.limits };
1733
+ const slowMs = ctx.slowMs ?? 500;
1734
+ const cache = new QueryCache(ctx.cacheTtlMs ?? 10 * 6e4, ctx.cacheSize ?? 60);
1735
+ const { catalog } = ctx;
1736
+ const timed = async (op, params, run) => {
1737
+ const t0 = Date.now();
1738
+ try {
1739
+ return await run();
1740
+ } finally {
1741
+ const ms = Date.now() - t0;
1742
+ if (ms > slowMs) ctx.onSlowQuery?.({ op, ms, params });
1743
+ }
1744
+ };
1745
+ const eventNames = (names) => names?.length ? names.filter((n) => catalog.events[n]) : Object.keys(catalog.events);
1746
+ function fromCatalog(dim2, names) {
1747
+ const facets = [];
1748
+ for (const d of catalog.envelope) if (d.key === dim2) facets.push(d);
1749
+ for (const name of eventNames(names)) {
1750
+ for (const d of catalog.events[name].dims) if (d.key === dim2) facets.push(d);
1751
+ }
1752
+ const out = [];
1753
+ for (const f of facets) for (const v of f.values ?? []) if (!out.includes(v)) out.push(v);
1754
+ return out;
1755
+ }
1756
+ function pickFamily(dim2, names) {
1757
+ if (dim2 === "subjectType" || dim2 === "actorType") return null;
1758
+ 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));
1759
+ const best = matches[0];
1760
+ return best ? { as: best.f.as, index: best.index, label: best.f.labels[best.index] } : null;
1761
+ }
1762
+ function rawReadable(dim2, names) {
1763
+ try {
1764
+ dimExpression(dim2);
1765
+ } catch {
1766
+ return false;
1767
+ }
1768
+ if (!dim2.startsWith("attr:")) return true;
1769
+ const key = dim2.slice(5);
1770
+ return eventNames(names).some((n) => catalog.events[n].indexedAttrs.includes(key));
1771
+ }
1772
+ return async function values(scope, params) {
1773
+ const { dim: dim2, names, range } = params;
1774
+ if (!dim2) return empty("none");
1775
+ const cap = Math.min(Math.max(1, params.limit ?? limits.values), limits.values);
1776
+ const key = JSON.stringify([
1777
+ "values",
1778
+ scope,
1779
+ dim2,
1780
+ names ?? null,
1781
+ range?.from ?? null,
1782
+ range?.to ?? null,
1783
+ cap
1784
+ ]);
1785
+ return cache.get(
1786
+ key,
1787
+ () => timed("values", { scope, dim: dim2, names }, async () => {
1788
+ const declared = fromCatalog(dim2, names);
1789
+ if (declared.length) {
1790
+ return { values: declared, source: "catalog", truncated: false, dataSource: "catalog" };
1791
+ }
1792
+ const family = pickFamily(dim2, names);
1793
+ if (family) {
1794
+ const rows2 = await ctx.RollupModel.aggregate([
1795
+ {
1796
+ $match: {
1797
+ ...isPlatformScope(scope) ? {} : { tenantId: scope },
1798
+ as: family.as
1799
+ }
1800
+ },
1801
+ { $project: { v: { $arrayElemAt: ["$dims", family.index] }, count: 1 } },
1802
+ { $match: { v: { $type: "string" } } },
1803
+ { $group: { _id: "$v", count: { $sum: "$count" } } },
1804
+ { $sort: { count: -1, _id: 1 } },
1805
+ { $limit: cap + 1 }
1806
+ ]);
1807
+ const truncated2 = rows2.length > cap;
1808
+ if (truncated2) rows2.pop();
1809
+ const prefix = `${family.label}=`;
1810
+ return {
1811
+ values: rows2.map(
1812
+ (r) => String(r._id).startsWith(prefix) ? String(r._id).slice(prefix.length) : String(r._id)
1813
+ ),
1814
+ counts: rows2.map((r) => r.count),
1815
+ source: "rollups",
1816
+ via: family.as,
1817
+ truncated: truncated2,
1818
+ dataSource: "rollups"
1819
+ };
1820
+ }
1821
+ if (!rawReadable(dim2, names)) return empty("none");
1822
+ if (!range) return empty("none");
1823
+ const rows = await ctx.TelemetryModel.aggregate([
1824
+ { $match: buildMatch(scope, range, names?.length ? { name: names } : {}) },
1825
+ { $group: { _id: dimExpression(dim2), count: SAMPLED_COUNT } },
1826
+ // a "no value" is not a value to pick — the null group is real
1827
+ // (breakdown reports it) but it is not something a filter can name
1828
+ { $match: { _id: { $ne: null } } },
1829
+ { $sort: { count: -1, _id: 1 } },
1830
+ { $limit: cap + 1 }
1831
+ ]);
1832
+ const truncated = rows.length > cap;
1833
+ if (truncated) rows.pop();
1834
+ return {
1835
+ values: rows.map((r) => String(r._id)),
1836
+ counts: rows.map((r) => r.count),
1837
+ source: "raw",
1838
+ truncated,
1839
+ dataSource: "raw"
1840
+ };
1841
+ })
1842
+ );
1843
+ };
1844
+ }
1845
+
1846
+ // src/server/execute.ts
1847
+ async function executeReport(q, scope, report, catalog, opts = {}) {
1848
+ const plan = resolveReport(report, catalog, { now: opts.now, limits: opts.limits });
1849
+ if ("unavailable" in plan) throw Object.assign(new Error(plan.why), { status: 400 });
1850
+ const run = async (args) => {
1851
+ const raw = await q[plan.primitive](scope, ...args);
1852
+ if (plan.primitive === "rollups" && plan.shape) {
1853
+ return foldRollups(raw?.rows ?? [], plan.shape, !!raw?.truncated);
1854
+ }
1855
+ if (plan.primitive === "records" && opts.redact) {
1856
+ return { ...raw, items: opts.redact(raw?.items ?? []) };
1857
+ }
1858
+ return raw;
1859
+ };
1860
+ const [result, previous] = await Promise.all([
1861
+ run(plan.args),
1862
+ plan.previous ? run(plan.previous.args) : Promise.resolve(void 0)
1863
+ ]);
1864
+ return {
1865
+ report,
1866
+ plan,
1867
+ result,
1868
+ ...plan.previous ? { previous } : {},
1869
+ dataSource: result?.dataSource ?? "raw"
1870
+ };
1871
+ }
1872
+ var MEASURE_OP2 = /^(sum|avg):(.+)$/;
1873
+ function foldRollups(rows, shape, truncated = false) {
1874
+ const op = MEASURE_OP2.exec(shape.measure);
1875
+ const groups = /* @__PURE__ */ new Map();
1876
+ for (const doc of rows) {
1877
+ const dims = doc?.dims ?? [];
1878
+ if (!(shape.filters ?? []).every((f) => admits(f, dimValue(dims, f.label)))) continue;
1879
+ const tuple = shape.labels.map((label2) => dimValue(dims, label2));
1880
+ const at = shape.interval && doc.bucketAt ? truncate(new Date(doc.bucketAt), shape.interval) : void 0;
1881
+ const key = `${JSON.stringify(tuple)}|${at ? at.getTime() : ""}`;
1882
+ let g = groups.get(key);
1883
+ if (!g) groups.set(key, g = { dims: tuple, ...at ? { at } : {}, sum: 0, count: 0 });
1884
+ g.count += typeof doc.count === "number" ? doc.count : 0;
1885
+ if (op) g.sum += sumOf(doc.sums, op[2]);
1886
+ }
1887
+ const rowsOut = [...groups.values()].map((g) => ({
1888
+ dims: g.dims,
1889
+ ...g.at ? { at: g.at } : {},
1890
+ // avg is sums[k]/count off the SAME doc, which is exact — not an average of
1891
+ // averages, which is what folding a per-bucket mean would have produced
1892
+ value: !op ? g.count : op[1] === "sum" ? g.sum : g.count ? g.sum / g.count : 0
1893
+ }));
1894
+ rowsOut.sort(
1895
+ shape.interval ? (a, b) => (a.at?.getTime() ?? 0) - (b.at?.getTime() ?? 0) || byDims(a, b) : (a, b) => b.value - a.value || byDims(a, b)
1896
+ );
1897
+ return {
1898
+ rows: rowsOut,
1899
+ groups: new Set([...groups.values()].map((g) => JSON.stringify(g.dims))).size,
1900
+ truncated,
1901
+ dataSource: "rollups"
1902
+ };
1903
+ }
1904
+ function dimValue(dims, label2) {
1905
+ const prefix = `${label2}=`;
1906
+ for (const d of dims) if (d.startsWith(prefix)) return d.slice(prefix.length);
1907
+ for (const d of dims) if (!d.includes("=")) return d;
1908
+ return null;
1909
+ }
1910
+ function admits(f, value) {
1911
+ if (f.op === "in") return f.value.map(String).includes(String(value));
1912
+ if (f.op === "gte" || f.op === "lte") {
1913
+ const n = Number(value);
1914
+ if (Number.isNaN(n)) return false;
1915
+ return f.op === "gte" ? n >= Number(f.value) : n <= Number(f.value);
1916
+ }
1917
+ return String(value) === String(f.value);
1918
+ }
1919
+ function sumOf(sums, key) {
1920
+ if (!sums) return 0;
1921
+ const v = sums instanceof Map ? sums.get(key) : sums[key];
1922
+ return typeof v === "number" ? v : 0;
1923
+ }
1924
+ function byDims(a, b) {
1925
+ for (let i = 0; i < a.dims.length; i++) {
1926
+ const x = a.dims[i] ?? "";
1927
+ const y = b.dims[i] ?? "";
1928
+ if (x !== y) return x < y ? -1 : 1;
1929
+ }
1930
+ return 0;
1931
+ }
1932
+
663
1933
  // src/server/mcp.ts
664
1934
  var tenantArg = {
665
1935
  tenant: zod.z.string().optional().describe(
@@ -672,7 +1942,9 @@ var rangeArg = {
672
1942
  };
673
1943
  var filterArg = {
674
1944
  kind: zod.z.enum(["event", "error", "span", "state", "usage"]).optional(),
675
- name: zod.z.string().optional().describe('exact event name, e.g. "user.signed_up" \u2014 see describe_telemetry'),
1945
+ name: zod.z.union([zod.z.string(), zod.z.array(zod.z.string())]).optional().describe(
1946
+ 'exact event name, e.g. "user.signed_up" \u2014 or several as an array, read as one $in. See describe_telemetry'
1947
+ ),
676
1948
  severity: zod.z.string().optional(),
677
1949
  env: zod.z.string().optional().describe("prod | staging | dev"),
678
1950
  service: zod.z.string().optional(),
@@ -700,6 +1972,38 @@ function parseRange(from, to) {
700
1972
  return { from: fromD, to: toD };
701
1973
  }
702
1974
  var INTERVAL = zod.z.enum(["hour", "day", "week", "month"]);
1975
+ var REPORT_ARG = zod.z.object({
1976
+ source: zod.z.union([
1977
+ zod.z.object({ event: zod.z.string() }),
1978
+ zod.z.object({ namespace: zod.z.string() }),
1979
+ zod.z.object({ kind: zod.z.enum(["event", "error", "span", "state", "usage"]) }),
1980
+ zod.z.object({ family: zod.z.string() })
1981
+ ]).describe(
1982
+ 'what is being counted: { event: "llm.completion" } one registered name, { namespace: "billing" } every name under it, { kind: "error" }, or { family: "llm_cost" } to read a rollup family directly'
1983
+ ),
1984
+ range: zod.z.union([zod.z.string(), zod.z.object({ from: zod.z.string(), to: zod.z.string() })]).describe('"7d" / "24h" / "90d", or an explicit half-open { from, to } ISO pair'),
1985
+ interval: INTERVAL.optional().describe("bucket the answer over time"),
1986
+ measure: zod.z.string().optional().describe(
1987
+ 'a measure key from the catalog: "count" (default), "sum:<metric>", "avg:<metric>", "p50|p90|p95|p99:<metric>", "distinct:<subjectType>" for exact actives, or "funnel"'
1988
+ ),
1989
+ groupBy: zod.z.array(zod.z.string()).max(2).optional().describe('at most two dims \u2014 "attr:<key>", "field:<path>", "subjectType", "actorType"'),
1990
+ filters: zod.z.array(
1991
+ zod.z.object({
1992
+ dim: zod.z.string().describe('a dim key, e.g. "attr:gen_ai_request_model" or "field:env"'),
1993
+ op: zod.z.enum(["eq", "in", "gte", "lte"]).describe("gte/lte bound a declared metric only"),
1994
+ value: zod.z.union([zod.z.string(), zod.z.array(zod.z.string()), zod.z.number()])
1995
+ })
1996
+ ).optional(),
1997
+ excludeActorTypes: zod.z.array(zod.z.string()).optional().describe('the customer toggle, e.g. ["admin","system"] \u2014 a record with no actor always survives'),
1998
+ sort: zod.z.enum(["value", "label", "time"]).optional(),
1999
+ limit: zod.z.number().int().positive().optional(),
2000
+ compare: zod.z.literal("previous").optional().describe("also run the window immediately before, same length"),
2001
+ // ── funnel only (`measure: "funnel"`) ──
2002
+ stages: zod.z.array(zod.z.string()).optional().describe("lifetime by:['subject'] rollup families, in order"),
2003
+ anchor: zod.z.string().optional().describe("the milestone that assigns cohort membership. Default: stages[0]"),
2004
+ exits: zod.z.array(zod.z.string()).optional().describe("families counted but never staged"),
2005
+ subjectType: zod.z.string().optional()
2006
+ });
703
2007
  function createTelemetryMcp(opts) {
704
2008
  const { telemetry: t, viewerAdapter, subjectAdapter, configured = [] } = opts;
705
2009
  if (!viewerAdapter?.resolveViewer) {
@@ -719,6 +2023,16 @@ function createTelemetryMcp(opts) {
719
2023
  registry: t.registry,
720
2024
  limits: opts.limits
721
2025
  });
2026
+ const catalog = deriveCatalog(t.registry, {
2027
+ platforms: t.models.telemetry.schema.path("client")?.schema?.path("platform")?.enumValues
2028
+ });
2029
+ const registry = projectRegistry(catalog);
2030
+ const values = createValues({
2031
+ catalog,
2032
+ TelemetryModel: t.models.telemetry,
2033
+ RollupModel: t.models.rollups,
2034
+ limits: opts.limits
2035
+ });
722
2036
  const ViewModel = buildViewModel(
723
2037
  t.models.telemetry.db,
724
2038
  `${t.models.telemetry.modelName}View`,
@@ -743,17 +2057,45 @@ function createTelemetryMcp(opts) {
743
2057
  if (!refs.size) return {};
744
2058
  return subjectAdapter.describe([...refs].slice(0, 100));
745
2059
  }
2060
+ async function pickReport(scope, viewer, a) {
2061
+ const override = a.from || a.to ? parseRange(a.from, a.to) : null;
2062
+ const withRange = (r) => override ? { ...r, range: { from: override.from.toISOString(), to: override.to.toISOString() } } : r;
2063
+ if (a.report) return { report: withRange(a.report) };
2064
+ if (!a.name) {
2065
+ throw new Error("needs either `name` (a report from list_reports) or an inline `report` object");
2066
+ }
2067
+ const views = await resolveViews({
2068
+ ViewModel,
2069
+ registry: t.registry,
2070
+ catalog,
2071
+ configured,
2072
+ tenantId: scope,
2073
+ viewerRef: viewer.viewerRef
2074
+ });
2075
+ const view = views.find((v) => v.name === a.name);
2076
+ if (!view) throw new Error(`no report named "${a.name}" \u2014 call list_reports for the menu`);
2077
+ const report = normalizeQuery(view.query);
2078
+ if (report) return { name: view.name, report: withRange(report) };
2079
+ const query = view.query ?? {};
2080
+ return {
2081
+ legacy: true,
2082
+ name: view.name,
2083
+ range: override ?? rangeFromView(query.range),
2084
+ filters: query.filters ?? {}
2085
+ };
2086
+ }
2087
+ const planShaped = (report) => resolveReport(report, catalog);
746
2088
  const tool = (d) => d;
747
2089
  const tools = [
748
2090
  // ── vocabulary ──────────────────────────────────────────────────────────
749
2091
  tool({
750
2092
  name: "describe_telemetry",
751
2093
  title: "Describe telemetry schema",
752
- description: "The vocabulary of this telemetry instance: every event name with its kind, declared attributes, metrics, indexed filters, and rollup families. CALL THIS FIRST \u2014 every other tool speaks the names it returns.",
2094
+ description: "The vocabulary of this telemetry instance: every event name with its kind, declared attributes, metrics, indexed filters, and rollup families. CALL THIS FIRST \u2014 every other tool speaks the names it returns. Prefer the `catalog` half of the answer over `registry`: it types every dimension, gives the closed value domain of the ones that have one, marks which are indexed (cheap) rather than a scan, lists the measures each event can be aggregated by, and says which rollup family answers a sum exactly.",
753
2095
  inputSchema: zod.z.object({}),
754
2096
  async handler(_args, ctx) {
755
2097
  await resolve(ctx);
756
- return { registry: registryProjection(t), kinds: ["event", "error", "span", "state", "usage"] };
2098
+ return { registry, catalog, kinds: ["event", "error", "span", "state", "usage"] };
757
2099
  }
758
2100
  }),
759
2101
  // ── events ──────────────────────────────────────────────────────────────
@@ -825,6 +2167,30 @@ function createTelemetryMcp(opts) {
825
2167
  });
826
2168
  }
827
2169
  }),
2170
+ tool({
2171
+ name: "event_breakdown",
2172
+ title: "Breakdown by dimension",
2173
+ description: "Top groups of a measure by one or two dimensions \u2014 'which models cost the most', 'errors by release', 'events by platform per week'. groupBy takes attr:<key>, field:<path>, subjectType, actorType; see describe_telemetry for the keys. Reports `truncated` when more groups existed than were returned.",
2174
+ inputSchema: zod.z.object({
2175
+ ...filterArg,
2176
+ ...rangeArg,
2177
+ ...tenantArg,
2178
+ groupBy: zod.z.array(zod.z.string()).min(1).max(2).describe('one or two dimensions, e.g. ["attr:gen_ai_request_model"] or ["attr:feature","field:env"]'),
2179
+ measure: zod.z.string().optional().describe('"count" (default), or "sum:<metric>" / "avg:<metric>", e.g. "sum:cost_usd"'),
2180
+ interval: INTERVAL.optional().describe("also split each group by time bucket; rows then carry `at`"),
2181
+ limit: zod.z.number().int().positive().optional().describe("groups returned, not rows scanned")
2182
+ }),
2183
+ async handler(a, ctx) {
2184
+ const viewer = await resolve(ctx);
2185
+ const scope = pickScope(viewer, a.tenant);
2186
+ return q.breakdown(scope, parseRange(a.from, a.to), toFilter(a), {
2187
+ groupBy: a.groupBy,
2188
+ measure: a.measure,
2189
+ interval: a.interval,
2190
+ limit: a.limit
2191
+ });
2192
+ }
2193
+ }),
828
2194
  tool({
829
2195
  name: "metric_distribution",
830
2196
  title: "Metric distribution (percentiles)",
@@ -870,6 +2236,30 @@ function createTelemetryMcp(opts) {
870
2236
  });
871
2237
  }
872
2238
  }),
2239
+ tool({
2240
+ name: "dimension_values",
2241
+ title: "Dimension values",
2242
+ description: "The values a dimension actually takes \u2014 from the registry's enum when it has one, else from what the rollups have seen, else from a raw scan over a range. Use before filtering or grouping so you name a value that exists. `source` says where the answer came from.",
2243
+ inputSchema: zod.z.object({
2244
+ dim: zod.z.string().describe('a dimension key from describe_telemetry: "attr:<key>", "field:<path>", "subjectType" or "actorType"'),
2245
+ names: zod.z.array(zod.z.string()).optional().describe("restrict to these event names \u2014 required for the raw scan, a narrowing hint otherwise"),
2246
+ ...rangeArg,
2247
+ ...tenantArg,
2248
+ limit: zod.z.number().int().positive().optional()
2249
+ }),
2250
+ async handler(a, ctx) {
2251
+ const viewer = await resolve(ctx);
2252
+ const scope = pickScope(viewer, a.tenant);
2253
+ return values(scope, {
2254
+ dim: a.dim,
2255
+ names: a.names,
2256
+ // no range is not an error here: it just means the raw step is off
2257
+ // the table, and `source: 'none'` says so
2258
+ range: a.from || a.to ? parseRange(a.from, a.to) : void 0,
2259
+ limit: a.limit
2260
+ });
2261
+ }
2262
+ }),
873
2263
  tool({
874
2264
  name: "active_users",
875
2265
  title: "Active users (DAU/WAU/MAU)",
@@ -968,6 +2358,7 @@ function createTelemetryMcp(opts) {
968
2358
  const views = await resolveViews({
969
2359
  ViewModel,
970
2360
  registry: t.registry,
2361
+ catalog,
971
2362
  configured,
972
2363
  tenantId: scope,
973
2364
  viewerRef: viewer.viewerRef
@@ -977,26 +2368,52 @@ function createTelemetryMcp(opts) {
977
2368
  }),
978
2369
  tool({
979
2370
  name: "run_report",
980
- title: "Run a saved report",
981
- description: "Execute one named report from list_reports and return its result. Read-only: the report is a stored filter/range that dispatches to the same query the dashboard would run.",
2371
+ title: "Run a report",
2372
+ description: 'Execute a report and return its answer. Two ways in: `name`, one of the reports list_reports offers, or `report`, an inline Report you compose yourself \u2014 the general "ask telemetry a question" tool. A Report says WHAT is counted (`source`), over what range, sliced by which dimensions (`groupBy`) and by what measure; the planner then picks the primitive that answers it, preferring a pre-aggregated rollup family (exact, one indexed read) over a raw scan. The answer carries the `plan` it ran, including `exactness` (exact / raw / scan) and a `why` sentence \u2014 read them, and prefer an `exact` plan when one exists. If you are unsure a report is answerable or affordable, call plan_report first: it returns the same plan without doing the read. Names and dimensions come from describe_telemetry; `from`/`to` override the report\'s own range. Raw records are redacted like search_events.',
982
2373
  inputSchema: zod.z.object({
983
- name: zod.z.string().describe("the report name from list_reports"),
2374
+ name: zod.z.string().optional().describe("a report name from list_reports \u2014 or pass `report` instead"),
2375
+ report: REPORT_ARG.optional().describe("an inline Report, composed from describe_telemetry's catalog"),
984
2376
  ...rangeArg,
985
2377
  ...tenantArg
986
2378
  }),
987
2379
  async handler(a, ctx) {
988
2380
  const viewer = await resolve(ctx);
989
2381
  const scope = pickScope(viewer, a.tenant);
990
- const views = await resolveViews({
991
- ViewModel,
992
- registry: t.registry,
993
- configured,
994
- tenantId: scope,
995
- viewerRef: viewer.viewerRef
996
- });
997
- const view = views.find((v) => v.name === a.name);
998
- if (!view) throw new Error(`no report named "${a.name}" \u2014 call list_reports for the menu`);
999
- return runReport(q, scope, view, a.from, a.to, redactAll);
2382
+ const picked = await pickReport(scope, viewer, a);
2383
+ if ("legacy" in picked) {
2384
+ const res = await q.records(scope, picked.range, toFilter(picked.filters), { limit: 200 });
2385
+ return {
2386
+ name: picked.name,
2387
+ legacy: true,
2388
+ result: { ...res, items: redactAll(res.items) },
2389
+ dataSource: "raw"
2390
+ };
2391
+ }
2392
+ const out = await executeReport(q, scope, picked.report, catalog, { redact: redactAll });
2393
+ return picked.name ? { name: picked.name, ...out } : out;
2394
+ }
2395
+ }),
2396
+ tool({
2397
+ name: "plan_report",
2398
+ title: "Plan a report (dry run)",
2399
+ description: "Ask what a report WOULD cost before you spend the read. Same input as run_report, no query: it returns the plan \u2014 which primitive answers it, with what arguments, and `exactness` (`exact` = maintained rollup docs, `raw` = an indexed scan of records, `scan` = a dimension with no index behind it) \u2014 or `{ unavailable: true, why }` when nothing can answer it. A refusal is an answer, not an error: the `why` names the offending key and the registry change that would make the question answerable, so use it to pick a different measure or dimension rather than retrying the same one.",
2400
+ inputSchema: zod.z.object({
2401
+ name: zod.z.string().optional().describe("a report name from list_reports \u2014 or pass `report` instead"),
2402
+ report: REPORT_ARG.optional().describe("an inline Report, composed from describe_telemetry's catalog"),
2403
+ ...rangeArg,
2404
+ ...tenantArg
2405
+ }),
2406
+ async handler(a, ctx) {
2407
+ const viewer = await resolve(ctx);
2408
+ const scope = pickScope(viewer, a.tenant);
2409
+ const picked = await pickReport(scope, viewer, a);
2410
+ if ("legacy" in picked) {
2411
+ return {
2412
+ unavailable: true,
2413
+ why: `the stored report "${picked.name}" is a pre-Report view whose query names no source, so there is nothing to plan \u2014 run_report reads its records directly. Compose an inline \`report\` to plan one`
2414
+ };
2415
+ }
2416
+ return picked.name ? { name: picked.name, ...planShaped(picked.report) } : planShaped(picked.report);
1000
2417
  }
1001
2418
  }),
1002
2419
  // ── platform ────────────────────────────────────────────────────────────
@@ -1044,62 +2461,35 @@ function createTelemetryMcp(opts) {
1044
2461
  tool({
1045
2462
  name: "telemetry_health",
1046
2463
  title: "Telemetry health",
1047
- description: 'The health of the telemetry pipeline itself: drop/default/cap counters, quarantined failed writes, and the index budget. Answers "are we silently dropping events?".',
2464
+ description: 'The health of the telemetry pipeline itself: drop/default/cap counters, quarantined failed writes, and the index budget. Answers "are we silently dropping events?" \u2014 and what the data says the registry is missing.',
1048
2465
  inputSchema: zod.z.object({ ...tenantArg }),
1049
2466
  async handler(a, ctx) {
1050
2467
  const viewer = await resolve(ctx);
1051
2468
  const scope = pickScope(viewer, a.tenant);
1052
2469
  const quarantine = await t.collections.rejects().find(isPlatformScope(scope) ? {} : { "raw.tenantId": scope }, { sort: { at: -1 }, limit: 50 }).toArray().catch(() => []);
1053
2470
  const indexes = await t.models.telemetry.collection.indexes().catch(() => []);
1054
- return { counters: t.counters, quarantine, indexCount: indexes.length };
2471
+ return {
2472
+ counters: t.counters,
2473
+ quarantine,
2474
+ indexCount: indexes.length,
2475
+ suggestions: deriveSuggestions({ counters: t.counters, catalog, quarantine })
2476
+ };
1055
2477
  }
1056
2478
  })
1057
2479
  ];
1058
2480
  return tools;
1059
2481
  }
1060
2482
  var toJsonSchema = (tool) => zod.z.toJSONSchema(tool.inputSchema);
1061
- function registryProjection(t) {
1062
- return Object.fromEntries(
1063
- Object.entries(t.registry).map(([name, spec]) => [
1064
- name,
1065
- {
1066
- kind: spec.kind,
1067
- description: spec.description,
1068
- attrKeys: spec.attrs ? Object.keys(spec.attrs.shape) : [],
1069
- metricKeys: spec.metrics ? Object.keys(spec.metrics.shape) : [],
1070
- indexedAttrs: spec.indexedAttrs ?? [],
1071
- indexedMetrics: spec.indexedMetrics ?? [],
1072
- rollups: (spec.rollups ?? []).map((r) => ({
1073
- as: r.as ?? name,
1074
- by: r.by,
1075
- bucket: r.bucket ?? null
1076
- }))
1077
- }
1078
- ])
1079
- );
1080
- }
1081
2483
  function reportSummary(v) {
2484
+ const report = normalizeQuery(v.query);
1082
2485
  return {
1083
2486
  name: v.name,
1084
2487
  origin: v.origin,
1085
2488
  page: v.page,
1086
2489
  shared: v.shared,
1087
- display: v.query?.display
2490
+ ...report ? { source: report.source } : {}
1088
2491
  };
1089
2492
  }
1090
- async function runReport(q, scope, view, from, to, redactAll) {
1091
- const query = view.query ?? {};
1092
- const filters = query.filters ?? {};
1093
- const range = from || to ? parseRange(from, to) : rangeFromView(query.range);
1094
- if (filters.rollup) {
1095
- return { report: view.name, result: await q.rollups(scope, { as: filters.rollup, range }) };
1096
- }
1097
- if (query.display === "series") {
1098
- return { report: view.name, result: await q.series(scope, range, toFilter(filters)) };
1099
- }
1100
- const res = await q.records(scope, range, toFilter(filters), { limit: 200 });
1101
- return { report: view.name, result: { ...res, items: redactAll(res.items) } };
1102
- }
1103
2493
  function rangeFromView(range) {
1104
2494
  const to = /* @__PURE__ */ new Date();
1105
2495
  const m = /^(\d+)([dh])$/.exec(String(range ?? "7d"));