umami-compass 0.1.3 → 0.3.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.
Files changed (68) hide show
  1. package/README.md +44 -9
  2. package/SECURITY.md +2 -2
  3. package/dist/api/client.d.ts +1 -0
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +76 -19
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/config.d.ts +2 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +8 -6
  10. package/dist/config.js.map +1 -1
  11. package/dist/mcp/insights-utils.d.ts +206 -0
  12. package/dist/mcp/insights-utils.d.ts.map +1 -0
  13. package/dist/mcp/insights-utils.js +505 -0
  14. package/dist/mcp/insights-utils.js.map +1 -0
  15. package/dist/mcp/modules/core.d.ts.map +1 -1
  16. package/dist/mcp/modules/core.js +14 -6
  17. package/dist/mcp/modules/core.js.map +1 -1
  18. package/dist/mcp/modules/events.d.ts.map +1 -1
  19. package/dist/mcp/modules/events.js +3 -3
  20. package/dist/mcp/modules/events.js.map +1 -1
  21. package/dist/mcp/modules/heatmaps.d.ts.map +1 -1
  22. package/dist/mcp/modules/heatmaps.js +3 -2
  23. package/dist/mcp/modules/heatmaps.js.map +1 -1
  24. package/dist/mcp/modules/insights.d.ts +3 -0
  25. package/dist/mcp/modules/insights.d.ts.map +1 -0
  26. package/dist/mcp/modules/insights.js +963 -0
  27. package/dist/mcp/modules/insights.js.map +1 -0
  28. package/dist/mcp/modules/performance.d.ts.map +1 -1
  29. package/dist/mcp/modules/performance.js +5 -3
  30. package/dist/mcp/modules/performance.js.map +1 -1
  31. package/dist/mcp/modules/replay.d.ts.map +1 -1
  32. package/dist/mcp/modules/replay.js +3 -2
  33. package/dist/mcp/modules/replay.js.map +1 -1
  34. package/dist/mcp/modules/reports.d.ts.map +1 -1
  35. package/dist/mcp/modules/reports.js +71 -19
  36. package/dist/mcp/modules/reports.js.map +1 -1
  37. package/dist/mcp/modules/revenue.d.ts.map +1 -1
  38. package/dist/mcp/modules/revenue.js +2 -2
  39. package/dist/mcp/modules/revenue.js.map +1 -1
  40. package/dist/mcp/modules/sessions.d.ts.map +1 -1
  41. package/dist/mcp/modules/sessions.js +4 -4
  42. package/dist/mcp/modules/sessions.js.map +1 -1
  43. package/dist/mcp/report-utils.d.ts.map +1 -1
  44. package/dist/mcp/report-utils.js +2 -1
  45. package/dist/mcp/report-utils.js.map +1 -1
  46. package/dist/mcp/result.d.ts +13 -1
  47. package/dist/mcp/result.d.ts.map +1 -1
  48. package/dist/mcp/result.js +88 -2
  49. package/dist/mcp/result.js.map +1 -1
  50. package/dist/mcp/schemas.d.ts +1185 -20
  51. package/dist/mcp/schemas.d.ts.map +1 -1
  52. package/dist/mcp/schemas.js +182 -21
  53. package/dist/mcp/schemas.js.map +1 -1
  54. package/dist/mcp/traffic-segmentation.d.ts +82 -0
  55. package/dist/mcp/traffic-segmentation.d.ts.map +1 -0
  56. package/dist/mcp/traffic-segmentation.js +256 -0
  57. package/dist/mcp/traffic-segmentation.js.map +1 -0
  58. package/dist/server-info.d.ts +32 -0
  59. package/dist/server-info.d.ts.map +1 -0
  60. package/dist/server-info.js +34 -0
  61. package/dist/server-info.js.map +1 -0
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +152 -31
  64. package/dist/server.js.map +1 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/package.json +1 -1
  68. package/server.json +9 -4
@@ -0,0 +1,963 @@
1
+ import { z } from "zod";
2
+ import { toSafeError, UmamiError } from "../../api/errors.js";
3
+ import { parseTimeRange } from "../../time.js";
4
+ import { comparePerformance, compareTraffic, comparisonPeriod, fetchWebsiteStats, finiteNumber, isoPeriod, mapConcurrent, normalizePeriod, percentChange, TRAFFIC_DIMENSIONS, totalsChanges, } from "../insights-utils.js";
5
+ import { requirePagedResponse } from "../report-utils.js";
6
+ import { READ_ONLY_ANNOTATIONS, runTool } from "../result.js";
7
+ import { appendReferrerExclusions, filtersSchema, outputSchema, pageviewsDataSchema, parseUpstream, rangeQuery, segmentedFiltersSchema, serializeFilters, seriesRangeQuery, timeSchema, timezoneSchema, trafficSegmentSchema, uuidSchema, } from "../schemas.js";
8
+ import { assessReferralSpam, } from "../traffic-segmentation.js";
9
+ const trafficDimensionSchema = z.enum(TRAFFIC_DIMENSIONS);
10
+ const websiteLimitSchema = z.number().int().min(1).max(50).default(25);
11
+ const MIN_RELEASE_TRAFFIC_CHANGE_PERCENT = 10;
12
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
13
+ const releaseTimestampSchema = timeSchema.refine((value) => typeof value === "number" || (/T/i.test(value) && /(Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())), "releaseAt must be Unix milliseconds or an ISO 8601 date-time with an explicit timezone");
14
+ async function safeReferralSpamAssessment(client, input) {
15
+ try {
16
+ return { status: "available", ...(await assessReferralSpam(client, input)) };
17
+ }
18
+ catch (error) {
19
+ return { status: "unavailable", error: toSafeError(error) };
20
+ }
21
+ }
22
+ async function trafficQualityComparison(client, input) {
23
+ const assess = (period) => safeReferralSpamAssessment(client, {
24
+ filters: input.filters,
25
+ maxRangeDays: input.maxRangeDays,
26
+ period,
27
+ signal: input.signal,
28
+ websiteId: input.websiteId,
29
+ });
30
+ const [current, comparison] = await Promise.all([
31
+ assess(input.current),
32
+ assess(input.comparison),
33
+ ]);
34
+ if (input.trafficSegment === "human" &&
35
+ (current.status !== "available" || comparison.status !== "available")) {
36
+ throw new UmamiError("UPSTREAM_ERROR", "The human-traffic preset could not assess referral spam for both periods.");
37
+ }
38
+ const excludedReferrers = input.trafficSegment === "human"
39
+ ? [
40
+ ...new Set([current, comparison].flatMap((assessment) => assessment.status === "available" ? assessment.excludedReferrers : [])),
41
+ ]
42
+ : [];
43
+ return {
44
+ method: "conservative_heuristic",
45
+ trafficSegment: input.trafficSegment,
46
+ current,
47
+ comparison,
48
+ excludedReferrers,
49
+ exclusionApplied: excludedReferrers.length > 0,
50
+ };
51
+ }
52
+ function isRecord(value) {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
55
+ function websitePage(value) {
56
+ const page = requirePagedResponse(value);
57
+ if (!page.data.every((item) => isRecord(item) && typeof item.id === "string" && item.id.length > 0)) {
58
+ throw new UmamiError("INVALID_RESPONSE", "Umami returned an invalid website list.");
59
+ }
60
+ return page;
61
+ }
62
+ function websiteSummary(website) {
63
+ return {
64
+ id: website.id,
65
+ ...(typeof website.name === "string" ? { name: website.name } : {}),
66
+ ...(typeof website.domain === "string" ? { domain: website.domain } : {}),
67
+ ...(typeof website.teamId === "string" ? { teamId: website.teamId } : {}),
68
+ };
69
+ }
70
+ function normalizeDomain(value) {
71
+ const trimmed = value.trim().toLowerCase();
72
+ try {
73
+ const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
74
+ return url.hostname.replace(/^www\./, "").replace(/\.$/, "");
75
+ }
76
+ catch {
77
+ return (trimmed
78
+ .replace(/^https?:\/\//, "")
79
+ .split(/[/?#]/, 1)[0]
80
+ ?.replace(/^www\./, "")
81
+ .replace(/\.$/, "") ?? trimmed);
82
+ }
83
+ }
84
+ function scoreWebsite(website, query, normalizedDomain) {
85
+ const id = website.id.toLowerCase();
86
+ const name = typeof website.name === "string" ? website.name.trim().toLowerCase() : "";
87
+ const domain = typeof website.domain === "string" ? normalizeDomain(website.domain) : "";
88
+ if (id === query)
89
+ return { score: 100, matchType: "id", confidence: "exact" };
90
+ if (domain && domain === normalizedDomain) {
91
+ return { score: 95, matchType: "domain", confidence: "exact" };
92
+ }
93
+ if (name && name === query) {
94
+ return { score: 90, matchType: "name", confidence: "exact" };
95
+ }
96
+ if (domain && (domain.startsWith(normalizedDomain) || normalizedDomain.startsWith(domain))) {
97
+ return { score: 70, matchType: "domain", confidence: "strong" };
98
+ }
99
+ if (name?.startsWith(query)) {
100
+ return { score: 65, matchType: "name", confidence: "strong" };
101
+ }
102
+ if (domain?.includes(normalizedDomain) || name?.includes(query)) {
103
+ return {
104
+ score: 50,
105
+ matchType: domain.includes(normalizedDomain) ? "domain" : "name",
106
+ confidence: "fuzzy",
107
+ };
108
+ }
109
+ return { score: 0, matchType: "none", confidence: "none" };
110
+ }
111
+ function dateValue(value) {
112
+ if (typeof value === "number" && Number.isFinite(value))
113
+ return value;
114
+ if (typeof value !== "string")
115
+ return undefined;
116
+ const parsed = Date.parse(value);
117
+ return Number.isFinite(parsed) ? parsed : undefined;
118
+ }
119
+ function currentTotals(stats) {
120
+ return {
121
+ pageviews: stats.pageviews,
122
+ visitors: stats.visitors,
123
+ visits: stats.visits,
124
+ bounces: stats.bounces,
125
+ totaltime: stats.totaltime,
126
+ };
127
+ }
128
+ const ZERO_TOTALS = {
129
+ pageviews: 0,
130
+ visitors: 0,
131
+ visits: 0,
132
+ bounces: 0,
133
+ totaltime: 0,
134
+ };
135
+ function sumTotals(values) {
136
+ return values.reduce((total, value) => ({
137
+ pageviews: total.pageviews + value.pageviews,
138
+ visitors: total.visitors + value.visitors,
139
+ visits: total.visits + value.visits,
140
+ bounces: total.bounces + value.bounces,
141
+ totaltime: total.totaltime + value.totaltime,
142
+ }), { ...ZERO_TOTALS });
143
+ }
144
+ async function portfolioSite(client, website, period, baselinePeriod, maxRangeDays, staleAfterHours, anomalyThresholdPercent, anomalyMinimumPageviews, now, signal) {
145
+ try {
146
+ const [stats, baselineStats, dateRangeResult] = await Promise.all([
147
+ fetchWebsiteStats(client, website.id, period, maxRangeDays, undefined, signal),
148
+ fetchWebsiteStats(client, website.id, baselinePeriod, maxRangeDays, undefined, signal),
149
+ client
150
+ .get(`websites/${encodeURIComponent(website.id)}/daterange`, undefined, signal)
151
+ .catch((error) => ({ dateRangeError: toSafeError(error) })),
152
+ ]);
153
+ const current = currentTotals(stats);
154
+ const comparison = currentTotals(baselineStats);
155
+ const lastDataAt = isRecord(dateRangeResult) && !("dateRangeError" in dateRangeResult)
156
+ ? dateValue(dateRangeResult.endDate)
157
+ : undefined;
158
+ const freshnessUnavailable = isRecord(dateRangeResult) && "dateRangeError" in dateRangeResult;
159
+ const stale = !freshnessUnavailable &&
160
+ (lastDataAt === undefined || now - lastDataAt > staleAfterHours * 3_600_000);
161
+ const pageviewPercent = percentChange(current.pageviews, comparison.pageviews);
162
+ const anomalous = (comparison.pageviews === 0 && current.pageviews >= anomalyMinimumPageviews) ||
163
+ (pageviewPercent !== null &&
164
+ Math.abs(pageviewPercent) >= anomalyThresholdPercent &&
165
+ Math.max(current.pageviews, comparison.pageviews) >= anomalyMinimumPageviews);
166
+ const dataStatus = freshnessUnavailable
167
+ ? "freshness_unknown"
168
+ : lastDataAt === undefined
169
+ ? "never_tracked"
170
+ : current.pageviews === 0
171
+ ? "no_data_in_range"
172
+ : stale
173
+ ? "stale"
174
+ : "fresh";
175
+ return {
176
+ status: "available",
177
+ website: websiteSummary(website),
178
+ current,
179
+ comparison,
180
+ changes: totalsChanges(current, comparison),
181
+ tracking: {
182
+ dataStatus,
183
+ ...(lastDataAt === undefined ? {} : { lastDataAt: new Date(lastDataAt).toISOString() }),
184
+ stale,
185
+ ...(isRecord(dateRangeResult) && "dateRangeError" in dateRangeResult
186
+ ? { dateRangeError: dateRangeResult.dateRangeError }
187
+ : {}),
188
+ },
189
+ anomaly: anomalous
190
+ ? {
191
+ metric: "pageviews",
192
+ percent: pageviewPercent,
193
+ direction: comparison.pageviews === 0
194
+ ? "new_activity"
195
+ : (pageviewPercent ?? 0) > 0
196
+ ? "spike"
197
+ : "drop",
198
+ }
199
+ : null,
200
+ };
201
+ }
202
+ catch (error) {
203
+ return {
204
+ status: "unavailable",
205
+ website: websiteSummary(website),
206
+ error: toSafeError(error),
207
+ };
208
+ }
209
+ }
210
+ function parseMetricNames(value) {
211
+ if (!Array.isArray(value)) {
212
+ throw new UmamiError("INVALID_RESPONSE", "Umami returned invalid metric data.");
213
+ }
214
+ return value.flatMap((item) => {
215
+ if (!isRecord(item) || typeof item.x !== "string")
216
+ return [];
217
+ const number = finiteNumber(item.y);
218
+ return number === undefined ? [] : [{ name: item.x, value: number }];
219
+ });
220
+ }
221
+ async function healthSite(client, website, period, options) {
222
+ const issues = [];
223
+ const checks = {};
224
+ await mapConcurrent(options.checks, 1, async (check) => {
225
+ try {
226
+ if (check === "traffic") {
227
+ const [stats, baselineStats, range] = await Promise.all([
228
+ fetchWebsiteStats(client, website.id, period, options.maxRangeDays, undefined, options.signal),
229
+ fetchWebsiteStats(client, website.id, comparisonPeriod(period, "previous"), options.maxRangeDays, undefined, options.signal),
230
+ client.get(`websites/${encodeURIComponent(website.id)}/daterange`, undefined, options.signal),
231
+ ]);
232
+ const lastDataAt = dateValue(range.endDate);
233
+ const comparison = currentTotals(baselineStats);
234
+ const change = percentChange(stats.pageviews, comparison.pageviews);
235
+ checks.traffic = {
236
+ status: "available",
237
+ pageviews: stats.pageviews,
238
+ comparisonPageviews: comparison.pageviews,
239
+ changePercent: change,
240
+ ...(lastDataAt === undefined ? {} : { lastDataAt: new Date(lastDataAt).toISOString() }),
241
+ };
242
+ if (lastDataAt === undefined) {
243
+ issues.push({
244
+ check,
245
+ code: "NO_ANALYTICS_DATA",
246
+ message: "The website has no discoverable analytics data range.",
247
+ severity: "error",
248
+ });
249
+ }
250
+ else if (Date.now() - lastDataAt > options.staleAfterHours * 3_600_000) {
251
+ issues.push({
252
+ check,
253
+ code: "STALE_TRACKING",
254
+ message: "The most recent analytics event is older than the configured freshness limit.",
255
+ severity: "warning",
256
+ evidence: { lastDataAt: new Date(lastDataAt).toISOString() },
257
+ });
258
+ }
259
+ if (stats.pageviews === 0) {
260
+ issues.push({
261
+ check,
262
+ code: "NO_TRAFFIC_IN_LOOKBACK",
263
+ message: "No pageviews were recorded in the health-check lookback window.",
264
+ severity: "warning",
265
+ });
266
+ }
267
+ else if (change !== null &&
268
+ change <= -options.dropThresholdPercent &&
269
+ comparison.pageviews >= options.minimumPageviews) {
270
+ issues.push({
271
+ check,
272
+ code: "TRAFFIC_DROP",
273
+ message: "Pageviews dropped beyond the configured threshold.",
274
+ severity: "warning",
275
+ evidence: { changePercent: change },
276
+ });
277
+ }
278
+ return;
279
+ }
280
+ if (check === "domain") {
281
+ const hostnames = parseMetricNames(await client.get(`websites/${encodeURIComponent(website.id)}/metrics`, {
282
+ ...rangeQuery(period.startAt, period.endAt, options.maxRangeDays),
283
+ type: "hostname",
284
+ limit: 10,
285
+ offset: 0,
286
+ }, options.signal));
287
+ const configuredDomain = typeof website.domain === "string" ? normalizeDomain(website.domain) : "";
288
+ const observedDomains = hostnames.map(({ name }) => normalizeDomain(name));
289
+ checks.domain = {
290
+ status: "available",
291
+ configuredDomain: configuredDomain || null,
292
+ observedHostnames: hostnames,
293
+ };
294
+ if (!configuredDomain) {
295
+ issues.push({
296
+ check,
297
+ code: "DOMAIN_MISSING",
298
+ message: "The Umami website has no configured domain.",
299
+ severity: "warning",
300
+ });
301
+ }
302
+ else if (observedDomains.length > 0 &&
303
+ !observedDomains.some((domain) => domain === configuredDomain || domain.endsWith(`.${configuredDomain}`))) {
304
+ issues.push({
305
+ check,
306
+ code: "DOMAIN_MISMATCH",
307
+ message: "Recent observed hostnames do not match the configured website domain.",
308
+ severity: "warning",
309
+ evidence: { configuredDomain, observedDomains },
310
+ });
311
+ }
312
+ return;
313
+ }
314
+ if (check === "events") {
315
+ const page = requirePagedResponse(await client.get(`websites/${encodeURIComponent(website.id)}/events`, {
316
+ ...rangeQuery(period.startAt, period.endAt, options.maxRangeDays, {
317
+ filters: { eventType: 2 },
318
+ }),
319
+ page: 1,
320
+ pageSize: 1,
321
+ }, options.signal));
322
+ checks.events = { status: "available", count: page.count };
323
+ if (options.expectEvents && page.count === 0) {
324
+ issues.push({
325
+ check,
326
+ code: "EXPECTED_EVENTS_MISSING",
327
+ message: "No custom events were recorded in the health-check lookback window.",
328
+ severity: "warning",
329
+ });
330
+ }
331
+ return;
332
+ }
333
+ if (check === "referral_spam") {
334
+ const assessment = await assessReferralSpam(client, {
335
+ filters: {},
336
+ maxRangeDays: options.maxRangeDays,
337
+ period,
338
+ signal: options.signal,
339
+ websiteId: website.id,
340
+ });
341
+ checks.referral_spam = { status: "available", ...assessment };
342
+ if (assessment.suspiciousReferrers.length > 0) {
343
+ issues.push({
344
+ check,
345
+ code: "SUSPECTED_REFERRAL_SPAM",
346
+ message: "One or more referrers match a conservative generated-domain, high-bounce, near-zero-duration spam pattern.",
347
+ severity: "warning",
348
+ evidence: {
349
+ suspiciousReferrers: assessment.suspiciousReferrers,
350
+ heuristic: true,
351
+ },
352
+ });
353
+ }
354
+ return;
355
+ }
356
+ const recorder = await client.get(`websites/${encodeURIComponent(website.id)}/recorder`, undefined, options.signal);
357
+ checks.recorder = {
358
+ status: "available",
359
+ enabled: recorder.enabled === true,
360
+ replayEnabled: recorder.replayEnabled === true,
361
+ heatmapEnabled: recorder.heatmapEnabled === true,
362
+ ...(finiteNumber(recorder.sampleRate) === undefined
363
+ ? {}
364
+ : { sampleRate: finiteNumber(recorder.sampleRate) }),
365
+ };
366
+ if ((options.expectReplay || options.expectHeatmap) && recorder.enabled !== true) {
367
+ issues.push({
368
+ check,
369
+ code: "RECORDER_DISABLED",
370
+ message: "Recorder is disabled although a recorder feature is expected.",
371
+ severity: "warning",
372
+ });
373
+ }
374
+ else {
375
+ if (options.expectReplay && recorder.replayEnabled !== true) {
376
+ issues.push({
377
+ check,
378
+ code: "REPLAY_DISABLED",
379
+ message: "Session replay is disabled but expected.",
380
+ severity: "warning",
381
+ });
382
+ }
383
+ if (options.expectHeatmap && recorder.heatmapEnabled !== true) {
384
+ issues.push({
385
+ check,
386
+ code: "HEATMAP_DISABLED",
387
+ message: "Heatmaps are disabled but expected.",
388
+ severity: "warning",
389
+ });
390
+ }
391
+ }
392
+ }
393
+ catch (error) {
394
+ const safeError = toSafeError(error);
395
+ checks[check] = { status: "unavailable", error: safeError };
396
+ const unavailableIsWarning = safeError.code === "FORBIDDEN" || check === "referral_spam";
397
+ issues.push({
398
+ check,
399
+ code: safeError.code === "FORBIDDEN" ? "PERMISSION_MISSING" : "CHECK_UNAVAILABLE",
400
+ message: safeError.message,
401
+ severity: unavailableIsWarning ? "warning" : "error",
402
+ });
403
+ }
404
+ });
405
+ return {
406
+ website: websiteSummary(website),
407
+ status: issues.some(({ severity }) => severity === "error")
408
+ ? "error"
409
+ : issues.length > 0
410
+ ? "warning"
411
+ : "healthy",
412
+ checks,
413
+ issues,
414
+ };
415
+ }
416
+ export const insightsModule = {
417
+ id: "insights",
418
+ access: "read",
419
+ register(server, { client, config }) {
420
+ server.registerTool("resolve_website", {
421
+ title: "Resolve an Umami website",
422
+ description: "Resolve a website UUID from a UUID, domain, URL, or website name. Ambiguous matches return bounded candidates instead of guessing.",
423
+ inputSchema: {
424
+ query: z.string().trim().min(1).max(500),
425
+ candidateLimit: z.number().int().min(1).max(20).default(10),
426
+ },
427
+ outputSchema,
428
+ annotations: READ_ONLY_ANNOTATIONS,
429
+ }, ({ query, candidateLimit }, extra) => runTool(async () => {
430
+ const normalizedQuery = query.trim().toLowerCase();
431
+ if (UUID_PATTERN.test(normalizedQuery)) {
432
+ try {
433
+ const website = await client.getWebsite(normalizedQuery, extra.signal);
434
+ return {
435
+ status: "resolved",
436
+ matchType: "id",
437
+ confidence: "exact",
438
+ website: websiteSummary(website),
439
+ };
440
+ }
441
+ catch (error) {
442
+ if (!(error instanceof UmamiError) || error.code !== "NOT_FOUND")
443
+ throw error;
444
+ return { status: "not_found", candidates: [] };
445
+ }
446
+ }
447
+ const domain = normalizeDomain(query);
448
+ const page = websitePage(await client.listWebsites({ page: 1, pageSize: 100, search: domain || normalizedQuery }, extra.signal));
449
+ const candidates = page.data
450
+ .map((website) => ({ website, ...scoreWebsite(website, normalizedQuery, domain) }))
451
+ .filter(({ score }) => score > 0)
452
+ .sort((left, right) => right.score - left.score ||
453
+ String(left.website.name ?? left.website.domain ?? left.website.id).localeCompare(String(right.website.name ?? right.website.domain ?? right.website.id)))
454
+ .slice(0, candidateLimit);
455
+ if (candidates.length === 0)
456
+ return { status: "not_found", candidates: [] };
457
+ const [best, second] = candidates;
458
+ if (best?.confidence === "exact" && (!second || best.score > second.score)) {
459
+ return {
460
+ status: "resolved",
461
+ matchType: best.matchType,
462
+ confidence: best.confidence,
463
+ website: websiteSummary(best.website),
464
+ candidatesConsidered: page.count,
465
+ };
466
+ }
467
+ return {
468
+ status: "ambiguous",
469
+ candidates: candidates.map(({ website, matchType, confidence, score }) => ({
470
+ website: websiteSummary(website),
471
+ matchType,
472
+ confidence,
473
+ score,
474
+ })),
475
+ };
476
+ }));
477
+ server.registerTool("get_portfolio_overview", {
478
+ title: "Get a portfolio analytics overview",
479
+ description: "Summarize bounded aggregate traffic across visible websites, including period changes, growth and decline leaders, stale tracking, failures, and suspicious jumps.",
480
+ inputSchema: {
481
+ start: timeSchema,
482
+ end: timeSchema,
483
+ websiteLimit: websiteLimitSchema,
484
+ staleAfterHours: z.number().int().min(1).max(8_760).default(48),
485
+ anomalyThresholdPercent: z.number().min(10).max(10_000).default(100),
486
+ anomalyMinimumPageviews: z.number().int().min(0).max(1_000_000_000).default(100),
487
+ },
488
+ outputSchema,
489
+ annotations: READ_ONLY_ANNOTATIONS,
490
+ }, ({ start, end, websiteLimit, staleAfterHours, anomalyThresholdPercent, anomalyMinimumPageviews, }, extra) => runTool(async () => {
491
+ const period = normalizePeriod(start, end, config.maxRangeDays);
492
+ const page = websitePage(await client.listWebsites({ page: 1, pageSize: websiteLimit }, extra.signal));
493
+ const now = Date.now();
494
+ const baselinePeriod = comparisonPeriod(period, "previous");
495
+ const sites = await mapConcurrent(page.data, 4, (website) => portfolioSite(client, website, period, baselinePeriod, config.maxRangeDays, staleAfterHours, anomalyThresholdPercent, anomalyMinimumPageviews, now, extra.signal));
496
+ const available = sites.filter((site) => site.status === "available");
497
+ const unavailable = sites.filter((site) => site.status === "unavailable");
498
+ const current = sumTotals(available.map(({ current: totals }) => totals));
499
+ const comparison = sumTotals(available.map(({ comparison: totals }) => totals));
500
+ const dataStatus = available.length === 0
501
+ ? unavailable.length > 0
502
+ ? "unknown"
503
+ : "empty"
504
+ : current.pageviews === 0 &&
505
+ current.visitors === 0 &&
506
+ current.visits === 0 &&
507
+ comparison.pageviews === 0 &&
508
+ comparison.visitors === 0 &&
509
+ comparison.visits === 0
510
+ ? "empty"
511
+ : "available";
512
+ const ranked = available
513
+ .map((site) => ({
514
+ website: site.website,
515
+ currentPageviews: site.current.pageviews,
516
+ comparisonPageviews: site.comparison.pageviews,
517
+ percent: percentChange(site.current.pageviews, site.comparison.pageviews),
518
+ }))
519
+ .filter((site) => site.percent !== null);
520
+ return {
521
+ dataStatus,
522
+ generatedAt: new Date(now).toISOString(),
523
+ period: isoPeriod(period),
524
+ comparisonPeriod: isoPeriod(baselinePeriod),
525
+ coverage: {
526
+ visibleWebsites: page.count,
527
+ analyzedWebsites: sites.length,
528
+ successfulWebsites: available.length,
529
+ failedWebsites: unavailable.length,
530
+ websiteLimit,
531
+ websitesTruncated: page.count > sites.length,
532
+ },
533
+ totals: {
534
+ current,
535
+ comparison,
536
+ changes: totalsChanges(current, comparison),
537
+ },
538
+ leaders: {
539
+ growth: ranked
540
+ .filter(({ percent }) => percent > 0)
541
+ .sort((a, b) => b.percent - a.percent)
542
+ .slice(0, 5),
543
+ decline: ranked
544
+ .filter(({ percent }) => percent < 0)
545
+ .sort((a, b) => a.percent - b.percent)
546
+ .slice(0, 5),
547
+ },
548
+ attention: {
549
+ staleOrMissing: available
550
+ .filter(({ tracking }) => tracking.dataStatus !== "fresh")
551
+ .map(({ website, tracking }) => ({ website, tracking })),
552
+ anomalies: available
553
+ .filter(({ anomaly }) => anomaly !== null)
554
+ .map(({ website, anomaly }) => ({ website, anomaly })),
555
+ failures: unavailable,
556
+ },
557
+ sites,
558
+ };
559
+ }, { range: { start, end } }));
560
+ server.registerTool("explain_traffic_change", {
561
+ title: "Explain a traffic change",
562
+ description: "Compare traffic with a previous, year-over-year, or custom period and rank observed changes across pages, referrers, countries, devices, channels, and events. Results describe association, not causation.",
563
+ inputSchema: {
564
+ websiteId: uuidSchema,
565
+ start: timeSchema,
566
+ end: timeSchema,
567
+ comparisonMode: z.enum(["previous", "year_over_year", "custom"]).default("previous"),
568
+ comparisonStart: timeSchema.optional(),
569
+ comparisonEnd: timeSchema.optional(),
570
+ dimensions: z
571
+ .array(trafficDimensionSchema)
572
+ .min(1)
573
+ .max(6)
574
+ .refine((items) => new Set(items).size === items.length, "dimensions must be unique")
575
+ .optional(),
576
+ limit: z.number().int().min(1).max(20).default(10),
577
+ filters: segmentedFiltersSchema.optional(),
578
+ trafficSegment: trafficSegmentSchema,
579
+ timezone: timezoneSchema,
580
+ },
581
+ outputSchema,
582
+ annotations: READ_ONLY_ANNOTATIONS,
583
+ }, ({ websiteId, start, end, comparisonMode, comparisonStart, comparisonEnd, dimensions, limit, filters, trafficSegment, timezone, }, extra) => runTool(async () => {
584
+ const current = normalizePeriod(start, end, config.maxRangeDays);
585
+ let comparison;
586
+ if (comparisonMode === "custom") {
587
+ if (comparisonStart === undefined || comparisonEnd === undefined) {
588
+ throw new UmamiError("VALIDATION_ERROR", "comparisonStart and comparisonEnd are required for comparisonMode=custom.");
589
+ }
590
+ comparison = normalizePeriod(comparisonStart, comparisonEnd, config.maxRangeDays);
591
+ }
592
+ else {
593
+ if (comparisonStart !== undefined || comparisonEnd !== undefined) {
594
+ throw new UmamiError("VALIDATION_ERROR", "comparisonStart and comparisonEnd can only be used with comparisonMode=custom.");
595
+ }
596
+ comparison = comparisonPeriod(current, comparisonMode);
597
+ }
598
+ const website = await client.getWebsite(websiteId, extra.signal);
599
+ const { channel, ...analyticsFilters } = filters ?? {};
600
+ const quality = await trafficQualityComparison(client, {
601
+ websiteId,
602
+ current,
603
+ comparison,
604
+ filters: serializeFilters(analyticsFilters),
605
+ maxRangeDays: config.maxRangeDays,
606
+ signal: extra.signal,
607
+ trafficSegment,
608
+ });
609
+ const effectiveDimensions = dimensions ??
610
+ (channel
611
+ ? ["channel", "device"]
612
+ : [...TRAFFIC_DIMENSIONS]);
613
+ const traffic = await compareTraffic(client, {
614
+ website: websiteSummary(website),
615
+ current,
616
+ comparison,
617
+ dimensions: effectiveDimensions,
618
+ limit,
619
+ maxRangeDays: config.maxRangeDays,
620
+ filters: analyticsFilters,
621
+ channel: channel,
622
+ excludedReferrers: quality.excludedReferrers,
623
+ signal: extra.signal,
624
+ });
625
+ const suspiciousCount = [quality.current, quality.comparison].reduce((total, assessment) => total +
626
+ (assessment.status === "available" ? assessment.suspiciousReferrers.length : 0), 0);
627
+ const qualityExplanation = suspiciousCount === 0
628
+ ? ""
629
+ : trafficSegment === "human"
630
+ ? ` The human-traffic preset excluded ${quality.excludedReferrers.length} suspicious referral domain(s) using a conservative heuristic.`
631
+ : ` ${suspiciousCount} period-level referrer row(s) match a conservative referral-spam pattern; verify the trafficQuality evidence or rerun with trafficSegment=human.`;
632
+ return {
633
+ comparisonMode,
634
+ timezone,
635
+ ...traffic,
636
+ explanation: `${traffic.explanation}${qualityExplanation}`,
637
+ trafficQuality: quality,
638
+ };
639
+ }, { websiteId, range: { start, end }, timezone }));
640
+ server.registerTool("compare_traffic_series", {
641
+ title: "Compare traffic time series",
642
+ description: "Return aligned current and comparison traffic buckets to locate the exact day or hour when traffic changed.",
643
+ inputSchema: {
644
+ websiteId: uuidSchema,
645
+ start: timeSchema,
646
+ end: timeSchema,
647
+ comparisonMode: z.enum(["previous", "year_over_year", "custom"]).default("previous"),
648
+ comparisonStart: timeSchema.optional(),
649
+ comparisonEnd: timeSchema.optional(),
650
+ unit: z.enum(["minute", "hour", "day", "month", "year"]).default("day"),
651
+ filters: filtersSchema.optional(),
652
+ trafficSegment: trafficSegmentSchema,
653
+ timezone: timezoneSchema,
654
+ },
655
+ outputSchema,
656
+ annotations: READ_ONLY_ANNOTATIONS,
657
+ }, ({ websiteId, start, end, comparisonMode, comparisonStart, comparisonEnd, unit, filters, trafficSegment, timezone, }, extra) => runTool(async () => {
658
+ const current = normalizePeriod(start, end, config.maxRangeDays);
659
+ let comparison;
660
+ if (comparisonMode === "custom") {
661
+ if (comparisonStart === undefined || comparisonEnd === undefined) {
662
+ throw new UmamiError("VALIDATION_ERROR", "comparisonStart and comparisonEnd are required for comparisonMode=custom.");
663
+ }
664
+ comparison = normalizePeriod(comparisonStart, comparisonEnd, config.maxRangeDays);
665
+ }
666
+ else {
667
+ if (comparisonStart !== undefined || comparisonEnd !== undefined) {
668
+ throw new UmamiError("VALIDATION_ERROR", "comparisonStart and comparisonEnd can only be used with comparisonMode=custom.");
669
+ }
670
+ comparison = comparisonPeriod(current, comparisonMode);
671
+ }
672
+ await client.assertWebsiteAccessible(websiteId, extra.signal);
673
+ const serializedFilters = serializeFilters(filters);
674
+ const quality = await trafficQualityComparison(client, {
675
+ websiteId,
676
+ current,
677
+ comparison,
678
+ filters: serializedFilters,
679
+ maxRangeDays: config.maxRangeDays,
680
+ signal: extra.signal,
681
+ trafficSegment,
682
+ });
683
+ const effectiveFilters = appendReferrerExclusions(serializedFilters, quality.excludedReferrers);
684
+ const fetchSeries = async (period) => parseUpstream(pageviewsDataSchema, await client.get(`websites/${encodeURIComponent(websiteId)}/pageviews`, seriesRangeQuery(period.startAt, period.endAt, config.maxRangeDays, {
685
+ serializedFilters: effectiveFilters,
686
+ timezone,
687
+ unit,
688
+ seriesCount: 2,
689
+ }), extra.signal), "pageview series");
690
+ const [currentSeries, comparisonSeries] = await Promise.all([
691
+ fetchSeries(current),
692
+ fetchSeries(comparison),
693
+ ]);
694
+ const currentSessions = new Map(currentSeries.sessions.map(({ x, y }) => [String(x), y]));
695
+ const comparisonSessions = new Map(comparisonSeries.sessions.map(({ x, y }) => [String(x), y]));
696
+ const bucketCount = Math.max(currentSeries.pageviews.length, comparisonSeries.pageviews.length);
697
+ const buckets = Array.from({ length: bucketCount }, (_, index) => {
698
+ const currentPoint = currentSeries.pageviews[index];
699
+ const comparisonPoint = comparisonSeries.pageviews[index];
700
+ const currentPageviews = currentPoint?.y ?? 0;
701
+ const comparisonPageviews = comparisonPoint?.y ?? 0;
702
+ return {
703
+ index,
704
+ current: currentPoint
705
+ ? {
706
+ x: currentPoint.x,
707
+ pageviews: currentPageviews,
708
+ sessions: currentSessions.get(String(currentPoint.x)) ?? 0,
709
+ }
710
+ : null,
711
+ comparison: comparisonPoint
712
+ ? {
713
+ x: comparisonPoint.x,
714
+ pageviews: comparisonPageviews,
715
+ sessions: comparisonSessions.get(String(comparisonPoint.x)) ?? 0,
716
+ }
717
+ : null,
718
+ pageviewChange: {
719
+ absolute: currentPageviews - comparisonPageviews,
720
+ percent: percentChange(currentPageviews, comparisonPageviews),
721
+ },
722
+ };
723
+ });
724
+ return {
725
+ dataStatus: currentSeries.pageviews.length === 0 && comparisonSeries.pageviews.length === 0
726
+ ? "empty"
727
+ : "available",
728
+ comparisonMode,
729
+ unit,
730
+ timezone,
731
+ currentPeriod: isoPeriod(current),
732
+ comparisonPeriod: isoPeriod(comparison),
733
+ current: currentSeries,
734
+ comparison: comparisonSeries,
735
+ buckets,
736
+ trafficQuality: quality,
737
+ };
738
+ }, { websiteId, range: { start, end }, timezone }));
739
+ server.registerTool("analyze_release_impact", {
740
+ title: "Analyze release impact",
741
+ description: "Compare equal pre- and post-release windows across traffic breakdowns and Core Web Vitals. Recent releases use a partial post window and an equally shortened pre window.",
742
+ inputSchema: {
743
+ websiteId: uuidSchema,
744
+ releaseAt: releaseTimestampSchema,
745
+ windowDays: z.number().int().min(1).max(30).default(7),
746
+ dimensions: z
747
+ .array(trafficDimensionSchema)
748
+ .min(1)
749
+ .max(6)
750
+ .refine((items) => new Set(items).size === items.length, "dimensions must be unique")
751
+ .optional(),
752
+ limit: z.number().int().min(1).max(20).default(10),
753
+ includePerformance: z.boolean().default(true),
754
+ filters: segmentedFiltersSchema.optional(),
755
+ trafficSegment: trafficSegmentSchema,
756
+ timezone: timezoneSchema,
757
+ },
758
+ outputSchema,
759
+ annotations: READ_ONLY_ANNOTATIONS,
760
+ }, ({ websiteId, releaseAt, windowDays, dimensions, limit, includePerformance, filters, trafficSegment, timezone, }, extra) => runTool(async () => {
761
+ const releaseTime = parseTimeRange(releaseAt, releaseAt, config.maxRangeDays).startAt;
762
+ const now = Date.now();
763
+ if (releaseTime > now) {
764
+ throw new UmamiError("VALIDATION_ERROR", "releaseAt cannot be in the future.");
765
+ }
766
+ const targetDuration = windowDays * 86_400_000;
767
+ const postEnd = Math.min(releaseTime + targetDuration - 1, now);
768
+ const actualDuration = postEnd - releaseTime;
769
+ if (actualDuration < 3_600_000) {
770
+ throw new UmamiError("VALIDATION_ERROR", "At least one hour of post-release data is required.");
771
+ }
772
+ const post = { startAt: releaseTime, endAt: postEnd };
773
+ const preEnd = releaseTime - 1;
774
+ const pre = { startAt: preEnd - actualDuration, endAt: preEnd };
775
+ const website = await client.getWebsite(websiteId, extra.signal);
776
+ const { channel, ...analyticsFilters } = filters ?? {};
777
+ const quality = await trafficQualityComparison(client, {
778
+ websiteId,
779
+ current: post,
780
+ comparison: pre,
781
+ filters: serializeFilters(analyticsFilters),
782
+ maxRangeDays: config.maxRangeDays,
783
+ signal: extra.signal,
784
+ trafficSegment,
785
+ });
786
+ const traffic = await compareTraffic(client, {
787
+ website: websiteSummary(website),
788
+ current: post,
789
+ comparison: pre,
790
+ dimensions: dimensions ??
791
+ (channel
792
+ ? ["channel", "device"]
793
+ : ["path", "referrer", "device", "channel", "event"]),
794
+ limit,
795
+ maxRangeDays: config.maxRangeDays,
796
+ filters: analyticsFilters,
797
+ channel: channel,
798
+ excludedReferrers: quality.excludedReferrers,
799
+ signal: extra.signal,
800
+ });
801
+ const performance = includePerformance
802
+ ? await comparePerformance(client, {
803
+ websiteId,
804
+ current: post,
805
+ comparison: pre,
806
+ filters: analyticsFilters,
807
+ timezone,
808
+ signal: extra.signal,
809
+ })
810
+ : { status: "not_requested" };
811
+ const trafficPercent = traffic.changes.pageviews?.percent ?? null;
812
+ const performanceChanges = "changes" in performance ? performance.changes : undefined;
813
+ const performanceRegressions = performance.status === "available" && performanceChanges
814
+ ? Object.entries(performanceChanges)
815
+ .filter(([, change]) => change.impact === "regressed")
816
+ .map(([metric]) => metric)
817
+ : [];
818
+ const performanceImprovements = performance.status === "available" && performanceChanges
819
+ ? Object.entries(performanceChanges)
820
+ .filter(([, change]) => change.impact === "improved")
821
+ .map(([metric]) => metric)
822
+ : [];
823
+ const trafficImpact = trafficPercent === null ||
824
+ Math.abs(trafficPercent) < MIN_RELEASE_TRAFFIC_CHANGE_PERCENT
825
+ ? "neutral"
826
+ : trafficPercent > 0
827
+ ? "positive"
828
+ : "negative";
829
+ const verdict = trafficImpact === "negative" && performanceRegressions.length > 0
830
+ ? "likely_regression"
831
+ : trafficImpact === "positive" && performanceImprovements.length > 0
832
+ ? "likely_improvement"
833
+ : trafficImpact === "neutral" && performanceRegressions.length === 0
834
+ ? "no_clear_change"
835
+ : "mixed";
836
+ const performanceEvidenceSufficient = !includePerformance ||
837
+ (performance.status === "available" &&
838
+ "sampleSufficient" in performance &&
839
+ performance.sampleSufficient === true);
840
+ const trafficEvidenceSufficient = traffic.current.pageviews >= 500 &&
841
+ traffic.comparison.pageviews >= 500 &&
842
+ !traffic.dataQuality.comparisonBaselineZero;
843
+ const dataStatus = traffic.dataStatus === "available" ||
844
+ (performance.status === "available" &&
845
+ "currentSampleCount" in performance &&
846
+ "comparisonSampleCount" in performance &&
847
+ ((typeof performance.currentSampleCount === "number" &&
848
+ performance.currentSampleCount > 0) ||
849
+ (typeof performance.comparisonSampleCount === "number" &&
850
+ performance.comparisonSampleCount > 0)))
851
+ ? "available"
852
+ : traffic.dataStatus;
853
+ return {
854
+ dataStatus,
855
+ website: websiteSummary(website),
856
+ releaseAt: new Date(releaseTime).toISOString(),
857
+ requestedWindowDays: windowDays,
858
+ partialPostWindow: postEnd < releaseTime + targetDuration - 1,
859
+ periods: { before: isoPeriod(pre), after: isoPeriod(post) },
860
+ comparability: {
861
+ equalDuration: true,
862
+ dayOfWeekAligned: postEnd === releaseTime + targetDuration - 1 && windowDays % 7 === 0,
863
+ note: windowDays % 7 === 0
864
+ ? "Full windows align weekdays when the post-release window is complete."
865
+ : "Use a 7, 14, 21, or 28 day window to reduce weekday-mix bias.",
866
+ },
867
+ assessment: {
868
+ verdict,
869
+ trafficImpact,
870
+ trafficChangePercent: trafficPercent,
871
+ performanceRegressions,
872
+ performanceImprovements,
873
+ confidence: trafficEvidenceSufficient && performanceEvidenceSufficient ? "medium" : "low",
874
+ caveat: "This is a before/after association. Campaigns, seasonality, outages, and other concurrent changes can produce the same pattern.",
875
+ },
876
+ traffic,
877
+ trafficQuality: quality,
878
+ performance,
879
+ };
880
+ }, { websiteId, timezone }));
881
+ server.registerTool("tracking_health_check", {
882
+ title: "Check analytics tracking health",
883
+ description: "Audit visible websites for stale or missing traffic, traffic drops, domain mismatches, referral-spam patterns, custom-event availability, recorder configuration, and section permission failures. Disabled optional features are warnings only when marked as expected.",
884
+ inputSchema: {
885
+ websiteLimit: websiteLimitSchema,
886
+ lookbackHours: z.number().int().min(1).max(8_760).default(48),
887
+ staleAfterHours: z.number().int().min(1).max(8_760).default(48),
888
+ dropThresholdPercent: z.number().min(1).max(100).default(50),
889
+ minimumPageviews: z.number().int().min(0).max(1_000_000_000).default(100),
890
+ checks: z
891
+ .array(z.enum(["traffic", "domain", "events", "recorder", "referral_spam"]))
892
+ .min(1)
893
+ .max(5)
894
+ .default(["traffic", "domain", "referral_spam", "events", "recorder"]),
895
+ expectEvents: z.boolean().default(false),
896
+ expectReplay: z.boolean().default(false),
897
+ expectHeatmap: z.boolean().default(false),
898
+ },
899
+ outputSchema,
900
+ annotations: READ_ONLY_ANNOTATIONS,
901
+ }, ({ websiteLimit, lookbackHours, staleAfterHours, dropThresholdPercent, minimumPageviews, checks, expectEvents, expectReplay, expectHeatmap, }, extra) => {
902
+ const endAt = Date.now();
903
+ const startAt = endAt - lookbackHours * 3_600_000;
904
+ const effectiveChecks = [...checks];
905
+ if (expectEvents && !effectiveChecks.includes("events"))
906
+ effectiveChecks.push("events");
907
+ if ((expectReplay || expectHeatmap) && !effectiveChecks.includes("recorder")) {
908
+ effectiveChecks.push("recorder");
909
+ }
910
+ return runTool(async () => {
911
+ const period = normalizePeriod(startAt, endAt, config.maxRangeDays);
912
+ const page = websitePage(await client.listWebsites({ page: 1, pageSize: websiteLimit }, extra.signal));
913
+ const websites = await mapConcurrent(page.data, 4, (website) => healthSite(client, website, period, {
914
+ checks: effectiveChecks,
915
+ dropThresholdPercent,
916
+ expectEvents,
917
+ expectHeatmap,
918
+ expectReplay,
919
+ maxRangeDays: config.maxRangeDays,
920
+ minimumPageviews,
921
+ signal: extra.signal,
922
+ staleAfterHours,
923
+ }));
924
+ const allIssues = websites.flatMap(({ website, issues }) => issues.map((issue) => ({ website, ...issue })));
925
+ const anyAvailableCheck = websites.some(({ checks: siteChecks }) => Object.values(siteChecks).some((check) => isRecord(check) && check.status === "available"));
926
+ return {
927
+ dataStatus: websites.length === 0
928
+ ? "empty"
929
+ : anyAvailableCheck
930
+ ? "available"
931
+ : "unknown",
932
+ generatedAt: new Date(endAt).toISOString(),
933
+ period: isoPeriod(period),
934
+ checkSelection: {
935
+ requested: checks,
936
+ effective: effectiveChecks,
937
+ },
938
+ coverage: {
939
+ visibleWebsites: page.count,
940
+ checkedWebsites: websites.length,
941
+ websiteLimit,
942
+ websitesTruncated: page.count > websites.length,
943
+ },
944
+ summary: {
945
+ healthy: websites.filter(({ status }) => status === "healthy").length,
946
+ warnings: websites.filter(({ status }) => status === "warning").length,
947
+ errors: websites.filter(({ status }) => status === "error").length,
948
+ issues: allIssues.length,
949
+ },
950
+ issues: allIssues,
951
+ websites,
952
+ scopeLimitations: [
953
+ "CMS linkage cannot be checked without a separate CMS integration.",
954
+ "Missing custom events are reported only when expectEvents=true.",
955
+ "Recorder features are reported as issues only when explicitly expected.",
956
+ "Referral-spam findings are conservative heuristics, not a definitive bot classification.",
957
+ ],
958
+ };
959
+ }, { range: { start: startAt, end: endAt } });
960
+ });
961
+ },
962
+ };
963
+ //# sourceMappingURL=insights.js.map