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