umami-compass 0.1.2 → 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 +18 -9
- package/SECURITY.md +2 -2
- package/dist/api/client.d.ts +1 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +114 -22
- package/dist/api/client.js.map +1 -1
- package/dist/api/types.d.ts +5 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -6
- package/dist/config.js.map +1 -1
- package/dist/mcp/insights-utils.d.ts +162 -0
- package/dist/mcp/insights-utils.d.ts.map +1 -0
- package/dist/mcp/insights-utils.js +377 -0
- package/dist/mcp/insights-utils.js.map +1 -0
- package/dist/mcp/modules/core.d.ts.map +1 -1
- package/dist/mcp/modules/core.js +7 -7
- package/dist/mcp/modules/core.js.map +1 -1
- package/dist/mcp/modules/events.d.ts.map +1 -1
- package/dist/mcp/modules/events.js +3 -3
- package/dist/mcp/modules/events.js.map +1 -1
- package/dist/mcp/modules/heatmaps.d.ts.map +1 -1
- package/dist/mcp/modules/heatmaps.js +3 -2
- package/dist/mcp/modules/heatmaps.js.map +1 -1
- package/dist/mcp/modules/insights.d.ts +3 -0
- package/dist/mcp/modules/insights.d.ts.map +1 -0
- package/dist/mcp/modules/insights.js +754 -0
- package/dist/mcp/modules/insights.js.map +1 -0
- package/dist/mcp/modules/performance.d.ts.map +1 -1
- package/dist/mcp/modules/performance.js +5 -3
- package/dist/mcp/modules/performance.js.map +1 -1
- package/dist/mcp/modules/replay.d.ts.map +1 -1
- package/dist/mcp/modules/replay.js +3 -2
- package/dist/mcp/modules/replay.js.map +1 -1
- package/dist/mcp/modules/reports.d.ts.map +1 -1
- package/dist/mcp/modules/reports.js +11 -11
- package/dist/mcp/modules/reports.js.map +1 -1
- package/dist/mcp/modules/revenue.d.ts.map +1 -1
- package/dist/mcp/modules/revenue.js +2 -2
- package/dist/mcp/modules/revenue.js.map +1 -1
- package/dist/mcp/modules/sessions.d.ts.map +1 -1
- package/dist/mcp/modules/sessions.js +4 -4
- package/dist/mcp/modules/sessions.js.map +1 -1
- package/dist/mcp/result.d.ts +13 -1
- package/dist/mcp/result.d.ts.map +1 -1
- package/dist/mcp/result.js +88 -2
- package/dist/mcp/result.js.map +1 -1
- package/dist/mcp/schemas.d.ts +122 -0
- package/dist/mcp/schemas.d.ts.map +1 -1
- package/dist/mcp/schemas.js +24 -0
- package/dist/mcp/schemas.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +165 -31
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +9 -4
|
@@ -0,0 +1,754 @@
|
|
|
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 { filtersSchema, outputSchema, rangeQuery, timeSchema, timezoneSchema, uuidSchema, } from "../schemas.js";
|
|
8
|
+
const trafficDimensionSchema = z.enum(TRAFFIC_DIMENSIONS);
|
|
9
|
+
const websiteLimitSchema = z.number().int().min(1).max(50).default(25);
|
|
10
|
+
const MIN_RELEASE_TRAFFIC_CHANGE_PERCENT = 10;
|
|
11
|
+
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;
|
|
12
|
+
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");
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function websitePage(value) {
|
|
17
|
+
const page = requirePagedResponse(value);
|
|
18
|
+
if (!page.data.every((item) => isRecord(item) && typeof item.id === "string" && item.id.length > 0)) {
|
|
19
|
+
throw new UmamiError("INVALID_RESPONSE", "Umami returned an invalid website list.");
|
|
20
|
+
}
|
|
21
|
+
return page;
|
|
22
|
+
}
|
|
23
|
+
function websiteSummary(website) {
|
|
24
|
+
return {
|
|
25
|
+
id: website.id,
|
|
26
|
+
...(typeof website.name === "string" ? { name: website.name } : {}),
|
|
27
|
+
...(typeof website.domain === "string" ? { domain: website.domain } : {}),
|
|
28
|
+
...(typeof website.teamId === "string" ? { teamId: website.teamId } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function normalizeDomain(value) {
|
|
32
|
+
const trimmed = value.trim().toLowerCase();
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
35
|
+
return url.hostname.replace(/^www\./, "").replace(/\.$/, "");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return (trimmed
|
|
39
|
+
.replace(/^https?:\/\//, "")
|
|
40
|
+
.split(/[/?#]/, 1)[0]
|
|
41
|
+
?.replace(/^www\./, "")
|
|
42
|
+
.replace(/\.$/, "") ?? trimmed);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function scoreWebsite(website, query, normalizedDomain) {
|
|
46
|
+
const id = website.id.toLowerCase();
|
|
47
|
+
const name = typeof website.name === "string" ? website.name.trim().toLowerCase() : "";
|
|
48
|
+
const domain = typeof website.domain === "string" ? normalizeDomain(website.domain) : "";
|
|
49
|
+
if (id === query)
|
|
50
|
+
return { score: 100, matchType: "id", confidence: "exact" };
|
|
51
|
+
if (domain && domain === normalizedDomain) {
|
|
52
|
+
return { score: 95, matchType: "domain", confidence: "exact" };
|
|
53
|
+
}
|
|
54
|
+
if (name && name === query) {
|
|
55
|
+
return { score: 90, matchType: "name", confidence: "exact" };
|
|
56
|
+
}
|
|
57
|
+
if (domain && (domain.startsWith(normalizedDomain) || normalizedDomain.startsWith(domain))) {
|
|
58
|
+
return { score: 70, matchType: "domain", confidence: "strong" };
|
|
59
|
+
}
|
|
60
|
+
if (name?.startsWith(query)) {
|
|
61
|
+
return { score: 65, matchType: "name", confidence: "strong" };
|
|
62
|
+
}
|
|
63
|
+
if (domain?.includes(normalizedDomain) || name?.includes(query)) {
|
|
64
|
+
return {
|
|
65
|
+
score: 50,
|
|
66
|
+
matchType: domain.includes(normalizedDomain) ? "domain" : "name",
|
|
67
|
+
confidence: "fuzzy",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { score: 0, matchType: "none", confidence: "none" };
|
|
71
|
+
}
|
|
72
|
+
function dateValue(value) {
|
|
73
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
74
|
+
return value;
|
|
75
|
+
if (typeof value !== "string")
|
|
76
|
+
return undefined;
|
|
77
|
+
const parsed = Date.parse(value);
|
|
78
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
79
|
+
}
|
|
80
|
+
function currentTotals(stats) {
|
|
81
|
+
return {
|
|
82
|
+
pageviews: stats.pageviews,
|
|
83
|
+
visitors: stats.visitors,
|
|
84
|
+
visits: stats.visits,
|
|
85
|
+
bounces: stats.bounces,
|
|
86
|
+
totaltime: stats.totaltime,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const ZERO_TOTALS = {
|
|
90
|
+
pageviews: 0,
|
|
91
|
+
visitors: 0,
|
|
92
|
+
visits: 0,
|
|
93
|
+
bounces: 0,
|
|
94
|
+
totaltime: 0,
|
|
95
|
+
};
|
|
96
|
+
function sumTotals(values) {
|
|
97
|
+
return values.reduce((total, value) => ({
|
|
98
|
+
pageviews: total.pageviews + value.pageviews,
|
|
99
|
+
visitors: total.visitors + value.visitors,
|
|
100
|
+
visits: total.visits + value.visits,
|
|
101
|
+
bounces: total.bounces + value.bounces,
|
|
102
|
+
totaltime: total.totaltime + value.totaltime,
|
|
103
|
+
}), { ...ZERO_TOTALS });
|
|
104
|
+
}
|
|
105
|
+
async function portfolioSite(client, website, period, baselinePeriod, maxRangeDays, staleAfterHours, anomalyThresholdPercent, anomalyMinimumPageviews, now, signal) {
|
|
106
|
+
try {
|
|
107
|
+
const [stats, baselineStats, dateRangeResult] = await Promise.all([
|
|
108
|
+
fetchWebsiteStats(client, website.id, period, maxRangeDays, undefined, signal),
|
|
109
|
+
fetchWebsiteStats(client, website.id, baselinePeriod, maxRangeDays, undefined, signal),
|
|
110
|
+
client
|
|
111
|
+
.get(`websites/${encodeURIComponent(website.id)}/daterange`, undefined, signal)
|
|
112
|
+
.catch((error) => ({ dateRangeError: toSafeError(error) })),
|
|
113
|
+
]);
|
|
114
|
+
const current = currentTotals(stats);
|
|
115
|
+
const comparison = currentTotals(baselineStats);
|
|
116
|
+
const lastDataAt = isRecord(dateRangeResult) && !("dateRangeError" in dateRangeResult)
|
|
117
|
+
? dateValue(dateRangeResult.endDate)
|
|
118
|
+
: undefined;
|
|
119
|
+
const freshnessUnavailable = isRecord(dateRangeResult) && "dateRangeError" in dateRangeResult;
|
|
120
|
+
const stale = !freshnessUnavailable &&
|
|
121
|
+
(lastDataAt === undefined || now - lastDataAt > staleAfterHours * 3_600_000);
|
|
122
|
+
const pageviewPercent = percentChange(current.pageviews, comparison.pageviews);
|
|
123
|
+
const anomalous = (comparison.pageviews === 0 && current.pageviews >= anomalyMinimumPageviews) ||
|
|
124
|
+
(pageviewPercent !== null &&
|
|
125
|
+
Math.abs(pageviewPercent) >= anomalyThresholdPercent &&
|
|
126
|
+
Math.max(current.pageviews, comparison.pageviews) >= anomalyMinimumPageviews);
|
|
127
|
+
const dataStatus = freshnessUnavailable
|
|
128
|
+
? "freshness_unknown"
|
|
129
|
+
: lastDataAt === undefined
|
|
130
|
+
? "never_tracked"
|
|
131
|
+
: current.pageviews === 0
|
|
132
|
+
? "no_data_in_range"
|
|
133
|
+
: stale
|
|
134
|
+
? "stale"
|
|
135
|
+
: "fresh";
|
|
136
|
+
return {
|
|
137
|
+
status: "available",
|
|
138
|
+
website: websiteSummary(website),
|
|
139
|
+
current,
|
|
140
|
+
comparison,
|
|
141
|
+
changes: totalsChanges(current, comparison),
|
|
142
|
+
tracking: {
|
|
143
|
+
dataStatus,
|
|
144
|
+
...(lastDataAt === undefined ? {} : { lastDataAt: new Date(lastDataAt).toISOString() }),
|
|
145
|
+
stale,
|
|
146
|
+
...(isRecord(dateRangeResult) && "dateRangeError" in dateRangeResult
|
|
147
|
+
? { dateRangeError: dateRangeResult.dateRangeError }
|
|
148
|
+
: {}),
|
|
149
|
+
},
|
|
150
|
+
anomaly: anomalous
|
|
151
|
+
? {
|
|
152
|
+
metric: "pageviews",
|
|
153
|
+
percent: pageviewPercent,
|
|
154
|
+
direction: comparison.pageviews === 0
|
|
155
|
+
? "new_activity"
|
|
156
|
+
: (pageviewPercent ?? 0) > 0
|
|
157
|
+
? "spike"
|
|
158
|
+
: "drop",
|
|
159
|
+
}
|
|
160
|
+
: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
status: "unavailable",
|
|
166
|
+
website: websiteSummary(website),
|
|
167
|
+
error: toSafeError(error),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function parseMetricNames(value) {
|
|
172
|
+
if (!Array.isArray(value)) {
|
|
173
|
+
throw new UmamiError("INVALID_RESPONSE", "Umami returned invalid metric data.");
|
|
174
|
+
}
|
|
175
|
+
return value.flatMap((item) => {
|
|
176
|
+
if (!isRecord(item) || typeof item.x !== "string")
|
|
177
|
+
return [];
|
|
178
|
+
const number = finiteNumber(item.y);
|
|
179
|
+
return number === undefined ? [] : [{ name: item.x, value: number }];
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async function healthSite(client, website, period, options) {
|
|
183
|
+
const issues = [];
|
|
184
|
+
const checks = {};
|
|
185
|
+
await mapConcurrent(options.checks, 1, async (check) => {
|
|
186
|
+
try {
|
|
187
|
+
if (check === "traffic") {
|
|
188
|
+
const [stats, baselineStats, range] = await Promise.all([
|
|
189
|
+
fetchWebsiteStats(client, website.id, period, options.maxRangeDays, undefined, options.signal),
|
|
190
|
+
fetchWebsiteStats(client, website.id, comparisonPeriod(period, "previous"), options.maxRangeDays, undefined, options.signal),
|
|
191
|
+
client.get(`websites/${encodeURIComponent(website.id)}/daterange`, undefined, options.signal),
|
|
192
|
+
]);
|
|
193
|
+
const lastDataAt = dateValue(range.endDate);
|
|
194
|
+
const comparison = currentTotals(baselineStats);
|
|
195
|
+
const change = percentChange(stats.pageviews, comparison.pageviews);
|
|
196
|
+
checks.traffic = {
|
|
197
|
+
status: "available",
|
|
198
|
+
pageviews: stats.pageviews,
|
|
199
|
+
comparisonPageviews: comparison.pageviews,
|
|
200
|
+
changePercent: change,
|
|
201
|
+
...(lastDataAt === undefined ? {} : { lastDataAt: new Date(lastDataAt).toISOString() }),
|
|
202
|
+
};
|
|
203
|
+
if (lastDataAt === undefined) {
|
|
204
|
+
issues.push({
|
|
205
|
+
check,
|
|
206
|
+
code: "NO_ANALYTICS_DATA",
|
|
207
|
+
message: "The website has no discoverable analytics data range.",
|
|
208
|
+
severity: "error",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
else if (Date.now() - lastDataAt > options.staleAfterHours * 3_600_000) {
|
|
212
|
+
issues.push({
|
|
213
|
+
check,
|
|
214
|
+
code: "STALE_TRACKING",
|
|
215
|
+
message: "The most recent analytics event is older than the configured freshness limit.",
|
|
216
|
+
severity: "warning",
|
|
217
|
+
evidence: { lastDataAt: new Date(lastDataAt).toISOString() },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (stats.pageviews === 0) {
|
|
221
|
+
issues.push({
|
|
222
|
+
check,
|
|
223
|
+
code: "NO_TRAFFIC_IN_LOOKBACK",
|
|
224
|
+
message: "No pageviews were recorded in the health-check lookback window.",
|
|
225
|
+
severity: "warning",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
else if (change !== null &&
|
|
229
|
+
change <= -options.dropThresholdPercent &&
|
|
230
|
+
comparison.pageviews >= options.minimumPageviews) {
|
|
231
|
+
issues.push({
|
|
232
|
+
check,
|
|
233
|
+
code: "TRAFFIC_DROP",
|
|
234
|
+
message: "Pageviews dropped beyond the configured threshold.",
|
|
235
|
+
severity: "warning",
|
|
236
|
+
evidence: { changePercent: change },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (check === "domain") {
|
|
242
|
+
const hostnames = parseMetricNames(await client.get(`websites/${encodeURIComponent(website.id)}/metrics`, {
|
|
243
|
+
...rangeQuery(period.startAt, period.endAt, options.maxRangeDays),
|
|
244
|
+
type: "hostname",
|
|
245
|
+
limit: 10,
|
|
246
|
+
offset: 0,
|
|
247
|
+
}, options.signal));
|
|
248
|
+
const configuredDomain = typeof website.domain === "string" ? normalizeDomain(website.domain) : "";
|
|
249
|
+
const observedDomains = hostnames.map(({ name }) => normalizeDomain(name));
|
|
250
|
+
checks.domain = {
|
|
251
|
+
status: "available",
|
|
252
|
+
configuredDomain: configuredDomain || null,
|
|
253
|
+
observedHostnames: hostnames,
|
|
254
|
+
};
|
|
255
|
+
if (!configuredDomain) {
|
|
256
|
+
issues.push({
|
|
257
|
+
check,
|
|
258
|
+
code: "DOMAIN_MISSING",
|
|
259
|
+
message: "The Umami website has no configured domain.",
|
|
260
|
+
severity: "warning",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
else if (observedDomains.length > 0 &&
|
|
264
|
+
!observedDomains.some((domain) => domain === configuredDomain || domain.endsWith(`.${configuredDomain}`))) {
|
|
265
|
+
issues.push({
|
|
266
|
+
check,
|
|
267
|
+
code: "DOMAIN_MISMATCH",
|
|
268
|
+
message: "Recent observed hostnames do not match the configured website domain.",
|
|
269
|
+
severity: "warning",
|
|
270
|
+
evidence: { configuredDomain, observedDomains },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (check === "events") {
|
|
276
|
+
const page = requirePagedResponse(await client.get(`websites/${encodeURIComponent(website.id)}/events`, {
|
|
277
|
+
...rangeQuery(period.startAt, period.endAt, options.maxRangeDays, {
|
|
278
|
+
filters: { eventType: 2 },
|
|
279
|
+
}),
|
|
280
|
+
page: 1,
|
|
281
|
+
pageSize: 1,
|
|
282
|
+
}, options.signal));
|
|
283
|
+
checks.events = { status: "available", count: page.count };
|
|
284
|
+
if (options.expectEvents && page.count === 0) {
|
|
285
|
+
issues.push({
|
|
286
|
+
check,
|
|
287
|
+
code: "EXPECTED_EVENTS_MISSING",
|
|
288
|
+
message: "No custom events were recorded in the health-check lookback window.",
|
|
289
|
+
severity: "warning",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const recorder = await client.get(`websites/${encodeURIComponent(website.id)}/recorder`, undefined, options.signal);
|
|
295
|
+
checks.recorder = {
|
|
296
|
+
status: "available",
|
|
297
|
+
enabled: recorder.enabled === true,
|
|
298
|
+
replayEnabled: recorder.replayEnabled === true,
|
|
299
|
+
heatmapEnabled: recorder.heatmapEnabled === true,
|
|
300
|
+
...(finiteNumber(recorder.sampleRate) === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { sampleRate: finiteNumber(recorder.sampleRate) }),
|
|
303
|
+
};
|
|
304
|
+
if ((options.expectReplay || options.expectHeatmap) && recorder.enabled !== true) {
|
|
305
|
+
issues.push({
|
|
306
|
+
check,
|
|
307
|
+
code: "RECORDER_DISABLED",
|
|
308
|
+
message: "Recorder is disabled although a recorder feature is expected.",
|
|
309
|
+
severity: "warning",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
if (options.expectReplay && recorder.replayEnabled !== true) {
|
|
314
|
+
issues.push({
|
|
315
|
+
check,
|
|
316
|
+
code: "REPLAY_DISABLED",
|
|
317
|
+
message: "Session replay is disabled but expected.",
|
|
318
|
+
severity: "warning",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (options.expectHeatmap && recorder.heatmapEnabled !== true) {
|
|
322
|
+
issues.push({
|
|
323
|
+
check,
|
|
324
|
+
code: "HEATMAP_DISABLED",
|
|
325
|
+
message: "Heatmaps are disabled but expected.",
|
|
326
|
+
severity: "warning",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
const safeError = toSafeError(error);
|
|
333
|
+
checks[check] = { status: "unavailable", error: safeError };
|
|
334
|
+
issues.push({
|
|
335
|
+
check,
|
|
336
|
+
code: safeError.code === "FORBIDDEN" ? "PERMISSION_MISSING" : "CHECK_UNAVAILABLE",
|
|
337
|
+
message: safeError.message,
|
|
338
|
+
severity: safeError.code === "FORBIDDEN" ? "warning" : "error",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
return {
|
|
343
|
+
website: websiteSummary(website),
|
|
344
|
+
status: issues.some(({ severity }) => severity === "error")
|
|
345
|
+
? "error"
|
|
346
|
+
: issues.length > 0
|
|
347
|
+
? "warning"
|
|
348
|
+
: "healthy",
|
|
349
|
+
checks,
|
|
350
|
+
issues,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export const insightsModule = {
|
|
354
|
+
id: "insights",
|
|
355
|
+
access: "read",
|
|
356
|
+
register(server, { client, config }) {
|
|
357
|
+
server.registerTool("resolve_website", {
|
|
358
|
+
title: "Resolve an Umami website",
|
|
359
|
+
description: "Resolve a website UUID from a UUID, domain, URL, or website name. Ambiguous matches return bounded candidates instead of guessing.",
|
|
360
|
+
inputSchema: {
|
|
361
|
+
query: z.string().trim().min(1).max(500),
|
|
362
|
+
candidateLimit: z.number().int().min(1).max(20).default(10),
|
|
363
|
+
},
|
|
364
|
+
outputSchema,
|
|
365
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
366
|
+
}, ({ query, candidateLimit }, extra) => runTool(async () => {
|
|
367
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
368
|
+
if (UUID_PATTERN.test(normalizedQuery)) {
|
|
369
|
+
try {
|
|
370
|
+
const website = await client.getWebsite(normalizedQuery, extra.signal);
|
|
371
|
+
return {
|
|
372
|
+
status: "resolved",
|
|
373
|
+
matchType: "id",
|
|
374
|
+
confidence: "exact",
|
|
375
|
+
website: websiteSummary(website),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (!(error instanceof UmamiError) || error.code !== "NOT_FOUND")
|
|
380
|
+
throw error;
|
|
381
|
+
return { status: "not_found", candidates: [] };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const domain = normalizeDomain(query);
|
|
385
|
+
const page = websitePage(await client.listWebsites({ page: 1, pageSize: 100, search: domain || normalizedQuery }, extra.signal));
|
|
386
|
+
const candidates = page.data
|
|
387
|
+
.map((website) => ({ website, ...scoreWebsite(website, normalizedQuery, domain) }))
|
|
388
|
+
.filter(({ score }) => score > 0)
|
|
389
|
+
.sort((left, right) => right.score - left.score ||
|
|
390
|
+
String(left.website.name ?? left.website.domain ?? left.website.id).localeCompare(String(right.website.name ?? right.website.domain ?? right.website.id)))
|
|
391
|
+
.slice(0, candidateLimit);
|
|
392
|
+
if (candidates.length === 0)
|
|
393
|
+
return { status: "not_found", candidates: [] };
|
|
394
|
+
const [best, second] = candidates;
|
|
395
|
+
if (best?.confidence === "exact" && (!second || best.score > second.score)) {
|
|
396
|
+
return {
|
|
397
|
+
status: "resolved",
|
|
398
|
+
matchType: best.matchType,
|
|
399
|
+
confidence: best.confidence,
|
|
400
|
+
website: websiteSummary(best.website),
|
|
401
|
+
candidatesConsidered: page.count,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
status: "ambiguous",
|
|
406
|
+
candidates: candidates.map(({ website, matchType, confidence, score }) => ({
|
|
407
|
+
website: websiteSummary(website),
|
|
408
|
+
matchType,
|
|
409
|
+
confidence,
|
|
410
|
+
score,
|
|
411
|
+
})),
|
|
412
|
+
};
|
|
413
|
+
}));
|
|
414
|
+
server.registerTool("get_portfolio_overview", {
|
|
415
|
+
title: "Get a portfolio analytics overview",
|
|
416
|
+
description: "Summarize bounded aggregate traffic across visible websites, including period changes, growth and decline leaders, stale tracking, failures, and suspicious jumps.",
|
|
417
|
+
inputSchema: {
|
|
418
|
+
start: timeSchema,
|
|
419
|
+
end: timeSchema,
|
|
420
|
+
websiteLimit: websiteLimitSchema,
|
|
421
|
+
staleAfterHours: z.number().int().min(1).max(8_760).default(48),
|
|
422
|
+
anomalyThresholdPercent: z.number().min(10).max(10_000).default(100),
|
|
423
|
+
anomalyMinimumPageviews: z.number().int().min(0).max(1_000_000_000).default(100),
|
|
424
|
+
},
|
|
425
|
+
outputSchema,
|
|
426
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
427
|
+
}, ({ start, end, websiteLimit, staleAfterHours, anomalyThresholdPercent, anomalyMinimumPageviews, }, extra) => runTool(async () => {
|
|
428
|
+
const period = normalizePeriod(start, end, config.maxRangeDays);
|
|
429
|
+
const page = websitePage(await client.listWebsites({ page: 1, pageSize: websiteLimit }, extra.signal));
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
const baselinePeriod = comparisonPeriod(period, "previous");
|
|
432
|
+
const sites = await mapConcurrent(page.data, 4, (website) => portfolioSite(client, website, period, baselinePeriod, config.maxRangeDays, staleAfterHours, anomalyThresholdPercent, anomalyMinimumPageviews, now, extra.signal));
|
|
433
|
+
const available = sites.filter((site) => site.status === "available");
|
|
434
|
+
const unavailable = sites.filter((site) => site.status === "unavailable");
|
|
435
|
+
const current = sumTotals(available.map(({ current: totals }) => totals));
|
|
436
|
+
const comparison = sumTotals(available.map(({ comparison: totals }) => totals));
|
|
437
|
+
const dataStatus = available.length === 0
|
|
438
|
+
? unavailable.length > 0
|
|
439
|
+
? "unknown"
|
|
440
|
+
: "empty"
|
|
441
|
+
: current.pageviews === 0 &&
|
|
442
|
+
current.visitors === 0 &&
|
|
443
|
+
current.visits === 0 &&
|
|
444
|
+
comparison.pageviews === 0 &&
|
|
445
|
+
comparison.visitors === 0 &&
|
|
446
|
+
comparison.visits === 0
|
|
447
|
+
? "empty"
|
|
448
|
+
: "available";
|
|
449
|
+
const ranked = available
|
|
450
|
+
.map((site) => ({
|
|
451
|
+
website: site.website,
|
|
452
|
+
currentPageviews: site.current.pageviews,
|
|
453
|
+
comparisonPageviews: site.comparison.pageviews,
|
|
454
|
+
percent: percentChange(site.current.pageviews, site.comparison.pageviews),
|
|
455
|
+
}))
|
|
456
|
+
.filter((site) => site.percent !== null);
|
|
457
|
+
return {
|
|
458
|
+
dataStatus,
|
|
459
|
+
generatedAt: new Date(now).toISOString(),
|
|
460
|
+
period: isoPeriod(period),
|
|
461
|
+
comparisonPeriod: isoPeriod(baselinePeriod),
|
|
462
|
+
coverage: {
|
|
463
|
+
visibleWebsites: page.count,
|
|
464
|
+
analyzedWebsites: sites.length,
|
|
465
|
+
successfulWebsites: available.length,
|
|
466
|
+
failedWebsites: unavailable.length,
|
|
467
|
+
websiteLimit,
|
|
468
|
+
websitesTruncated: page.count > sites.length,
|
|
469
|
+
},
|
|
470
|
+
totals: {
|
|
471
|
+
current,
|
|
472
|
+
comparison,
|
|
473
|
+
changes: totalsChanges(current, comparison),
|
|
474
|
+
},
|
|
475
|
+
leaders: {
|
|
476
|
+
growth: ranked
|
|
477
|
+
.filter(({ percent }) => percent > 0)
|
|
478
|
+
.sort((a, b) => b.percent - a.percent)
|
|
479
|
+
.slice(0, 5),
|
|
480
|
+
decline: ranked
|
|
481
|
+
.filter(({ percent }) => percent < 0)
|
|
482
|
+
.sort((a, b) => a.percent - b.percent)
|
|
483
|
+
.slice(0, 5),
|
|
484
|
+
},
|
|
485
|
+
attention: {
|
|
486
|
+
staleOrMissing: available
|
|
487
|
+
.filter(({ tracking }) => tracking.dataStatus !== "fresh")
|
|
488
|
+
.map(({ website, tracking }) => ({ website, tracking })),
|
|
489
|
+
anomalies: available
|
|
490
|
+
.filter(({ anomaly }) => anomaly !== null)
|
|
491
|
+
.map(({ website, anomaly }) => ({ website, anomaly })),
|
|
492
|
+
failures: unavailable,
|
|
493
|
+
},
|
|
494
|
+
sites,
|
|
495
|
+
};
|
|
496
|
+
}, { range: { start, end } }));
|
|
497
|
+
server.registerTool("explain_traffic_change", {
|
|
498
|
+
title: "Explain a traffic change",
|
|
499
|
+
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.",
|
|
500
|
+
inputSchema: {
|
|
501
|
+
websiteId: uuidSchema,
|
|
502
|
+
start: timeSchema,
|
|
503
|
+
end: timeSchema,
|
|
504
|
+
comparisonMode: z.enum(["previous", "year_over_year", "custom"]).default("previous"),
|
|
505
|
+
comparisonStart: timeSchema.optional(),
|
|
506
|
+
comparisonEnd: timeSchema.optional(),
|
|
507
|
+
dimensions: z
|
|
508
|
+
.array(trafficDimensionSchema)
|
|
509
|
+
.min(1)
|
|
510
|
+
.max(6)
|
|
511
|
+
.default(["path", "referrer", "country", "device", "event"]),
|
|
512
|
+
limit: z.number().int().min(1).max(20).default(10),
|
|
513
|
+
filters: filtersSchema.optional(),
|
|
514
|
+
timezone: timezoneSchema,
|
|
515
|
+
},
|
|
516
|
+
outputSchema,
|
|
517
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
518
|
+
}, ({ websiteId, start, end, comparisonMode, comparisonStart, comparisonEnd, dimensions, limit, filters, timezone, }, extra) => runTool(async () => {
|
|
519
|
+
const current = normalizePeriod(start, end, config.maxRangeDays);
|
|
520
|
+
let comparison;
|
|
521
|
+
if (comparisonMode === "custom") {
|
|
522
|
+
if (comparisonStart === undefined || comparisonEnd === undefined) {
|
|
523
|
+
throw new UmamiError("VALIDATION_ERROR", "comparisonStart and comparisonEnd are required for comparisonMode=custom.");
|
|
524
|
+
}
|
|
525
|
+
comparison = normalizePeriod(comparisonStart, comparisonEnd, config.maxRangeDays);
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
if (comparisonStart !== undefined || comparisonEnd !== undefined) {
|
|
529
|
+
throw new UmamiError("VALIDATION_ERROR", "comparisonStart and comparisonEnd can only be used with comparisonMode=custom.");
|
|
530
|
+
}
|
|
531
|
+
comparison = comparisonPeriod(current, comparisonMode);
|
|
532
|
+
}
|
|
533
|
+
const website = await client.getWebsite(websiteId, extra.signal);
|
|
534
|
+
return {
|
|
535
|
+
comparisonMode,
|
|
536
|
+
timezone,
|
|
537
|
+
...(await compareTraffic(client, {
|
|
538
|
+
website: websiteSummary(website),
|
|
539
|
+
current,
|
|
540
|
+
comparison,
|
|
541
|
+
dimensions: dimensions,
|
|
542
|
+
limit,
|
|
543
|
+
maxRangeDays: config.maxRangeDays,
|
|
544
|
+
filters,
|
|
545
|
+
signal: extra.signal,
|
|
546
|
+
})),
|
|
547
|
+
};
|
|
548
|
+
}, { websiteId, range: { start, end }, timezone }));
|
|
549
|
+
server.registerTool("analyze_release_impact", {
|
|
550
|
+
title: "Analyze release impact",
|
|
551
|
+
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.",
|
|
552
|
+
inputSchema: {
|
|
553
|
+
websiteId: uuidSchema,
|
|
554
|
+
releaseAt: releaseTimestampSchema,
|
|
555
|
+
windowDays: z.number().int().min(1).max(30).default(7),
|
|
556
|
+
dimensions: z
|
|
557
|
+
.array(trafficDimensionSchema)
|
|
558
|
+
.min(1)
|
|
559
|
+
.max(6)
|
|
560
|
+
.default(["path", "referrer", "device", "event"]),
|
|
561
|
+
limit: z.number().int().min(1).max(20).default(10),
|
|
562
|
+
includePerformance: z.boolean().default(true),
|
|
563
|
+
filters: filtersSchema.optional(),
|
|
564
|
+
timezone: timezoneSchema,
|
|
565
|
+
},
|
|
566
|
+
outputSchema,
|
|
567
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
568
|
+
}, ({ websiteId, releaseAt, windowDays, dimensions, limit, includePerformance, filters, timezone, }, extra) => runTool(async () => {
|
|
569
|
+
const releaseTime = parseTimeRange(releaseAt, releaseAt, config.maxRangeDays).startAt;
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
if (releaseTime > now) {
|
|
572
|
+
throw new UmamiError("VALIDATION_ERROR", "releaseAt cannot be in the future.");
|
|
573
|
+
}
|
|
574
|
+
const targetDuration = windowDays * 86_400_000;
|
|
575
|
+
const postEnd = Math.min(releaseTime + targetDuration - 1, now);
|
|
576
|
+
const actualDuration = postEnd - releaseTime;
|
|
577
|
+
if (actualDuration < 3_600_000) {
|
|
578
|
+
throw new UmamiError("VALIDATION_ERROR", "At least one hour of post-release data is required.");
|
|
579
|
+
}
|
|
580
|
+
const post = { startAt: releaseTime, endAt: postEnd };
|
|
581
|
+
const preEnd = releaseTime - 1;
|
|
582
|
+
const pre = { startAt: preEnd - actualDuration, endAt: preEnd };
|
|
583
|
+
const website = await client.getWebsite(websiteId, extra.signal);
|
|
584
|
+
const traffic = await compareTraffic(client, {
|
|
585
|
+
website: websiteSummary(website),
|
|
586
|
+
current: post,
|
|
587
|
+
comparison: pre,
|
|
588
|
+
dimensions: dimensions,
|
|
589
|
+
limit,
|
|
590
|
+
maxRangeDays: config.maxRangeDays,
|
|
591
|
+
filters,
|
|
592
|
+
signal: extra.signal,
|
|
593
|
+
});
|
|
594
|
+
const performance = includePerformance
|
|
595
|
+
? await comparePerformance(client, {
|
|
596
|
+
websiteId,
|
|
597
|
+
current: post,
|
|
598
|
+
comparison: pre,
|
|
599
|
+
filters,
|
|
600
|
+
timezone,
|
|
601
|
+
signal: extra.signal,
|
|
602
|
+
})
|
|
603
|
+
: { status: "not_requested" };
|
|
604
|
+
const trafficPercent = traffic.changes.pageviews?.percent ?? null;
|
|
605
|
+
const performanceChanges = "changes" in performance ? performance.changes : undefined;
|
|
606
|
+
const performanceRegressions = performance.status === "available" && performanceChanges
|
|
607
|
+
? Object.entries(performanceChanges)
|
|
608
|
+
.filter(([, change]) => change.impact === "regressed")
|
|
609
|
+
.map(([metric]) => metric)
|
|
610
|
+
: [];
|
|
611
|
+
const performanceImprovements = performance.status === "available" && performanceChanges
|
|
612
|
+
? Object.entries(performanceChanges)
|
|
613
|
+
.filter(([, change]) => change.impact === "improved")
|
|
614
|
+
.map(([metric]) => metric)
|
|
615
|
+
: [];
|
|
616
|
+
const trafficImpact = trafficPercent === null ||
|
|
617
|
+
Math.abs(trafficPercent) < MIN_RELEASE_TRAFFIC_CHANGE_PERCENT
|
|
618
|
+
? "neutral"
|
|
619
|
+
: trafficPercent > 0
|
|
620
|
+
? "positive"
|
|
621
|
+
: "negative";
|
|
622
|
+
const verdict = trafficImpact === "negative" && performanceRegressions.length > 0
|
|
623
|
+
? "likely_regression"
|
|
624
|
+
: trafficImpact === "positive" && performanceImprovements.length > 0
|
|
625
|
+
? "likely_improvement"
|
|
626
|
+
: trafficImpact === "neutral" && performanceRegressions.length === 0
|
|
627
|
+
? "no_clear_change"
|
|
628
|
+
: "mixed";
|
|
629
|
+
const performanceEvidenceSufficient = !includePerformance ||
|
|
630
|
+
(performance.status === "available" &&
|
|
631
|
+
"sampleSufficient" in performance &&
|
|
632
|
+
performance.sampleSufficient === true);
|
|
633
|
+
const trafficEvidenceSufficient = traffic.current.pageviews >= 500 &&
|
|
634
|
+
traffic.comparison.pageviews >= 500 &&
|
|
635
|
+
!traffic.dataQuality.comparisonBaselineZero;
|
|
636
|
+
const dataStatus = traffic.dataStatus === "available" ||
|
|
637
|
+
(performance.status === "available" &&
|
|
638
|
+
"currentSampleCount" in performance &&
|
|
639
|
+
"comparisonSampleCount" in performance &&
|
|
640
|
+
((typeof performance.currentSampleCount === "number" &&
|
|
641
|
+
performance.currentSampleCount > 0) ||
|
|
642
|
+
(typeof performance.comparisonSampleCount === "number" &&
|
|
643
|
+
performance.comparisonSampleCount > 0)))
|
|
644
|
+
? "available"
|
|
645
|
+
: traffic.dataStatus;
|
|
646
|
+
return {
|
|
647
|
+
dataStatus,
|
|
648
|
+
website: websiteSummary(website),
|
|
649
|
+
releaseAt: new Date(releaseTime).toISOString(),
|
|
650
|
+
requestedWindowDays: windowDays,
|
|
651
|
+
partialPostWindow: postEnd < releaseTime + targetDuration - 1,
|
|
652
|
+
periods: { before: isoPeriod(pre), after: isoPeriod(post) },
|
|
653
|
+
comparability: {
|
|
654
|
+
equalDuration: true,
|
|
655
|
+
dayOfWeekAligned: postEnd === releaseTime + targetDuration - 1 && windowDays % 7 === 0,
|
|
656
|
+
note: windowDays % 7 === 0
|
|
657
|
+
? "Full windows align weekdays when the post-release window is complete."
|
|
658
|
+
: "Use a 7, 14, 21, or 28 day window to reduce weekday-mix bias.",
|
|
659
|
+
},
|
|
660
|
+
assessment: {
|
|
661
|
+
verdict,
|
|
662
|
+
trafficImpact,
|
|
663
|
+
trafficChangePercent: trafficPercent,
|
|
664
|
+
performanceRegressions,
|
|
665
|
+
performanceImprovements,
|
|
666
|
+
confidence: trafficEvidenceSufficient && performanceEvidenceSufficient ? "medium" : "low",
|
|
667
|
+
caveat: "This is a before/after association. Campaigns, seasonality, outages, and other concurrent changes can produce the same pattern.",
|
|
668
|
+
},
|
|
669
|
+
traffic,
|
|
670
|
+
performance,
|
|
671
|
+
};
|
|
672
|
+
}, { websiteId, timezone }));
|
|
673
|
+
server.registerTool("tracking_health_check", {
|
|
674
|
+
title: "Check analytics tracking health",
|
|
675
|
+
description: "Audit visible websites for stale or missing traffic, traffic drops, domain mismatches, custom-event availability, recorder configuration, and section permission failures. Disabled optional features are warnings only when marked as expected.",
|
|
676
|
+
inputSchema: {
|
|
677
|
+
websiteLimit: websiteLimitSchema,
|
|
678
|
+
lookbackHours: z.number().int().min(1).max(8_760).default(48),
|
|
679
|
+
staleAfterHours: z.number().int().min(1).max(8_760).default(48),
|
|
680
|
+
dropThresholdPercent: z.number().min(1).max(100).default(50),
|
|
681
|
+
minimumPageviews: z.number().int().min(0).max(1_000_000_000).default(100),
|
|
682
|
+
checks: z
|
|
683
|
+
.array(z.enum(["traffic", "domain", "events", "recorder"]))
|
|
684
|
+
.min(1)
|
|
685
|
+
.max(4)
|
|
686
|
+
.default(["traffic", "domain", "events", "recorder"]),
|
|
687
|
+
expectEvents: z.boolean().default(false),
|
|
688
|
+
expectReplay: z.boolean().default(false),
|
|
689
|
+
expectHeatmap: z.boolean().default(false),
|
|
690
|
+
},
|
|
691
|
+
outputSchema,
|
|
692
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
693
|
+
}, ({ websiteLimit, lookbackHours, staleAfterHours, dropThresholdPercent, minimumPageviews, checks, expectEvents, expectReplay, expectHeatmap, }, extra) => {
|
|
694
|
+
const endAt = Date.now();
|
|
695
|
+
const startAt = endAt - lookbackHours * 3_600_000;
|
|
696
|
+
const effectiveChecks = [...checks];
|
|
697
|
+
if (expectEvents && !effectiveChecks.includes("events"))
|
|
698
|
+
effectiveChecks.push("events");
|
|
699
|
+
if ((expectReplay || expectHeatmap) && !effectiveChecks.includes("recorder")) {
|
|
700
|
+
effectiveChecks.push("recorder");
|
|
701
|
+
}
|
|
702
|
+
return runTool(async () => {
|
|
703
|
+
const period = normalizePeriod(startAt, endAt, config.maxRangeDays);
|
|
704
|
+
const page = websitePage(await client.listWebsites({ page: 1, pageSize: websiteLimit }, extra.signal));
|
|
705
|
+
const websites = await mapConcurrent(page.data, 4, (website) => healthSite(client, website, period, {
|
|
706
|
+
checks: effectiveChecks,
|
|
707
|
+
dropThresholdPercent,
|
|
708
|
+
expectEvents,
|
|
709
|
+
expectHeatmap,
|
|
710
|
+
expectReplay,
|
|
711
|
+
maxRangeDays: config.maxRangeDays,
|
|
712
|
+
minimumPageviews,
|
|
713
|
+
signal: extra.signal,
|
|
714
|
+
staleAfterHours,
|
|
715
|
+
}));
|
|
716
|
+
const allIssues = websites.flatMap(({ website, issues }) => issues.map((issue) => ({ website, ...issue })));
|
|
717
|
+
const anyAvailableCheck = websites.some(({ checks: siteChecks }) => Object.values(siteChecks).some((check) => isRecord(check) && check.status === "available"));
|
|
718
|
+
return {
|
|
719
|
+
dataStatus: websites.length === 0
|
|
720
|
+
? "empty"
|
|
721
|
+
: anyAvailableCheck
|
|
722
|
+
? "available"
|
|
723
|
+
: "unknown",
|
|
724
|
+
generatedAt: new Date(endAt).toISOString(),
|
|
725
|
+
period: isoPeriod(period),
|
|
726
|
+
checkSelection: {
|
|
727
|
+
requested: checks,
|
|
728
|
+
effective: effectiveChecks,
|
|
729
|
+
},
|
|
730
|
+
coverage: {
|
|
731
|
+
visibleWebsites: page.count,
|
|
732
|
+
checkedWebsites: websites.length,
|
|
733
|
+
websiteLimit,
|
|
734
|
+
websitesTruncated: page.count > websites.length,
|
|
735
|
+
},
|
|
736
|
+
summary: {
|
|
737
|
+
healthy: websites.filter(({ status }) => status === "healthy").length,
|
|
738
|
+
warnings: websites.filter(({ status }) => status === "warning").length,
|
|
739
|
+
errors: websites.filter(({ status }) => status === "error").length,
|
|
740
|
+
issues: allIssues.length,
|
|
741
|
+
},
|
|
742
|
+
issues: allIssues,
|
|
743
|
+
websites,
|
|
744
|
+
scopeLimitations: [
|
|
745
|
+
"CMS linkage cannot be checked without a separate CMS integration.",
|
|
746
|
+
"Missing custom events are reported only when expectEvents=true.",
|
|
747
|
+
"Recorder features are reported as issues only when explicitly expected.",
|
|
748
|
+
],
|
|
749
|
+
};
|
|
750
|
+
}, { range: { start: startAt, end: endAt } });
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
//# sourceMappingURL=insights.js.map
|