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