@jeffjassky/telemetry 0.1.0 → 0.2.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 +1 -1
- package/dist/mcp-sdk.cjs +19 -0
- package/dist/mcp-sdk.cjs.map +1 -0
- package/dist/mcp-sdk.js +17 -0
- package/dist/mcp-sdk.js.map +1 -0
- package/dist/mcp.cjs +1114 -0
- package/dist/mcp.cjs.map +1 -0
- package/dist/mcp.js +1111 -0
- package/dist/mcp.js.map +1 -0
- package/package.json +11 -1
- package/types/mcp-sdk.d.ts +17 -0
- package/types/mcp.d.ts +44 -0
- package/types/test-d.ts +29 -0
package/dist/mcp.cjs
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var zod = require('zod');
|
|
4
|
+
require('uuidv7');
|
|
5
|
+
var mongoose = require('mongoose');
|
|
6
|
+
|
|
7
|
+
// src/server/mcp.ts
|
|
8
|
+
var PLATFORM_SCOPE = "*";
|
|
9
|
+
var isPlatformScope = (tenantId) => tenantId === PLATFORM_SCOPE;
|
|
10
|
+
var truncate = (d, b) => {
|
|
11
|
+
if (!b) return void 0;
|
|
12
|
+
if (b === "hour") return new Date(Math.floor(d.getTime() / 36e5) * 36e5);
|
|
13
|
+
const y = d.getUTCFullYear();
|
|
14
|
+
const m = d.getUTCMonth();
|
|
15
|
+
if (b === "month") return new Date(Date.UTC(y, m, 1));
|
|
16
|
+
const day = new Date(Date.UTC(y, m, d.getUTCDate()));
|
|
17
|
+
if (b === "day") return day;
|
|
18
|
+
return new Date(day.getTime() - (day.getUTCDay() + 6) % 7 * 864e5);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/server/funnel.ts
|
|
22
|
+
var DAY_MS = 864e5;
|
|
23
|
+
function median(values) {
|
|
24
|
+
if (values.length === 0) return null;
|
|
25
|
+
const s = [...values].sort((a, b) => a - b);
|
|
26
|
+
const mid = s.length >> 1;
|
|
27
|
+
return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
28
|
+
}
|
|
29
|
+
function summarizeStages(subjects, stages) {
|
|
30
|
+
const firstKey = stages[0]?.key;
|
|
31
|
+
const first = firstKey ? subjects.filter((s) => s.stages[firstKey]).length : 0;
|
|
32
|
+
return stages.map((st, i) => {
|
|
33
|
+
const prev = i > 0 ? stages[i - 1] : null;
|
|
34
|
+
const next = i < stages.length - 1 ? stages[i + 1] : null;
|
|
35
|
+
const reached = [];
|
|
36
|
+
const fromAnchor = [];
|
|
37
|
+
for (const s of subjects) {
|
|
38
|
+
const at = s.stages[st.key];
|
|
39
|
+
if (!at) continue;
|
|
40
|
+
reached.push(s);
|
|
41
|
+
if (s.anchorAt) fromAnchor.push((at.getTime() - s.anchorAt.getTime()) / DAY_MS);
|
|
42
|
+
}
|
|
43
|
+
let prevReached = 0;
|
|
44
|
+
let notReached = 0;
|
|
45
|
+
const fromPrevious = [];
|
|
46
|
+
if (prev) {
|
|
47
|
+
for (const s of subjects) {
|
|
48
|
+
const p = s.stages[prev.key];
|
|
49
|
+
if (!p) continue;
|
|
50
|
+
prevReached += 1;
|
|
51
|
+
const cur = s.stages[st.key];
|
|
52
|
+
if (cur) fromPrevious.push((cur.getTime() - p.getTime()) / DAY_MS);
|
|
53
|
+
else notReached += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
order: st.order,
|
|
58
|
+
key: st.key,
|
|
59
|
+
as: st.as,
|
|
60
|
+
label: st.label,
|
|
61
|
+
...st.description ? { description: st.description } : {},
|
|
62
|
+
subjects: reached.length,
|
|
63
|
+
pctOfFirst: first > 0 ? reached.length / first * 100 : null,
|
|
64
|
+
pctOfPrevious: prev ? prevReached > 0 ? reached.length / prevReached * 100 : null : null,
|
|
65
|
+
medianDaysFromAnchor: median(fromAnchor),
|
|
66
|
+
medianDaysFromPrevious: prev ? median(fromPrevious) : null,
|
|
67
|
+
notReached,
|
|
68
|
+
// the divergence: maxed's `!next` clause makes every subject that reached
|
|
69
|
+
// the terminal stage "stuck" there. null says "undefined", which is true.
|
|
70
|
+
stalledAt: next ? subjects.filter((s) => s.stages[st.key] && !s.stages[next.key]).length : null
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function findFamily(registry, as) {
|
|
75
|
+
for (const [name, s] of Object.entries(registry)) {
|
|
76
|
+
for (const r of s.rollups ?? []) {
|
|
77
|
+
if ((r.as ?? name) === as) return { name, spec: r };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function requireMilestoneFamily(registry, as, primitive) {
|
|
83
|
+
const found = findFamily(registry, as);
|
|
84
|
+
if (!found) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`telemetry: ${primitive} \u2014 no rollup family "${as}" is declared. Add a \`rollups: [{ as: '${as}', by: ['subject'], subjects: [...] }]\` block to the event that marks it.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const { name, spec } = found;
|
|
90
|
+
const shape = `by: [${spec.by.map((d) => `'${d}'`).join(", ")}]${spec.bucket ? `, bucket: '${spec.bucket}'` : ""}`;
|
|
91
|
+
if (spec.bucket) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`telemetry: ${primitive} \u2014 rollup family "${as}" (declared on "${name}") 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.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (spec.by.length !== 1 || spec.by[0] !== "subject") {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`telemetry: ${primitive} \u2014 rollup family "${as}" (declared on "${name}") 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.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return spec;
|
|
102
|
+
}
|
|
103
|
+
function cohortWindow(c) {
|
|
104
|
+
const endInclusive = c.endInclusive === true;
|
|
105
|
+
return { from: c.from, to: endInclusive ? new Date(c.to.getTime() + 1) : c.to, endInclusive };
|
|
106
|
+
}
|
|
107
|
+
async function runFunnel(ctx, scope, params) {
|
|
108
|
+
if (!params.stages?.length) throw new Error("telemetry: funnel() needs at least one stage");
|
|
109
|
+
const stages = params.stages.map((s, i) => ({
|
|
110
|
+
order: i + 1,
|
|
111
|
+
key: s.key ?? s.as,
|
|
112
|
+
as: s.as,
|
|
113
|
+
label: s.label ?? s.key ?? s.as,
|
|
114
|
+
...s.description ? { description: s.description } : {}
|
|
115
|
+
}));
|
|
116
|
+
const exits = (params.exits ?? []).map((s, i) => ({
|
|
117
|
+
order: i + 1,
|
|
118
|
+
key: s.key ?? s.as,
|
|
119
|
+
as: s.as,
|
|
120
|
+
label: s.label ?? s.key ?? s.as
|
|
121
|
+
}));
|
|
122
|
+
const anchor = params.anchor ?? stages[0].as;
|
|
123
|
+
requireMilestoneFamily(ctx.registry, anchor, "funnel()");
|
|
124
|
+
for (const s of [...stages, ...exits]) requireMilestoneFamily(ctx.registry, s.as, "funnel()");
|
|
125
|
+
const { from, to, endInclusive } = cohortWindow(params.cohort);
|
|
126
|
+
const cap = Math.min(Math.max(1, params.limit ?? ctx.cohortCap), ctx.cohortCap);
|
|
127
|
+
const cohortMatch = {
|
|
128
|
+
...ctx.scopeMatch(scope),
|
|
129
|
+
as: anchor,
|
|
130
|
+
// AMBIGUOUS-2 (cohort-math): maxed's cohort read overwrites per row with no
|
|
131
|
+
// ordering, so its `signupAt` is whichever row Mongo returned last —
|
|
132
|
+
// nondeterministic. Unreachable here by construction: the rollup's `firstAt`
|
|
133
|
+
// is a `$min` maintained on write, so the anchor timestamp is the EARLIEST
|
|
134
|
+
// occurrence, always, matching the tie-break rule maxed uses everywhere else
|
|
135
|
+
// (R12). We take min(at) and the storage enforces it.
|
|
136
|
+
firstAt: { $gte: from, $lt: to }
|
|
137
|
+
};
|
|
138
|
+
if (params.subjectType) cohortMatch.subjectType = params.subjectType;
|
|
139
|
+
const cohortRows = await ctx.RollupModel.find(cohortMatch).sort({ firstAt: 1 }).limit(cap + 1).lean();
|
|
140
|
+
const truncated = cohortRows.length > cap;
|
|
141
|
+
if (truncated) cohortRows.pop();
|
|
142
|
+
const keyOf = (r) => `${r.tenantId ?? ""}|${r.dims?.[0]}`;
|
|
143
|
+
const index = /* @__PURE__ */ new Map();
|
|
144
|
+
const refs = /* @__PURE__ */ new Set();
|
|
145
|
+
const tenants = /* @__PURE__ */ new Set();
|
|
146
|
+
for (const r of cohortRows) {
|
|
147
|
+
const ref = r.dims?.[0];
|
|
148
|
+
if (!ref) continue;
|
|
149
|
+
refs.add(ref);
|
|
150
|
+
tenants.add(String(r.tenantId ?? ""));
|
|
151
|
+
index.set(keyOf(r), { ref, anchorAt: r.firstAt, stages: {}, exits: {} });
|
|
152
|
+
}
|
|
153
|
+
if (refs.size) {
|
|
154
|
+
const byAs = /* @__PURE__ */ new Map();
|
|
155
|
+
for (const s of [...stages, ...exits]) {
|
|
156
|
+
const list = byAs.get(s.as) ?? [];
|
|
157
|
+
list.push(s);
|
|
158
|
+
byAs.set(s.as, list);
|
|
159
|
+
}
|
|
160
|
+
const stageRows = await ctx.RollupModel.find({
|
|
161
|
+
...ctx.scopeMatch(scope),
|
|
162
|
+
as: { $in: [...byAs.keys()] },
|
|
163
|
+
dims: { $in: [...refs] },
|
|
164
|
+
// AMBIGUOUS-1 (cohort-math): maxed collects stages with `at >= cohortStart`
|
|
165
|
+
// and no upper bound. We keep the lower bound — reading (a), as-written.
|
|
166
|
+
// Reasons: (1) it is what maxed does, and an equivalence test against a
|
|
167
|
+
// table computed under the other reading would prove nothing; (2) over
|
|
168
|
+
// rollup storage the predicate reads "first reached no earlier than the
|
|
169
|
+
// cohort opened", which is the only reading under which a cohort's funnel
|
|
170
|
+
// is a function of its own window — drop the bound and a backfill dated
|
|
171
|
+
// before the window silently adds stages to a report already published.
|
|
172
|
+
// No UPPER bound, deliberately: a conversion landing months after the
|
|
173
|
+
// window still belongs to its cohort (R7).
|
|
174
|
+
firstAt: { $gte: from }
|
|
175
|
+
}).limit(refs.size * tenants.size * byAs.size + 1).lean();
|
|
176
|
+
for (const r of stageRows) {
|
|
177
|
+
const subject = index.get(keyOf(r));
|
|
178
|
+
if (!subject) continue;
|
|
179
|
+
for (const s of byAs.get(r.as) ?? []) {
|
|
180
|
+
const bag = exits.includes(s) ? subject.exits : subject.stages;
|
|
181
|
+
const prevAt = bag[s.key];
|
|
182
|
+
if (!prevAt || r.firstAt < prevAt) bag[s.key] = r.firstAt;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const subjects = [...index.values()];
|
|
187
|
+
const summary = summarizeStages(subjects, stages);
|
|
188
|
+
let slices = null;
|
|
189
|
+
if (params.interval) {
|
|
190
|
+
const groups = /* @__PURE__ */ new Map();
|
|
191
|
+
for (const s of subjects) {
|
|
192
|
+
if (!s.anchorAt) continue;
|
|
193
|
+
const at = truncate(s.anchorAt, params.interval);
|
|
194
|
+
const k = at.getTime();
|
|
195
|
+
groups.set(k, [...groups.get(k) ?? [], s]);
|
|
196
|
+
}
|
|
197
|
+
slices = [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([k, members]) => ({
|
|
198
|
+
at: new Date(k),
|
|
199
|
+
subjects: members.length,
|
|
200
|
+
stages: summarizeStages(members, stages)
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
cohortSubjects: subjects.length,
|
|
205
|
+
first: stages[0] ? subjects.filter((s) => s.stages[stages[0].key]).length : 0,
|
|
206
|
+
stages: summary,
|
|
207
|
+
exits: exits.map((e) => ({
|
|
208
|
+
key: e.key,
|
|
209
|
+
as: e.as,
|
|
210
|
+
label: e.label,
|
|
211
|
+
subjects: subjects.filter((s) => s.exits[e.key]).length
|
|
212
|
+
})),
|
|
213
|
+
slices,
|
|
214
|
+
truncated,
|
|
215
|
+
cohort: { from: params.cohort.from, to: params.cohort.to, endInclusive, anchor },
|
|
216
|
+
dataSource: "rollups"
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/server/query.ts
|
|
221
|
+
var DEFAULT_LIMITS = {
|
|
222
|
+
records: 200,
|
|
223
|
+
series: 744,
|
|
224
|
+
// a month of hourly buckets
|
|
225
|
+
rollups: 500,
|
|
226
|
+
trace: 500,
|
|
227
|
+
journey: 500,
|
|
228
|
+
distribution: 1e5,
|
|
229
|
+
distinct: 1e5,
|
|
230
|
+
funnel: 5e3
|
|
231
|
+
};
|
|
232
|
+
var esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
233
|
+
function buildMatch(scope, range, f) {
|
|
234
|
+
const match = {
|
|
235
|
+
// the ONLY place the tenant term is optional. Omitted under '*' — every
|
|
236
|
+
// other term still applies, and the time range is still mandatory (§18).
|
|
237
|
+
...isPlatformScope(scope) ? {} : { tenantId: scope },
|
|
238
|
+
occurredAt: { $gte: range.from, $lt: range.to }
|
|
239
|
+
};
|
|
240
|
+
for (const k of ["kind", "name", "severity", "env", "service", "release", "traceId"]) {
|
|
241
|
+
if (f[k]) match[k] = f[k];
|
|
242
|
+
}
|
|
243
|
+
if (f.subject) match.subjectKeys = f.subject;
|
|
244
|
+
for (const [k, v] of Object.entries(f.attrs ?? {})) match[`attrs.${k}`] = v;
|
|
245
|
+
for (const [k, r] of Object.entries(f.metrics ?? {})) {
|
|
246
|
+
const term = {};
|
|
247
|
+
if (r.gte != null) term.$gte = r.gte;
|
|
248
|
+
if (r.lte != null) term.$lte = r.lte;
|
|
249
|
+
if (Object.keys(term).length) match[`metrics.${k}`] = term;
|
|
250
|
+
}
|
|
251
|
+
if (f.excludeActorTypes?.length) {
|
|
252
|
+
match.$and = [
|
|
253
|
+
...match.$and ?? [],
|
|
254
|
+
{
|
|
255
|
+
$or: [
|
|
256
|
+
{ actor: { $exists: false } },
|
|
257
|
+
{ actor: { $not: new RegExp(`^(${f.excludeActorTypes.map(esc).join("|")}):`) } }
|
|
258
|
+
]
|
|
259
|
+
}
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
return match;
|
|
263
|
+
}
|
|
264
|
+
var QueryCache = class {
|
|
265
|
+
constructor(ttlMs, cap) {
|
|
266
|
+
this.ttlMs = ttlMs;
|
|
267
|
+
this.cap = cap;
|
|
268
|
+
}
|
|
269
|
+
ttlMs;
|
|
270
|
+
cap;
|
|
271
|
+
store = /* @__PURE__ */ new Map();
|
|
272
|
+
get(key, produce) {
|
|
273
|
+
const hit = this.store.get(key);
|
|
274
|
+
if (hit && Date.now() - hit.at < this.ttlMs) return hit.value;
|
|
275
|
+
const value = produce();
|
|
276
|
+
value.catch(() => this.store.delete(key));
|
|
277
|
+
if (this.store.size >= this.cap) {
|
|
278
|
+
const oldest = [...this.store.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
279
|
+
if (oldest) this.store.delete(oldest[0]);
|
|
280
|
+
}
|
|
281
|
+
this.store.set(key, { at: Date.now(), value });
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
function createQueries(ctx) {
|
|
286
|
+
const limits = { ...DEFAULT_LIMITS, ...ctx.limits };
|
|
287
|
+
const slowMs = ctx.slowMs ?? 500;
|
|
288
|
+
const cache = new QueryCache(ctx.cacheTtlMs ?? 10 * 6e4, ctx.cacheSize ?? 60);
|
|
289
|
+
const timed = async (op, params, run) => {
|
|
290
|
+
const t0 = Date.now();
|
|
291
|
+
try {
|
|
292
|
+
return await run();
|
|
293
|
+
} finally {
|
|
294
|
+
const ms = Date.now() - t0;
|
|
295
|
+
if (ms > slowMs) ctx.onSlowQuery?.({ op, ms, params });
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
return {
|
|
299
|
+
/** cursor-paged raw envelope reads — tables, lists, detail drawers */
|
|
300
|
+
async records(scope, range, filter = {}, opts = {}) {
|
|
301
|
+
const limit = Math.min(Math.max(1, opts.limit ?? limits.records), limits.records);
|
|
302
|
+
const match = buildMatch(scope, range, filter);
|
|
303
|
+
if (opts.cursor) {
|
|
304
|
+
const [atIso, id] = JSON.parse(Buffer.from(opts.cursor, "base64url").toString());
|
|
305
|
+
const at = new Date(atIso);
|
|
306
|
+
match.$and = [
|
|
307
|
+
...match.$and ?? [],
|
|
308
|
+
{ $or: [{ occurredAt: { $lt: at } }, { occurredAt: at, _id: { $lt: id } }] }
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
return timed("records", { scope, filter }, async () => {
|
|
312
|
+
const items = await ctx.TelemetryModel.find(match).sort({ occurredAt: -1, _id: -1 }).limit(limit + 1).lean();
|
|
313
|
+
const more = items.length > limit;
|
|
314
|
+
if (more) items.pop();
|
|
315
|
+
const last = items[items.length - 1];
|
|
316
|
+
return {
|
|
317
|
+
items,
|
|
318
|
+
nextCursor: more ? Buffer.from(JSON.stringify([new Date(last.occurredAt).toISOString(), last._id])).toString("base64url") : null,
|
|
319
|
+
dataSource: "raw"
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
/** time-series at query time. count extrapolates by 1/sampleRate (§5.3) —
|
|
324
|
+
* exact while rates sit at 1, still honest the day one drops.
|
|
325
|
+
*
|
|
326
|
+
* Under PLATFORM_SCOPE this aggregates ACROSS tenants into one bucket per
|
|
327
|
+
* interval. That is the platform-wide chart, not a bug — the sum of every
|
|
328
|
+
* tenant is the number a platform operator came for. A per-tenant
|
|
329
|
+
* breakdown is a different question; ask it with rollups() or by scoping
|
|
330
|
+
* to a tenant. Same for distribution() below. */
|
|
331
|
+
series(scope, range, filter, opts = {}) {
|
|
332
|
+
const { measure = "count", interval = "day" } = opts;
|
|
333
|
+
const key = JSON.stringify(["series", scope, range.from, range.to, filter, measure, interval]);
|
|
334
|
+
return cache.get(
|
|
335
|
+
key,
|
|
336
|
+
() => timed("series", { scope, filter, measure, interval }, async () => {
|
|
337
|
+
const m = /^(sum|avg):(.+)$/.exec(measure);
|
|
338
|
+
const value = !m ? { $sum: { $divide: [1, { $ifNull: ["$sampleRate", 1] }] } } : m[1] === "sum" ? { $sum: `$metrics.${m[2]}` } : { $avg: `$metrics.${m[2]}` };
|
|
339
|
+
const buckets = await ctx.TelemetryModel.aggregate([
|
|
340
|
+
{ $match: buildMatch(scope, range, filter) },
|
|
341
|
+
{
|
|
342
|
+
$group: {
|
|
343
|
+
_id: { $dateTrunc: { date: "$occurredAt", unit: interval, ...interval === "week" ? { startOfWeek: "monday" } : {} } },
|
|
344
|
+
value
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{ $sort: { _id: 1 } },
|
|
348
|
+
{ $limit: limits.series }
|
|
349
|
+
]);
|
|
350
|
+
return { buckets: buckets.map((b) => ({ at: b._id, value: b.value })), dataSource: "raw" };
|
|
351
|
+
})
|
|
352
|
+
);
|
|
353
|
+
},
|
|
354
|
+
/**
|
|
355
|
+
* Percentiles + histogram off raw. Keep-all makes the SAMPLE complete —
|
|
356
|
+
* no sampling stands between the match and the math (§5.3) — but the
|
|
357
|
+
* computation is not exact and this comment used to claim it was:
|
|
358
|
+
* `$percentile` runs `method: 'approximate'` (t-digest), and the scan stops
|
|
359
|
+
* at `limits.distribution`.
|
|
360
|
+
*
|
|
361
|
+
* So the ceiling is read as cap+1 and `truncated` reports whether it was
|
|
362
|
+
* actually reached, the same way rollups/distinctCount/funnel do. A match
|
|
363
|
+
* wider than the ceiling is an undercount, and an undercount the response
|
|
364
|
+
* does not mention is the silent cap this package refuses everywhere else.
|
|
365
|
+
* Mongo 7+.
|
|
366
|
+
*/
|
|
367
|
+
distribution(scope, range, filter, opts = {}) {
|
|
368
|
+
const measure = opts.measure ?? "durationMs";
|
|
369
|
+
const path = measure === "durationMs" ? "$durationMs" : `$metrics.${measure.replace(/^metric:/, "")}`;
|
|
370
|
+
const key = JSON.stringify(["distribution", scope, range.from, range.to, filter, measure]);
|
|
371
|
+
return cache.get(
|
|
372
|
+
key,
|
|
373
|
+
() => timed("distribution", { scope, filter, measure }, async () => {
|
|
374
|
+
const match = {
|
|
375
|
+
...buildMatch(scope, range, filter),
|
|
376
|
+
[path.slice(1)]: { $exists: true }
|
|
377
|
+
};
|
|
378
|
+
const cap = limits.distribution;
|
|
379
|
+
const [summary] = await ctx.TelemetryModel.aggregate([
|
|
380
|
+
{ $match: match },
|
|
381
|
+
{ $limit: cap + 1 },
|
|
382
|
+
{
|
|
383
|
+
$group: {
|
|
384
|
+
_id: null,
|
|
385
|
+
p: { $percentile: { input: path, p: [0.5, 0.9, 0.95, 0.99], method: "approximate" } },
|
|
386
|
+
min: { $min: path },
|
|
387
|
+
max: { $max: path },
|
|
388
|
+
avg: { $avg: path },
|
|
389
|
+
n: { $sum: 1 }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
]);
|
|
393
|
+
if (!summary) return { n: 0, truncated: false, dataSource: "raw" };
|
|
394
|
+
const [p50, p90, p95, p99] = summary.p;
|
|
395
|
+
const histogram = await ctx.TelemetryModel.aggregate([
|
|
396
|
+
{ $match: match },
|
|
397
|
+
{ $limit: cap + 1 },
|
|
398
|
+
{ $bucketAuto: { groupBy: path, buckets: 20 } }
|
|
399
|
+
]);
|
|
400
|
+
return {
|
|
401
|
+
p50,
|
|
402
|
+
p90,
|
|
403
|
+
p95,
|
|
404
|
+
p99,
|
|
405
|
+
min: summary.min,
|
|
406
|
+
max: summary.max,
|
|
407
|
+
avg: summary.avg,
|
|
408
|
+
n: summary.n,
|
|
409
|
+
histogram: histogram.map((h) => ({ min: h._id.min, max: h._id.max, n: h.count })),
|
|
410
|
+
truncated: summary.n > cap,
|
|
411
|
+
dataSource: "raw"
|
|
412
|
+
};
|
|
413
|
+
})
|
|
414
|
+
);
|
|
415
|
+
},
|
|
416
|
+
/** rollup family reads — issues, spend, activity, milestones, funnels */
|
|
417
|
+
rollups(scope, params) {
|
|
418
|
+
const key = JSON.stringify(["rollups", scope, params]);
|
|
419
|
+
return cache.get(
|
|
420
|
+
key,
|
|
421
|
+
() => timed("rollups", { scope, params }, async () => {
|
|
422
|
+
let bucketed = false;
|
|
423
|
+
outer: for (const [name, s] of Object.entries(ctx.registry)) {
|
|
424
|
+
for (const r of s.rollups ?? []) {
|
|
425
|
+
if ((r.as ?? name) === params.as) {
|
|
426
|
+
bucketed = !!r.bucket;
|
|
427
|
+
break outer;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const match = {
|
|
432
|
+
...isPlatformScope(scope) ? {} : { tenantId: scope },
|
|
433
|
+
as: params.as
|
|
434
|
+
};
|
|
435
|
+
if (params.dims) {
|
|
436
|
+
match.dims = Array.isArray(params.dims) ? { $in: params.dims } : params.dims;
|
|
437
|
+
}
|
|
438
|
+
if (params.subjectType) match.subjectType = params.subjectType;
|
|
439
|
+
if (params.range) {
|
|
440
|
+
const on = params.on ?? (bucketed ? "bucketAt" : "lastAt");
|
|
441
|
+
match[on] = { $gte: params.range.from, $lt: params.range.to };
|
|
442
|
+
}
|
|
443
|
+
const sortKey = params.sort ?? (bucketed ? "bucketAt" : "count");
|
|
444
|
+
const limit = Math.min(Math.max(1, params.limit ?? limits.rollups), limits.rollups);
|
|
445
|
+
const rows = await ctx.RollupModel.find(match).sort({ [sortKey]: sortKey === "firstAt" || sortKey === "bucketAt" ? 1 : -1 }).limit(limit + 1).lean();
|
|
446
|
+
const truncated = rows.length > limit;
|
|
447
|
+
if (truncated) rows.pop();
|
|
448
|
+
return { rows, bucketed, truncated, dataSource: "rollups" };
|
|
449
|
+
})
|
|
450
|
+
);
|
|
451
|
+
},
|
|
452
|
+
/** one trace, every kind, one time axis — the first join view */
|
|
453
|
+
trace(scope, traceId) {
|
|
454
|
+
return timed("trace", { scope, traceId }, async () => {
|
|
455
|
+
const items = await ctx.TelemetryModel.find({
|
|
456
|
+
...isPlatformScope(scope) ? {} : { tenantId: scope },
|
|
457
|
+
traceId
|
|
458
|
+
}).sort({ occurredAt: 1 }).limit(limits.trace).lean();
|
|
459
|
+
return { items, dataSource: "raw" };
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
/** one subject's whole story — records interleaved, milestones as markers */
|
|
463
|
+
journey(scope, subjectRef, range, opts = {}) {
|
|
464
|
+
return timed("journey", { scope, subjectRef }, async () => {
|
|
465
|
+
const limit = Math.min(Math.max(1, opts.limit ?? limits.journey), limits.journey);
|
|
466
|
+
const pin = isPlatformScope(scope) ? {} : { tenantId: scope };
|
|
467
|
+
const [records, milestones] = await Promise.all([
|
|
468
|
+
ctx.TelemetryModel.find({
|
|
469
|
+
...pin,
|
|
470
|
+
subjectKeys: subjectRef,
|
|
471
|
+
occurredAt: { $gte: range.from, $lt: range.to }
|
|
472
|
+
}).sort({ occurredAt: -1 }).limit(limit).lean(),
|
|
473
|
+
// lifetime families only — bucketed activity rows would drown the markers
|
|
474
|
+
ctx.RollupModel.find({ ...pin, dims: subjectRef, bucketAt: { $exists: false } }).sort({ firstAt: 1 }).limit(100).lean()
|
|
475
|
+
]);
|
|
476
|
+
return { records, milestones, dataSource: "raw+rollups" };
|
|
477
|
+
});
|
|
478
|
+
},
|
|
479
|
+
/**
|
|
480
|
+
* Distinct subjects per bucket, and over the whole range — DAU/MAU/WAU,
|
|
481
|
+
* EXACTLY, with no sketch and no write-path change.
|
|
482
|
+
*
|
|
483
|
+
* The trick is that there is no trick. A family declared `by: ['subject']`
|
|
484
|
+
* with a bucket already writes exactly ONE doc per (subject, bucket), which
|
|
485
|
+
* is what the deterministic `_id` guarantees. So distinct-subjects-in-bucket
|
|
486
|
+
* IS the doc count, and distinct-over-a-range is one `$group` on `dims`. An
|
|
487
|
+
* HLL sketch would buy approximation we do not need and storage we would
|
|
488
|
+
* have to maintain.
|
|
489
|
+
*
|
|
490
|
+
* `interval` may be COARSER than the family's own bucket (daily rows →
|
|
491
|
+
* monthly MAU) — re-truncating bucket starts cannot split a bucket across
|
|
492
|
+
* two periods, so the roll-up stays exact. Asking for finer than the family
|
|
493
|
+
* writes cannot invent detail: it returns the family's own grain.
|
|
494
|
+
*/
|
|
495
|
+
distinctCount(scope, params) {
|
|
496
|
+
const spec = requireDistinctFamily(ctx.registry, params.as);
|
|
497
|
+
const interval = params.interval ?? spec.bucket;
|
|
498
|
+
const key = JSON.stringify(["distinctCount", scope, params]);
|
|
499
|
+
return cache.get(
|
|
500
|
+
key,
|
|
501
|
+
() => timed("distinctCount", { scope, params }, async () => {
|
|
502
|
+
const match = {
|
|
503
|
+
...isPlatformScope(scope) ? {} : { tenantId: scope },
|
|
504
|
+
as: params.as,
|
|
505
|
+
bucketAt: { $gte: params.range.from, $lt: params.range.to }
|
|
506
|
+
};
|
|
507
|
+
if (params.subjectType) match.subjectType = params.subjectType;
|
|
508
|
+
const cap = limits.distinct;
|
|
509
|
+
const [out] = await ctx.RollupModel.aggregate([
|
|
510
|
+
{ $match: match },
|
|
511
|
+
// one scan ceiling, shared by both branches — and cap+1 so the
|
|
512
|
+
// response can SAY it was truncated instead of quietly undercounting
|
|
513
|
+
{ $limit: cap + 1 },
|
|
514
|
+
{
|
|
515
|
+
$facet: {
|
|
516
|
+
buckets: [
|
|
517
|
+
{
|
|
518
|
+
$group: {
|
|
519
|
+
_id: {
|
|
520
|
+
at: { $dateTrunc: { date: "$bucketAt", unit: interval, ...interval === "week" ? { startOfWeek: "monday" } : {} } },
|
|
521
|
+
dims: "$dims"
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{ $group: { _id: "$_id.at", value: { $sum: 1 } } },
|
|
526
|
+
{ $sort: { _id: 1 } },
|
|
527
|
+
{ $limit: limits.series }
|
|
528
|
+
],
|
|
529
|
+
distinct: [{ $group: { _id: "$dims" } }, { $count: "n" }],
|
|
530
|
+
scanned: [{ $count: "n" }]
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
]);
|
|
534
|
+
const scanned = out?.scanned?.[0]?.n ?? 0;
|
|
535
|
+
return {
|
|
536
|
+
buckets: (out?.buckets ?? []).map((b) => ({ at: b._id, value: b.value })),
|
|
537
|
+
/** distinct subjects across the WHOLE range — never the sum of the buckets */
|
|
538
|
+
distinct: out?.distinct?.[0]?.n ?? 0,
|
|
539
|
+
interval,
|
|
540
|
+
truncated: scanned > cap,
|
|
541
|
+
dataSource: "rollups"
|
|
542
|
+
};
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
},
|
|
546
|
+
/**
|
|
547
|
+
* Cohort funnel over lifetime milestone families — stage counts, conversion,
|
|
548
|
+
* and median time-to-step. See funnel.ts; the math lives there so it can be
|
|
549
|
+
* unit-pinned without a database.
|
|
550
|
+
*/
|
|
551
|
+
funnel(scope, params) {
|
|
552
|
+
return timed(
|
|
553
|
+
"funnel",
|
|
554
|
+
{ scope, params },
|
|
555
|
+
() => runFunnel(
|
|
556
|
+
{
|
|
557
|
+
RollupModel: ctx.RollupModel,
|
|
558
|
+
registry: ctx.registry,
|
|
559
|
+
cohortCap: limits.funnel,
|
|
560
|
+
scopeMatch: (s) => isPlatformScope(s) ? {} : { tenantId: s }
|
|
561
|
+
},
|
|
562
|
+
scope,
|
|
563
|
+
params
|
|
564
|
+
)
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function requireDistinctFamily(registry, as) {
|
|
570
|
+
const found = findFamily(registry, as);
|
|
571
|
+
if (!found) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`telemetry: distinctCount() \u2014 no rollup family "${as}" is declared. Add \`rollups: [{ as: '${as}', by: ['subject'], subjects: [...], bucket: 'day' }]\` to the events that count as activity.`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
const { name, spec } = found;
|
|
577
|
+
const by = `by: [${spec.by.map((d) => `'${d}'`).join(", ")}]`;
|
|
578
|
+
if (!spec.bucket) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
`telemetry: distinctCount() \u2014 rollup family "${as}" (declared on "${name}") has no \`bucket\`. Distinct-per-period needs one doc per (subject, period); a lifetime family has one doc per subject forever, so every period would report the same number. Add \`bucket: 'day'\`, or ask this with rollups().`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (!spec.by.includes("subject")) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`telemetry: distinctCount() \u2014 rollup family "${as}" (declared on "${name}") is keyed ${by} with no \`subject\` dim, so its docs count OCCURRENCES, not subjects. Add 'subject' to \`by\` (with \`subjects: [...]\`).`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
if (spec.by.length !== 1) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`telemetry: distinctCount() \u2014 rollup family "${as}" (declared on "${name}") is keyed ${by}. Extra dims split one subject across several docs per period, so the count would exceed the true distinct total. Declare a second family with \`by: ['subject']\` for the distinct question.`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
return spec;
|
|
594
|
+
}
|
|
595
|
+
function buildViewModel(connection, modelName, collection) {
|
|
596
|
+
const existing = connection.models?.[modelName];
|
|
597
|
+
if (existing) return existing;
|
|
598
|
+
const schema = new mongoose.Schema(
|
|
599
|
+
{
|
|
600
|
+
_id: { type: String, required: true },
|
|
601
|
+
tenantId: { type: String, required: true },
|
|
602
|
+
/** a person — forget() deletes private views, redacts this on shared ones */
|
|
603
|
+
ownerRef: String,
|
|
604
|
+
shared: { type: Boolean, default: false },
|
|
605
|
+
spec: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
606
|
+
createdAt: { type: Date, required: true }
|
|
607
|
+
},
|
|
608
|
+
{ collection, versionKey: false }
|
|
609
|
+
);
|
|
610
|
+
schema.index({ tenantId: 1, shared: 1 });
|
|
611
|
+
schema.index({ tenantId: 1, ownerRef: 1 });
|
|
612
|
+
return connection.model(modelName, schema);
|
|
613
|
+
}
|
|
614
|
+
var KIND_PAGE = {
|
|
615
|
+
error: "errors",
|
|
616
|
+
span: "traces",
|
|
617
|
+
event: "events",
|
|
618
|
+
state: "journeys",
|
|
619
|
+
usage: "usage"
|
|
620
|
+
};
|
|
621
|
+
function deriveViews(registry) {
|
|
622
|
+
const views = [];
|
|
623
|
+
const families = /* @__PURE__ */ new Set();
|
|
624
|
+
for (const [name, spec] of Object.entries(registry)) {
|
|
625
|
+
views.push({
|
|
626
|
+
origin: "derived",
|
|
627
|
+
name,
|
|
628
|
+
page: KIND_PAGE[spec.kind] ?? "events",
|
|
629
|
+
query: { range: "7d", filters: { name }, display: spec.kind === "event" ? "series" : "table" }
|
|
630
|
+
});
|
|
631
|
+
for (const r of spec.rollups ?? []) families.add(r.as ?? name);
|
|
632
|
+
}
|
|
633
|
+
for (const as of families) {
|
|
634
|
+
views.push({
|
|
635
|
+
origin: "derived",
|
|
636
|
+
name: `rollup: ${as}`,
|
|
637
|
+
page: "journeys",
|
|
638
|
+
query: { range: "30d", filters: { rollup: as }, display: "breakdown" }
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return views;
|
|
642
|
+
}
|
|
643
|
+
async function resolveViews(opts) {
|
|
644
|
+
const byName = /* @__PURE__ */ new Map();
|
|
645
|
+
for (const v of deriveViews(opts.registry)) byName.set(v.name, v);
|
|
646
|
+
for (const v of opts.configured) byName.set(v.name, { ...v, origin: "configured" });
|
|
647
|
+
const saved = await opts.ViewModel.find({
|
|
648
|
+
tenantId: opts.tenantId,
|
|
649
|
+
$or: [{ shared: true }, ...opts.viewerRef ? [{ ownerRef: opts.viewerRef }] : []]
|
|
650
|
+
}).sort({ createdAt: 1 }).limit(200).lean();
|
|
651
|
+
for (const doc of saved) {
|
|
652
|
+
byName.set(doc.spec.name, {
|
|
653
|
+
...doc.spec,
|
|
654
|
+
origin: "saved",
|
|
655
|
+
id: doc._id,
|
|
656
|
+
ownerRef: doc.ownerRef,
|
|
657
|
+
shared: doc.shared
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return [...byName.values()];
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/server/mcp.ts
|
|
664
|
+
var tenantArg = {
|
|
665
|
+
tenant: zod.z.string().optional().describe(
|
|
666
|
+
"platform operators only: restrict the read to one tenant id. Permitted ONLY when your viewer is scoped to '*'; a hard error otherwise."
|
|
667
|
+
)
|
|
668
|
+
};
|
|
669
|
+
var rangeArg = {
|
|
670
|
+
from: zod.z.string().optional().describe("ISO 8601 start, inclusive. Default: 7 days before `to`."),
|
|
671
|
+
to: zod.z.string().optional().describe("ISO 8601 end, exclusive. Default: now.")
|
|
672
|
+
};
|
|
673
|
+
var filterArg = {
|
|
674
|
+
kind: zod.z.enum(["event", "error", "span", "state", "usage"]).optional(),
|
|
675
|
+
name: zod.z.string().optional().describe('exact event name, e.g. "user.signed_up" \u2014 see describe_telemetry'),
|
|
676
|
+
severity: zod.z.string().optional(),
|
|
677
|
+
env: zod.z.string().optional().describe("prod | staging | dev"),
|
|
678
|
+
service: zod.z.string().optional(),
|
|
679
|
+
release: zod.z.string().optional(),
|
|
680
|
+
subject: zod.z.string().optional().describe('pin to one subject, e.g. "user:u_1"'),
|
|
681
|
+
traceId: zod.z.string().optional(),
|
|
682
|
+
attrs: zod.z.record(zod.z.string(), zod.z.string()).optional().describe("equality on declared indexed attrs"),
|
|
683
|
+
excludeActorTypes: zod.z.array(zod.z.string()).optional().describe('drop typed actors, e.g. ["admin","system"] for a customer-only view')
|
|
684
|
+
};
|
|
685
|
+
function toFilter(a) {
|
|
686
|
+
const f = {};
|
|
687
|
+
for (const k of ["kind", "name", "severity", "env", "service", "release", "subject", "traceId"]) {
|
|
688
|
+
if (a[k] != null) f[k] = a[k];
|
|
689
|
+
}
|
|
690
|
+
if (a.attrs) f.attrs = a.attrs;
|
|
691
|
+
if (a.excludeActorTypes?.length) f.excludeActorTypes = a.excludeActorTypes;
|
|
692
|
+
return f;
|
|
693
|
+
}
|
|
694
|
+
function parseRange(from, to) {
|
|
695
|
+
const toD = to ? new Date(to) : /* @__PURE__ */ new Date();
|
|
696
|
+
const fromD = from ? new Date(from) : new Date(toD.getTime() - 7 * 864e5);
|
|
697
|
+
if (Number.isNaN(fromD.getTime()) || Number.isNaN(toD.getTime()) || fromD >= toD) {
|
|
698
|
+
throw new Error("invalid time range: `from` must be a valid ISO time strictly before `to`");
|
|
699
|
+
}
|
|
700
|
+
return { from: fromD, to: toD };
|
|
701
|
+
}
|
|
702
|
+
var INTERVAL = zod.z.enum(["hour", "day", "week", "month"]);
|
|
703
|
+
function createTelemetryMcp(opts) {
|
|
704
|
+
const { telemetry: t, viewerAdapter, subjectAdapter, configured = [] } = opts;
|
|
705
|
+
if (!viewerAdapter?.resolveViewer) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
"telemetry: createTelemetryMcp requires a viewerAdapter \u2014 an unauthenticated telemetry tool is a data leak an agent will find"
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const redact = opts.redact === false ? (r) => r : opts.redact ?? ((r) => {
|
|
711
|
+
if (!r || typeof r !== "object" || r.data === void 0) return r;
|
|
712
|
+
const { data, ...rest } = r;
|
|
713
|
+
return { ...rest, data: "[redacted]" };
|
|
714
|
+
});
|
|
715
|
+
const redactAll = (items) => items.map(redact);
|
|
716
|
+
const q = createQueries({
|
|
717
|
+
TelemetryModel: t.models.telemetry,
|
|
718
|
+
RollupModel: t.models.rollups,
|
|
719
|
+
registry: t.registry,
|
|
720
|
+
limits: opts.limits
|
|
721
|
+
});
|
|
722
|
+
const ViewModel = buildViewModel(
|
|
723
|
+
t.models.telemetry.db,
|
|
724
|
+
`${t.models.telemetry.modelName}View`,
|
|
725
|
+
`${t.models.telemetry.collection.collectionName}_views`
|
|
726
|
+
);
|
|
727
|
+
async function resolve(ctx) {
|
|
728
|
+
const viewer = await viewerAdapter.resolveViewer(ctx);
|
|
729
|
+
if (!viewer?.tenantId) throw new Error("unauthorized: viewerAdapter returned no viewer");
|
|
730
|
+
return viewer;
|
|
731
|
+
}
|
|
732
|
+
function pickScope(viewer, tenant) {
|
|
733
|
+
if (tenant == null || tenant === "") return viewer.tenantId;
|
|
734
|
+
if (!isPlatformScope(viewer.tenantId)) {
|
|
735
|
+
throw new Error("the `tenant` argument is permitted only for platform-scope ('*') viewers");
|
|
736
|
+
}
|
|
737
|
+
return tenant;
|
|
738
|
+
}
|
|
739
|
+
async function labelSubjects(items) {
|
|
740
|
+
if (!subjectAdapter) return void 0;
|
|
741
|
+
const refs = /* @__PURE__ */ new Set();
|
|
742
|
+
for (const it of items) for (const r of it?.subjectKeys ?? []) refs.add(r);
|
|
743
|
+
if (!refs.size) return {};
|
|
744
|
+
return subjectAdapter.describe([...refs].slice(0, 100));
|
|
745
|
+
}
|
|
746
|
+
const tool = (d) => d;
|
|
747
|
+
const tools = [
|
|
748
|
+
// ── vocabulary ──────────────────────────────────────────────────────────
|
|
749
|
+
tool({
|
|
750
|
+
name: "describe_telemetry",
|
|
751
|
+
title: "Describe telemetry schema",
|
|
752
|
+
description: "The vocabulary of this telemetry instance: every event name with its kind, declared attributes, metrics, indexed filters, and rollup families. CALL THIS FIRST \u2014 every other tool speaks the names it returns.",
|
|
753
|
+
inputSchema: zod.z.object({}),
|
|
754
|
+
async handler(_args, ctx) {
|
|
755
|
+
await resolve(ctx);
|
|
756
|
+
return { registry: registryProjection(t), kinds: ["event", "error", "span", "state", "usage"] };
|
|
757
|
+
}
|
|
758
|
+
}),
|
|
759
|
+
// ── events ──────────────────────────────────────────────────────────────
|
|
760
|
+
tool({
|
|
761
|
+
name: "search_events",
|
|
762
|
+
title: "Search events",
|
|
763
|
+
description: "Raw telemetry records matching a filter, newest first, cursor-paged. The general list/table/tail tool across every kind. `data` payloads are redacted by default.",
|
|
764
|
+
inputSchema: zod.z.object({
|
|
765
|
+
...filterArg,
|
|
766
|
+
...rangeArg,
|
|
767
|
+
...tenantArg,
|
|
768
|
+
limit: zod.z.number().int().positive().optional(),
|
|
769
|
+
cursor: zod.z.string().optional().describe("opaque nextCursor from a previous call")
|
|
770
|
+
}),
|
|
771
|
+
async handler(a, ctx) {
|
|
772
|
+
const viewer = await resolve(ctx);
|
|
773
|
+
const scope = pickScope(viewer, a.tenant);
|
|
774
|
+
const res = await q.records(scope, parseRange(a.from, a.to), toFilter(a), {
|
|
775
|
+
limit: a.limit,
|
|
776
|
+
cursor: a.cursor
|
|
777
|
+
});
|
|
778
|
+
return { ...res, items: redactAll(res.items), subjects: await labelSubjects(res.items) };
|
|
779
|
+
}
|
|
780
|
+
}),
|
|
781
|
+
tool({
|
|
782
|
+
name: "list_errors",
|
|
783
|
+
title: "List errors",
|
|
784
|
+
description: 'Recent error records, newest first \u2014 the focused feed for "what is breaking?". A convenience over search_events with kind pinned to "error"; still accepts name/service/env/severity filters.',
|
|
785
|
+
inputSchema: zod.z.object({
|
|
786
|
+
name: zod.z.string().optional(),
|
|
787
|
+
severity: zod.z.string().optional(),
|
|
788
|
+
service: zod.z.string().optional(),
|
|
789
|
+
env: zod.z.string().optional(),
|
|
790
|
+
release: zod.z.string().optional(),
|
|
791
|
+
subject: zod.z.string().optional(),
|
|
792
|
+
excludeActorTypes: filterArg.excludeActorTypes,
|
|
793
|
+
...rangeArg,
|
|
794
|
+
...tenantArg,
|
|
795
|
+
limit: zod.z.number().int().positive().optional(),
|
|
796
|
+
cursor: zod.z.string().optional()
|
|
797
|
+
}),
|
|
798
|
+
async handler(a, ctx) {
|
|
799
|
+
const viewer = await resolve(ctx);
|
|
800
|
+
const scope = pickScope(viewer, a.tenant);
|
|
801
|
+
const res = await q.records(scope, parseRange(a.from, a.to), { ...toFilter(a), kind: "error" }, {
|
|
802
|
+
limit: a.limit,
|
|
803
|
+
cursor: a.cursor
|
|
804
|
+
});
|
|
805
|
+
return { ...res, items: redactAll(res.items), subjects: await labelSubjects(res.items) };
|
|
806
|
+
}
|
|
807
|
+
}),
|
|
808
|
+
tool({
|
|
809
|
+
name: "event_trends",
|
|
810
|
+
title: "Event trends over time",
|
|
811
|
+
description: 'A time series of a measure, bucketed by interval \u2014 counts by default, or sum:/avg: of a declared metric (e.g. "avg:durationMs"). Use for "is this rising?" questions.',
|
|
812
|
+
inputSchema: zod.z.object({
|
|
813
|
+
...filterArg,
|
|
814
|
+
...rangeArg,
|
|
815
|
+
...tenantArg,
|
|
816
|
+
measure: zod.z.string().optional().describe('"count" (default), or "sum:<metric>" / "avg:<metric>", e.g. "sum:tokens"'),
|
|
817
|
+
interval: INTERVAL.optional()
|
|
818
|
+
}),
|
|
819
|
+
async handler(a, ctx) {
|
|
820
|
+
const viewer = await resolve(ctx);
|
|
821
|
+
const scope = pickScope(viewer, a.tenant);
|
|
822
|
+
return q.series(scope, parseRange(a.from, a.to), toFilter(a), {
|
|
823
|
+
measure: a.measure,
|
|
824
|
+
interval: a.interval
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}),
|
|
828
|
+
tool({
|
|
829
|
+
name: "metric_distribution",
|
|
830
|
+
title: "Metric distribution (percentiles)",
|
|
831
|
+
description: 'Percentiles (p50/p90/p95/p99), min/max/avg, and a 20-bucket histogram of a numeric metric over the matched records \u2014 e.g. "what is p95 latency for the checkout span?". Defaults to durationMs.',
|
|
832
|
+
inputSchema: zod.z.object({
|
|
833
|
+
...filterArg,
|
|
834
|
+
...rangeArg,
|
|
835
|
+
...tenantArg,
|
|
836
|
+
measure: zod.z.string().optional().describe('"durationMs" (default) or a declared metric key, e.g. "metric:tokens"')
|
|
837
|
+
}),
|
|
838
|
+
async handler(a, ctx) {
|
|
839
|
+
const viewer = await resolve(ctx);
|
|
840
|
+
const scope = pickScope(viewer, a.tenant);
|
|
841
|
+
return q.distribution(scope, parseRange(a.from, a.to), toFilter(a), { measure: a.measure });
|
|
842
|
+
}
|
|
843
|
+
}),
|
|
844
|
+
// ── aggregates ────────────────────────────────────────────────────────────
|
|
845
|
+
tool({
|
|
846
|
+
name: "rollup_breakdown",
|
|
847
|
+
title: "Rollup breakdown",
|
|
848
|
+
description: "Top rows of a pre-aggregated rollup family \u2014 top issues, top spenders, most-active accounts, whatever the registry declares. Name the family (`as`) from describe_telemetry; optionally slice by dimension values.",
|
|
849
|
+
inputSchema: zod.z.object({
|
|
850
|
+
as: zod.z.string().describe('the rollup family, e.g. "spend_by_account" \u2014 see describe_telemetry'),
|
|
851
|
+
dims: zod.z.array(zod.z.string()).optional().describe("restrict to these dimension values"),
|
|
852
|
+
subjectType: zod.z.string().optional(),
|
|
853
|
+
on: zod.z.enum(["firstAt", "lastAt", "bucketAt"]).optional(),
|
|
854
|
+
sort: zod.z.enum(["count", "lastAt", "firstAt", "bucketAt"]).optional(),
|
|
855
|
+
...rangeArg,
|
|
856
|
+
...tenantArg,
|
|
857
|
+
limit: zod.z.number().int().positive().optional()
|
|
858
|
+
}),
|
|
859
|
+
async handler(a, ctx) {
|
|
860
|
+
const viewer = await resolve(ctx);
|
|
861
|
+
const scope = pickScope(viewer, a.tenant);
|
|
862
|
+
return q.rollups(scope, {
|
|
863
|
+
as: a.as,
|
|
864
|
+
dims: a.dims,
|
|
865
|
+
subjectType: a.subjectType,
|
|
866
|
+
on: a.on,
|
|
867
|
+
sort: a.sort,
|
|
868
|
+
range: a.from || a.to ? parseRange(a.from, a.to) : void 0,
|
|
869
|
+
limit: a.limit
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}),
|
|
873
|
+
tool({
|
|
874
|
+
name: "active_users",
|
|
875
|
+
title: "Active users (DAU/WAU/MAU)",
|
|
876
|
+
description: "Exact distinct-subject counts per interval and across the whole range \u2014 daily/weekly/monthly actives \u2014 from a bucketed subject rollup family. Errors if the named family has no subject dimension or bucket.",
|
|
877
|
+
inputSchema: zod.z.object({
|
|
878
|
+
as: zod.z.string().describe('a bucketed subject family, e.g. "active_accounts"'),
|
|
879
|
+
subjectType: zod.z.string().optional(),
|
|
880
|
+
interval: INTERVAL.optional(),
|
|
881
|
+
...rangeArg,
|
|
882
|
+
...tenantArg
|
|
883
|
+
}),
|
|
884
|
+
async handler(a, ctx) {
|
|
885
|
+
const viewer = await resolve(ctx);
|
|
886
|
+
const scope = pickScope(viewer, a.tenant);
|
|
887
|
+
return q.distinctCount(scope, {
|
|
888
|
+
as: a.as,
|
|
889
|
+
subjectType: a.subjectType,
|
|
890
|
+
interval: a.interval,
|
|
891
|
+
range: parseRange(a.from, a.to)
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}),
|
|
895
|
+
tool({
|
|
896
|
+
name: "funnel_analysis",
|
|
897
|
+
title: "Cohort funnel",
|
|
898
|
+
description: "A cohort funnel over lifetime milestone families: stage counts, conversion %, drop-off, and median time-to-step. Pass ordered stage families; the cohort window is the time range.",
|
|
899
|
+
inputSchema: zod.z.object({
|
|
900
|
+
stages: zod.z.array(zod.z.string()).min(1).describe('ordered rollup families, e.g. ["signed_up","activated","converted"]'),
|
|
901
|
+
anchor: zod.z.string().optional().describe("family assigning cohort membership. Default: stages[0]."),
|
|
902
|
+
exits: zod.z.array(zod.z.string()).optional().describe("families counted but not staged"),
|
|
903
|
+
subjectType: zod.z.string().optional(),
|
|
904
|
+
interval: zod.z.enum(["day", "week", "month"]).optional().describe("also slice the cohort by anchor date"),
|
|
905
|
+
endInclusive: zod.z.boolean().optional(),
|
|
906
|
+
...rangeArg,
|
|
907
|
+
...tenantArg,
|
|
908
|
+
limit: zod.z.number().int().positive().optional()
|
|
909
|
+
}),
|
|
910
|
+
async handler(a, ctx) {
|
|
911
|
+
const viewer = await resolve(ctx);
|
|
912
|
+
const scope = pickScope(viewer, a.tenant);
|
|
913
|
+
const range = parseRange(a.from, a.to);
|
|
914
|
+
return q.funnel(scope, {
|
|
915
|
+
stages: a.stages.map((as) => ({ as })),
|
|
916
|
+
anchor: a.anchor,
|
|
917
|
+
exits: a.exits?.map((as) => ({ as })),
|
|
918
|
+
subjectType: a.subjectType,
|
|
919
|
+
interval: a.interval,
|
|
920
|
+
cohort: { ...range, endInclusive: a.endInclusive === true },
|
|
921
|
+
limit: a.limit
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}),
|
|
925
|
+
// ── deep-dive ─────────────────────────────────────────────────────────────
|
|
926
|
+
tool({
|
|
927
|
+
name: "inspect_trace",
|
|
928
|
+
title: "Inspect a trace",
|
|
929
|
+
description: "Every record sharing one traceId, on a single time axis \u2014 the correlated view of one request across services. Get a traceId from search_events or list_errors.",
|
|
930
|
+
inputSchema: zod.z.object({ traceId: zod.z.string(), ...tenantArg }),
|
|
931
|
+
async handler(a, ctx) {
|
|
932
|
+
const viewer = await resolve(ctx);
|
|
933
|
+
const scope = pickScope(viewer, a.tenant);
|
|
934
|
+
const res = await q.trace(scope, a.traceId);
|
|
935
|
+
return { ...res, items: redactAll(res.items) };
|
|
936
|
+
}
|
|
937
|
+
}),
|
|
938
|
+
tool({
|
|
939
|
+
name: "user_journey",
|
|
940
|
+
title: "User journey",
|
|
941
|
+
description: `One subject's whole story over a range \u2014 records interleaved with lifetime milestones. Pass a subject ref like "user:u_1".`,
|
|
942
|
+
inputSchema: zod.z.object({
|
|
943
|
+
subject: zod.z.string().describe('subject ref, e.g. "user:u_1"'),
|
|
944
|
+
...rangeArg,
|
|
945
|
+
...tenantArg,
|
|
946
|
+
limit: zod.z.number().int().positive().optional()
|
|
947
|
+
}),
|
|
948
|
+
async handler(a, ctx) {
|
|
949
|
+
const viewer = await resolve(ctx);
|
|
950
|
+
const scope = pickScope(viewer, a.tenant);
|
|
951
|
+
const res = await q.journey(scope, a.subject, parseRange(a.from, a.to), { limit: a.limit });
|
|
952
|
+
return {
|
|
953
|
+
...res,
|
|
954
|
+
records: redactAll(res.records),
|
|
955
|
+
subjects: await labelSubjects(res.records)
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
}),
|
|
959
|
+
// ── reports ───────────────────────────────────────────────────────────────
|
|
960
|
+
tool({
|
|
961
|
+
name: "list_reports",
|
|
962
|
+
title: "List saved reports",
|
|
963
|
+
description: "The menu of reports available in this instance: saved (built by people), configured (wired by the host), and registry-derived. Each has a name and a stored query you can run with run_report.",
|
|
964
|
+
inputSchema: zod.z.object({ ...tenantArg }),
|
|
965
|
+
async handler(a, ctx) {
|
|
966
|
+
const viewer = await resolve(ctx);
|
|
967
|
+
const scope = pickScope(viewer, a.tenant);
|
|
968
|
+
const views = await resolveViews({
|
|
969
|
+
ViewModel,
|
|
970
|
+
registry: t.registry,
|
|
971
|
+
configured,
|
|
972
|
+
tenantId: scope,
|
|
973
|
+
viewerRef: viewer.viewerRef
|
|
974
|
+
});
|
|
975
|
+
return { reports: views.map(reportSummary) };
|
|
976
|
+
}
|
|
977
|
+
}),
|
|
978
|
+
tool({
|
|
979
|
+
name: "run_report",
|
|
980
|
+
title: "Run a saved report",
|
|
981
|
+
description: "Execute one named report from list_reports and return its result. Read-only: the report is a stored filter/range that dispatches to the same query the dashboard would run.",
|
|
982
|
+
inputSchema: zod.z.object({
|
|
983
|
+
name: zod.z.string().describe("the report name from list_reports"),
|
|
984
|
+
...rangeArg,
|
|
985
|
+
...tenantArg
|
|
986
|
+
}),
|
|
987
|
+
async handler(a, ctx) {
|
|
988
|
+
const viewer = await resolve(ctx);
|
|
989
|
+
const scope = pickScope(viewer, a.tenant);
|
|
990
|
+
const views = await resolveViews({
|
|
991
|
+
ViewModel,
|
|
992
|
+
registry: t.registry,
|
|
993
|
+
configured,
|
|
994
|
+
tenantId: scope,
|
|
995
|
+
viewerRef: viewer.viewerRef
|
|
996
|
+
});
|
|
997
|
+
const view = views.find((v) => v.name === a.name);
|
|
998
|
+
if (!view) throw new Error(`no report named "${a.name}" \u2014 call list_reports for the menu`);
|
|
999
|
+
return runReport(q, scope, view, a.from, a.to, redactAll);
|
|
1000
|
+
}
|
|
1001
|
+
}),
|
|
1002
|
+
// ── platform ────────────────────────────────────────────────────────────
|
|
1003
|
+
tool({
|
|
1004
|
+
name: "list_tenants",
|
|
1005
|
+
title: "List tenants",
|
|
1006
|
+
description: 'The tenant roster with recent activity \u2014 tenants that emitted anything in the range, each with a rollup-doc count, family count, event total, and last-activity time. Cross-tenant under a platform ("*") viewer; a single row otherwise.',
|
|
1007
|
+
inputSchema: zod.z.object({ ...rangeArg, limit: zod.z.number().int().positive().optional() }),
|
|
1008
|
+
async handler(a, ctx) {
|
|
1009
|
+
const viewer = await resolve(ctx);
|
|
1010
|
+
const { from, to } = parseRange(a.from, a.to);
|
|
1011
|
+
const limit = Math.min(Math.max(1, a.limit ?? 200), 1e3);
|
|
1012
|
+
const match = { lastAt: { $gte: from, $lt: to } };
|
|
1013
|
+
if (!isPlatformScope(viewer.tenantId)) match.tenantId = viewer.tenantId;
|
|
1014
|
+
const rows = await t.models.rollups.aggregate([
|
|
1015
|
+
{ $match: match },
|
|
1016
|
+
{
|
|
1017
|
+
$group: {
|
|
1018
|
+
_id: "$tenantId",
|
|
1019
|
+
rollupDocs: { $sum: 1 },
|
|
1020
|
+
families: { $addToSet: "$as" },
|
|
1021
|
+
events: { $sum: "$count" },
|
|
1022
|
+
lastActivity: { $max: "$lastAt" }
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
{ $sort: { lastActivity: -1 } },
|
|
1026
|
+
{ $limit: limit + 1 }
|
|
1027
|
+
]);
|
|
1028
|
+
const truncated = rows.length > limit;
|
|
1029
|
+
if (truncated) rows.pop();
|
|
1030
|
+
return {
|
|
1031
|
+
tenants: rows.map((r) => ({
|
|
1032
|
+
tenantId: r._id,
|
|
1033
|
+
rollupDocs: r.rollupDocs,
|
|
1034
|
+
families: r.families.length,
|
|
1035
|
+
events: r.events,
|
|
1036
|
+
lastActivity: r.lastActivity
|
|
1037
|
+
})),
|
|
1038
|
+
truncated,
|
|
1039
|
+
dataSource: "rollups"
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
}),
|
|
1043
|
+
// ── ops ─────────────────────────────────────────────────────────────────
|
|
1044
|
+
tool({
|
|
1045
|
+
name: "telemetry_health",
|
|
1046
|
+
title: "Telemetry health",
|
|
1047
|
+
description: 'The health of the telemetry pipeline itself: drop/default/cap counters, quarantined failed writes, and the index budget. Answers "are we silently dropping events?".',
|
|
1048
|
+
inputSchema: zod.z.object({ ...tenantArg }),
|
|
1049
|
+
async handler(a, ctx) {
|
|
1050
|
+
const viewer = await resolve(ctx);
|
|
1051
|
+
const scope = pickScope(viewer, a.tenant);
|
|
1052
|
+
const quarantine = await t.collections.rejects().find(isPlatformScope(scope) ? {} : { "raw.tenantId": scope }, { sort: { at: -1 }, limit: 50 }).toArray().catch(() => []);
|
|
1053
|
+
const indexes = await t.models.telemetry.collection.indexes().catch(() => []);
|
|
1054
|
+
return { counters: t.counters, quarantine, indexCount: indexes.length };
|
|
1055
|
+
}
|
|
1056
|
+
})
|
|
1057
|
+
];
|
|
1058
|
+
return tools;
|
|
1059
|
+
}
|
|
1060
|
+
var toJsonSchema = (tool) => zod.z.toJSONSchema(tool.inputSchema);
|
|
1061
|
+
function registryProjection(t) {
|
|
1062
|
+
return Object.fromEntries(
|
|
1063
|
+
Object.entries(t.registry).map(([name, spec]) => [
|
|
1064
|
+
name,
|
|
1065
|
+
{
|
|
1066
|
+
kind: spec.kind,
|
|
1067
|
+
description: spec.description,
|
|
1068
|
+
attrKeys: spec.attrs ? Object.keys(spec.attrs.shape) : [],
|
|
1069
|
+
metricKeys: spec.metrics ? Object.keys(spec.metrics.shape) : [],
|
|
1070
|
+
indexedAttrs: spec.indexedAttrs ?? [],
|
|
1071
|
+
indexedMetrics: spec.indexedMetrics ?? [],
|
|
1072
|
+
rollups: (spec.rollups ?? []).map((r) => ({
|
|
1073
|
+
as: r.as ?? name,
|
|
1074
|
+
by: r.by,
|
|
1075
|
+
bucket: r.bucket ?? null
|
|
1076
|
+
}))
|
|
1077
|
+
}
|
|
1078
|
+
])
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
function reportSummary(v) {
|
|
1082
|
+
return {
|
|
1083
|
+
name: v.name,
|
|
1084
|
+
origin: v.origin,
|
|
1085
|
+
page: v.page,
|
|
1086
|
+
shared: v.shared,
|
|
1087
|
+
display: v.query?.display
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
async function runReport(q, scope, view, from, to, redactAll) {
|
|
1091
|
+
const query = view.query ?? {};
|
|
1092
|
+
const filters = query.filters ?? {};
|
|
1093
|
+
const range = from || to ? parseRange(from, to) : rangeFromView(query.range);
|
|
1094
|
+
if (filters.rollup) {
|
|
1095
|
+
return { report: view.name, result: await q.rollups(scope, { as: filters.rollup, range }) };
|
|
1096
|
+
}
|
|
1097
|
+
if (query.display === "series") {
|
|
1098
|
+
return { report: view.name, result: await q.series(scope, range, toFilter(filters)) };
|
|
1099
|
+
}
|
|
1100
|
+
const res = await q.records(scope, range, toFilter(filters), { limit: 200 });
|
|
1101
|
+
return { report: view.name, result: { ...res, items: redactAll(res.items) } };
|
|
1102
|
+
}
|
|
1103
|
+
function rangeFromView(range) {
|
|
1104
|
+
const to = /* @__PURE__ */ new Date();
|
|
1105
|
+
const m = /^(\d+)([dh])$/.exec(String(range ?? "7d"));
|
|
1106
|
+
const n = m ? Number(m[1]) : 7;
|
|
1107
|
+
const unit = m?.[2] === "h" ? 36e5 : 864e5;
|
|
1108
|
+
return { from: new Date(to.getTime() - n * unit), to };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
exports.createTelemetryMcp = createTelemetryMcp;
|
|
1112
|
+
exports.toJsonSchema = toJsonSchema;
|
|
1113
|
+
//# sourceMappingURL=mcp.cjs.map
|
|
1114
|
+
//# sourceMappingURL=mcp.cjs.map
|