@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/README.md +3 -1
- package/dist/index.cjs +1437 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1423 -64
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs +1468 -78
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.js +1468 -78
- package/dist/mcp.js.map +1 -1
- package/dist/ui/_assets/{index-CXiDp_v6.css → index-COswHpSX.css} +1 -1
- package/dist/ui/_assets/index-Deqhu-hT.js +41 -0
- package/dist/ui/_assets/index-Deqhu-hT.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/types/index.d.ts +484 -11
- package/types/test-d.ts +173 -2
- package/dist/ui/_assets/index-3jcToTuP.js +0 -41
- package/dist/ui/_assets/index-3jcToTuP.js.map +0 -1
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
|
-
|
|
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
|
|
624
|
-
for (const [name,
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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(
|
|
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
|
|
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
|
|
981
|
-
description:
|
|
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("
|
|
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
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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 {
|
|
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
|
-
|
|
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"));
|