@listo-ai/mcp-observability 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 +609 -0
- package/dist/easy-setup.d.ts +34 -0
- package/dist/easy-setup.d.ts.map +1 -0
- package/dist/easy-setup.js +75 -0
- package/dist/endpoints.d.ts +34 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +307 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +790 -0
- package/dist/remote-sink.d.ts +26 -0
- package/dist/remote-sink.d.ts.map +1 -0
- package/dist/remote-sink.js +123 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
export class InMemorySink {
|
|
3
|
+
events = [];
|
|
4
|
+
limit;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.limit = options?.limit ?? 2000;
|
|
7
|
+
}
|
|
8
|
+
emit(event) {
|
|
9
|
+
this.events.push(event);
|
|
10
|
+
if (this.events.length > this.limit) {
|
|
11
|
+
this.events.shift();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
getEvents() {
|
|
15
|
+
return [...this.events];
|
|
16
|
+
}
|
|
17
|
+
getMetrics() {
|
|
18
|
+
const httpEvents = this.events.filter((event) => event.type === 'http_request');
|
|
19
|
+
const mcpEvents = this.events.filter((event) => event.type === 'mcp_request');
|
|
20
|
+
const sessionEvents = this.events.filter((event) => event.type === 'mcp_session');
|
|
21
|
+
const uiEvents = this.events.filter((event) => event.type === 'ui_event');
|
|
22
|
+
const businessEvents = this.events.filter((event) => event.type === 'business_event');
|
|
23
|
+
return {
|
|
24
|
+
totals: {
|
|
25
|
+
events: this.events.length,
|
|
26
|
+
http: httpEvents.length,
|
|
27
|
+
mcp: mcpEvents.length,
|
|
28
|
+
sessions: sessionEvents.length,
|
|
29
|
+
ui: uiEvents.length,
|
|
30
|
+
business: businessEvents.length,
|
|
31
|
+
},
|
|
32
|
+
http: {
|
|
33
|
+
overall: summarizeLatency(httpEvents),
|
|
34
|
+
byRoute: summarizeBy(httpEvents, (event) => event.route),
|
|
35
|
+
},
|
|
36
|
+
mcp: {
|
|
37
|
+
byKind: summarizeBy(mcpEvents, (event) => event.requestKind),
|
|
38
|
+
tools: summarizeBy(mcpEvents.filter((event) => event.requestKind === 'CallTool'), (event) => event.toolName ?? 'unknown'),
|
|
39
|
+
},
|
|
40
|
+
sessions: summarizeSessionEvents(sessionEvents),
|
|
41
|
+
ui: summarizeUiEvents(uiEvents),
|
|
42
|
+
business: summarizeBusinessEvents(businessEvents),
|
|
43
|
+
analytics: computeAnalytics({ uiEvents, businessEvents }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function createConsoleSink(options) {
|
|
48
|
+
const logSuccess = options?.logSuccess ?? false;
|
|
49
|
+
return {
|
|
50
|
+
emit(event) {
|
|
51
|
+
if (event.type === 'http_request') {
|
|
52
|
+
if (event.status === 'ok' && !logSuccess)
|
|
53
|
+
return;
|
|
54
|
+
const label = `${event.method} ${event.route}`;
|
|
55
|
+
const message = `[telemetry] ${label} status=${event.status} code=${event.statusCode} latency=${event.latencyMs.toFixed(1)}ms`;
|
|
56
|
+
(event.status === 'error' ? console.error : console.info)(event.error ? `${message} error=${event.error}` : message);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (event.type === 'mcp_request') {
|
|
60
|
+
if (event.status === 'ok' && !logSuccess)
|
|
61
|
+
return;
|
|
62
|
+
const label = `${event.requestKind}${event.toolName ? `:${event.toolName}` : ''}`;
|
|
63
|
+
const message = `[telemetry] ${label} status=${event.status} latency=${event.latencyMs.toFixed(1)}ms`;
|
|
64
|
+
(event.status === 'error' ? console.error : console.info)(event.error ? `${message} error=${event.error}` : message);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (event.type === 'mcp_session' || event.type === 'business_event') {
|
|
68
|
+
console.info('[telemetry]', event);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function combineSinks(...sinks) {
|
|
74
|
+
return {
|
|
75
|
+
emit(event) {
|
|
76
|
+
sinks.forEach((sink) => {
|
|
77
|
+
try {
|
|
78
|
+
const result = sink.emit(event);
|
|
79
|
+
if (result && typeof result.then === 'function') {
|
|
80
|
+
result.catch((error) => {
|
|
81
|
+
console.error('Telemetry sink failed', error);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error('Telemetry sink threw', error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export class McpObservability {
|
|
93
|
+
serviceName;
|
|
94
|
+
serviceVersion;
|
|
95
|
+
environment;
|
|
96
|
+
sinks;
|
|
97
|
+
sampleRate;
|
|
98
|
+
redactKeys;
|
|
99
|
+
capturePayloads;
|
|
100
|
+
tenantResolver;
|
|
101
|
+
constructor(options) {
|
|
102
|
+
this.serviceName = options.serviceName;
|
|
103
|
+
this.serviceVersion = options.serviceVersion;
|
|
104
|
+
this.environment = options.environment;
|
|
105
|
+
this.sinks = options.sinks?.length
|
|
106
|
+
? options.sinks
|
|
107
|
+
: [createConsoleSink({ logSuccess: false })];
|
|
108
|
+
this.sampleRate = clampSampleRate(options.sampleRate);
|
|
109
|
+
this.redactKeys = new Set((options.redactKeys ?? [
|
|
110
|
+
'authorization',
|
|
111
|
+
'apikey',
|
|
112
|
+
'password',
|
|
113
|
+
'token',
|
|
114
|
+
'secret',
|
|
115
|
+
]).map((key) => key.toLowerCase()));
|
|
116
|
+
this.capturePayloads = options.capturePayloads ?? false;
|
|
117
|
+
this.tenantResolver = options.tenantResolver;
|
|
118
|
+
}
|
|
119
|
+
recordUiEvent(event) {
|
|
120
|
+
const enriched = {
|
|
121
|
+
...event,
|
|
122
|
+
type: 'ui_event',
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
service: this.serviceName,
|
|
125
|
+
serviceVersion: this.serviceVersion,
|
|
126
|
+
environment: this.environment,
|
|
127
|
+
};
|
|
128
|
+
this.safeEmit(enriched);
|
|
129
|
+
}
|
|
130
|
+
async trackHttpRequest(req, res, handler) {
|
|
131
|
+
const start = performance.now();
|
|
132
|
+
const requestId = randomUUID();
|
|
133
|
+
const defaultRoute = normalizePath(req.url);
|
|
134
|
+
const path = defaultRoute;
|
|
135
|
+
const context = {
|
|
136
|
+
route: defaultRoute,
|
|
137
|
+
tenantId: this.tenantResolver?.(req),
|
|
138
|
+
};
|
|
139
|
+
let recorded = false;
|
|
140
|
+
const finalize = (error) => {
|
|
141
|
+
if (recorded)
|
|
142
|
+
return;
|
|
143
|
+
recorded = true;
|
|
144
|
+
const latencyMs = performance.now() - start;
|
|
145
|
+
const statusCode = res.statusCode || (error ? 500 : 200);
|
|
146
|
+
const status = error || statusCode >= 500 ? 'error' : 'ok';
|
|
147
|
+
const event = {
|
|
148
|
+
type: 'http_request',
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
service: this.serviceName,
|
|
151
|
+
serviceVersion: this.serviceVersion,
|
|
152
|
+
environment: this.environment,
|
|
153
|
+
requestId,
|
|
154
|
+
method: req.method ?? 'UNKNOWN',
|
|
155
|
+
path,
|
|
156
|
+
route: context.route,
|
|
157
|
+
statusCode,
|
|
158
|
+
status,
|
|
159
|
+
latencyMs,
|
|
160
|
+
tenantId: context.tenantId || undefined,
|
|
161
|
+
error: error ? toErrorMessage(error) : undefined,
|
|
162
|
+
};
|
|
163
|
+
this.safeEmit(event);
|
|
164
|
+
};
|
|
165
|
+
res.once('finish', () => finalize());
|
|
166
|
+
try {
|
|
167
|
+
await handler({
|
|
168
|
+
setRoute: (route) => {
|
|
169
|
+
context.route = route || context.route;
|
|
170
|
+
},
|
|
171
|
+
setTenant: (tenantId) => {
|
|
172
|
+
context.tenantId = tenantId ?? undefined;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
finalize(error);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
wrapMcpHandler(requestKind, handler, context) {
|
|
182
|
+
return async (request) => {
|
|
183
|
+
const metadata = context?.(request) ?? {};
|
|
184
|
+
const requestId = metadata.requestId ?? randomUUID();
|
|
185
|
+
const start = performance.now();
|
|
186
|
+
try {
|
|
187
|
+
const result = await handler(request);
|
|
188
|
+
const sanitizedArgs = metadata.args
|
|
189
|
+
? this.sanitizePayload(metadata.args)
|
|
190
|
+
: undefined;
|
|
191
|
+
const sanitizedResult = metadata.resultPreview
|
|
192
|
+
? this.sanitizePayload(metadata.resultPreview(result))
|
|
193
|
+
: undefined;
|
|
194
|
+
this.safeEmit({
|
|
195
|
+
...this.baseMcpFields(),
|
|
196
|
+
type: 'mcp_request',
|
|
197
|
+
timestamp: Date.now(),
|
|
198
|
+
requestId,
|
|
199
|
+
requestKind,
|
|
200
|
+
toolName: metadata.toolName,
|
|
201
|
+
resourceUri: metadata.resourceUri,
|
|
202
|
+
status: 'ok',
|
|
203
|
+
latencyMs: performance.now() - start,
|
|
204
|
+
tenantId: metadata.tenantId,
|
|
205
|
+
sessionId: metadata.sessionId,
|
|
206
|
+
locale: metadata.locale,
|
|
207
|
+
userAgent: metadata.userAgent,
|
|
208
|
+
conversationId: metadata.conversationId,
|
|
209
|
+
argsHash: sanitizedArgs ? hashPayload(sanitizedArgs) : undefined,
|
|
210
|
+
inputBytes: estimateSize(metadata.args),
|
|
211
|
+
outputBytes: estimateSize(result),
|
|
212
|
+
samplePayload: this.capturePayloads ? sanitizedArgs : undefined,
|
|
213
|
+
argsPreview: sanitizedArgs
|
|
214
|
+
? truncatePreview(sanitizedArgs)
|
|
215
|
+
: undefined,
|
|
216
|
+
resultPreview: sanitizedResult
|
|
217
|
+
? truncatePreview(sanitizedResult)
|
|
218
|
+
: undefined,
|
|
219
|
+
resultHash: sanitizedResult
|
|
220
|
+
? hashPayload(sanitizedResult)
|
|
221
|
+
: undefined,
|
|
222
|
+
userQueryHash: metadata.userQuery
|
|
223
|
+
? hashPayload(this.sanitizePayload(metadata.userQuery))
|
|
224
|
+
: undefined,
|
|
225
|
+
userQueryPreview: metadata.userQuery
|
|
226
|
+
? truncateString(metadata.userQuery, 240)
|
|
227
|
+
: undefined,
|
|
228
|
+
});
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
const sanitizedArgs = metadata.args
|
|
233
|
+
? this.sanitizePayload(metadata.args)
|
|
234
|
+
: undefined;
|
|
235
|
+
this.safeEmit({
|
|
236
|
+
...this.baseMcpFields(),
|
|
237
|
+
type: 'mcp_request',
|
|
238
|
+
timestamp: Date.now(),
|
|
239
|
+
requestId,
|
|
240
|
+
requestKind,
|
|
241
|
+
toolName: metadata.toolName,
|
|
242
|
+
resourceUri: metadata.resourceUri,
|
|
243
|
+
status: 'error',
|
|
244
|
+
latencyMs: performance.now() - start,
|
|
245
|
+
tenantId: metadata.tenantId,
|
|
246
|
+
sessionId: metadata.sessionId,
|
|
247
|
+
locale: metadata.locale,
|
|
248
|
+
userAgent: metadata.userAgent,
|
|
249
|
+
conversationId: metadata.conversationId,
|
|
250
|
+
argsHash: sanitizedArgs ? hashPayload(sanitizedArgs) : undefined,
|
|
251
|
+
inputBytes: estimateSize(metadata.args),
|
|
252
|
+
error: toErrorMessage(error),
|
|
253
|
+
});
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
recordSession(event) {
|
|
259
|
+
const enriched = {
|
|
260
|
+
...event,
|
|
261
|
+
timestamp: Date.now(),
|
|
262
|
+
service: this.serviceName,
|
|
263
|
+
serviceVersion: this.serviceVersion,
|
|
264
|
+
environment: this.environment,
|
|
265
|
+
};
|
|
266
|
+
this.safeEmit(enriched);
|
|
267
|
+
}
|
|
268
|
+
recordBusinessEvent(name, properties, status) {
|
|
269
|
+
const event = {
|
|
270
|
+
type: 'business_event',
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
service: this.serviceName,
|
|
273
|
+
serviceVersion: this.serviceVersion,
|
|
274
|
+
environment: this.environment,
|
|
275
|
+
name,
|
|
276
|
+
properties,
|
|
277
|
+
status,
|
|
278
|
+
};
|
|
279
|
+
this.safeEmit(event);
|
|
280
|
+
}
|
|
281
|
+
baseMcpFields() {
|
|
282
|
+
return {
|
|
283
|
+
service: this.serviceName,
|
|
284
|
+
serviceVersion: this.serviceVersion,
|
|
285
|
+
environment: this.environment,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
sanitizePayload(payload, depth = 0) {
|
|
289
|
+
if (payload === null || payload === undefined)
|
|
290
|
+
return payload;
|
|
291
|
+
if (depth > 6)
|
|
292
|
+
return '[truncated]';
|
|
293
|
+
if (Array.isArray(payload)) {
|
|
294
|
+
return payload
|
|
295
|
+
.slice(0, 20)
|
|
296
|
+
.map((item) => this.sanitizePayload(item, depth + 1));
|
|
297
|
+
}
|
|
298
|
+
if (typeof payload !== 'object')
|
|
299
|
+
return payload;
|
|
300
|
+
const output = {};
|
|
301
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
302
|
+
if (this.redactKeys.has(key.toLowerCase())) {
|
|
303
|
+
output[key] = '[redacted]';
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
output[key] = this.sanitizePayload(value, depth + 1);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return output;
|
|
310
|
+
}
|
|
311
|
+
safeEmit(event) {
|
|
312
|
+
if (!shouldSample(event, this.sampleRate))
|
|
313
|
+
return;
|
|
314
|
+
for (const sink of this.sinks) {
|
|
315
|
+
try {
|
|
316
|
+
const result = sink.emit(event);
|
|
317
|
+
if (result && typeof result.then === 'function') {
|
|
318
|
+
result.catch((error) => {
|
|
319
|
+
console.error('Telemetry sink failed', error);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
console.error('Telemetry sink threw', error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
export function createMcpObservability(options) {
|
|
330
|
+
return new McpObservability(options);
|
|
331
|
+
}
|
|
332
|
+
function hashPayload(payload) {
|
|
333
|
+
try {
|
|
334
|
+
const json = JSON.stringify(payload);
|
|
335
|
+
if (!json)
|
|
336
|
+
return undefined;
|
|
337
|
+
return createHash('sha256').update(json).digest('hex');
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function estimateSize(payload) {
|
|
344
|
+
try {
|
|
345
|
+
const json = JSON.stringify(payload);
|
|
346
|
+
return json ? Buffer.byteLength(json, 'utf8') : undefined;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function toErrorMessage(error) {
|
|
353
|
+
if (error instanceof Error)
|
|
354
|
+
return error.message;
|
|
355
|
+
return typeof error === 'string' ? error : JSON.stringify(error);
|
|
356
|
+
}
|
|
357
|
+
function normalizePath(path) {
|
|
358
|
+
if (!path)
|
|
359
|
+
return '/';
|
|
360
|
+
const withoutQuery = path.split('?')[0];
|
|
361
|
+
return withoutQuery || '/';
|
|
362
|
+
}
|
|
363
|
+
function clampSampleRate(sampleRate) {
|
|
364
|
+
if (sampleRate === undefined || Number.isNaN(sampleRate))
|
|
365
|
+
return 1;
|
|
366
|
+
return Math.min(1, Math.max(0, sampleRate));
|
|
367
|
+
}
|
|
368
|
+
function shouldSample(event, sampleRate) {
|
|
369
|
+
if ('status' in event && event.status === 'error')
|
|
370
|
+
return true;
|
|
371
|
+
if (event.type === 'mcp_session')
|
|
372
|
+
return true;
|
|
373
|
+
if (sampleRate >= 1)
|
|
374
|
+
return true;
|
|
375
|
+
return Math.random() < sampleRate;
|
|
376
|
+
}
|
|
377
|
+
function percentile(values, p) {
|
|
378
|
+
if (!values.length)
|
|
379
|
+
return null;
|
|
380
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
381
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p)));
|
|
382
|
+
return sorted[index];
|
|
383
|
+
}
|
|
384
|
+
function summarizeLatency(events) {
|
|
385
|
+
if (!events.length) {
|
|
386
|
+
return { count: 0, errors: 0 };
|
|
387
|
+
}
|
|
388
|
+
const latencies = events.map((event) => event.latencyMs);
|
|
389
|
+
const errors = events.filter((event) => event.status === 'error').length;
|
|
390
|
+
const totalLatency = latencies.reduce((sum, value) => sum + value, 0);
|
|
391
|
+
return {
|
|
392
|
+
count: events.length,
|
|
393
|
+
errors,
|
|
394
|
+
avgMs: totalLatency / latencies.length,
|
|
395
|
+
p50: percentile(latencies, 0.5),
|
|
396
|
+
p95: percentile(latencies, 0.95),
|
|
397
|
+
p99: percentile(latencies, 0.99),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function summarizeBy(events, keyFn) {
|
|
401
|
+
const groups = {};
|
|
402
|
+
for (const event of events) {
|
|
403
|
+
const key = keyFn(event) || 'unknown';
|
|
404
|
+
if (!groups[key])
|
|
405
|
+
groups[key] = [];
|
|
406
|
+
groups[key].push(event);
|
|
407
|
+
}
|
|
408
|
+
const summaries = {};
|
|
409
|
+
for (const [key, group] of Object.entries(groups)) {
|
|
410
|
+
summaries[key] = summarizeLatency(group);
|
|
411
|
+
}
|
|
412
|
+
return summaries;
|
|
413
|
+
}
|
|
414
|
+
function summarizeSessionEvents(events) {
|
|
415
|
+
const counts = { open: 0, close: 0 };
|
|
416
|
+
for (const event of events) {
|
|
417
|
+
counts[event.action] += 1;
|
|
418
|
+
}
|
|
419
|
+
return counts;
|
|
420
|
+
}
|
|
421
|
+
function summarizeUiEvents(events) {
|
|
422
|
+
const byName = {};
|
|
423
|
+
const byAction = {};
|
|
424
|
+
const byWidget = {};
|
|
425
|
+
for (const event of events) {
|
|
426
|
+
const nameKey = event.name || 'unknown';
|
|
427
|
+
const actionKey = event.action || 'unspecified';
|
|
428
|
+
const widgetKey = event.widgetId || 'unknown';
|
|
429
|
+
byName[nameKey] = (byName[nameKey] ?? 0) + 1;
|
|
430
|
+
byAction[actionKey] = (byAction[actionKey] ?? 0) + 1;
|
|
431
|
+
byWidget[widgetKey] = (byWidget[widgetKey] ?? 0) + 1;
|
|
432
|
+
}
|
|
433
|
+
return { byName, byAction, byWidget, total: events.length };
|
|
434
|
+
}
|
|
435
|
+
function summarizeBusinessEvents(events) {
|
|
436
|
+
const byName = {};
|
|
437
|
+
for (const event of events) {
|
|
438
|
+
const key = event.name || 'unknown';
|
|
439
|
+
if (!byName[key]) {
|
|
440
|
+
byName[key] = { count: 0, ok: 0, error: 0 };
|
|
441
|
+
}
|
|
442
|
+
byName[key].count += 1;
|
|
443
|
+
if (event.status === 'error')
|
|
444
|
+
byName[key].error += 1;
|
|
445
|
+
if (event.status === 'ok')
|
|
446
|
+
byName[key].ok += 1;
|
|
447
|
+
}
|
|
448
|
+
return { byName };
|
|
449
|
+
}
|
|
450
|
+
function truncateString(value, max = 240) {
|
|
451
|
+
if (value.length <= max)
|
|
452
|
+
return value;
|
|
453
|
+
return `${value.slice(0, max)}…`;
|
|
454
|
+
}
|
|
455
|
+
function truncatePreview(value) {
|
|
456
|
+
if (Array.isArray(value)) {
|
|
457
|
+
// Truncate individual string elements but keep all array items
|
|
458
|
+
return value.map((item) => typeof item === 'string' ? truncateString(item) : item);
|
|
459
|
+
}
|
|
460
|
+
if (value && typeof value === 'object') {
|
|
461
|
+
// Keep all object entries but truncate string values
|
|
462
|
+
const result = {};
|
|
463
|
+
for (const [key, val] of Object.entries(value)) {
|
|
464
|
+
result[key] = typeof val === 'string' ? truncateString(val) : val;
|
|
465
|
+
}
|
|
466
|
+
return result;
|
|
467
|
+
}
|
|
468
|
+
if (typeof value === 'string')
|
|
469
|
+
return truncateString(value);
|
|
470
|
+
return value;
|
|
471
|
+
}
|
|
472
|
+
function incMap(map, rawKey, delta = 1) {
|
|
473
|
+
const key = rawKey.trim();
|
|
474
|
+
if (!key)
|
|
475
|
+
return;
|
|
476
|
+
map.set(key, (map.get(key) ?? 0) + delta);
|
|
477
|
+
}
|
|
478
|
+
function asString(value) {
|
|
479
|
+
if (typeof value === 'string')
|
|
480
|
+
return value;
|
|
481
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
482
|
+
return String(value);
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
function asObject(value) {
|
|
486
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
487
|
+
return undefined;
|
|
488
|
+
return value;
|
|
489
|
+
}
|
|
490
|
+
function asStringArray(value) {
|
|
491
|
+
if (!Array.isArray(value))
|
|
492
|
+
return [];
|
|
493
|
+
return value
|
|
494
|
+
.map((v) => asString(v))
|
|
495
|
+
.filter((v) => typeof v === 'string' && v.trim().length > 0);
|
|
496
|
+
}
|
|
497
|
+
function tokenize(input, stopWords) {
|
|
498
|
+
const sanitized = input
|
|
499
|
+
.toLowerCase()
|
|
500
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
501
|
+
.trim();
|
|
502
|
+
if (!sanitized)
|
|
503
|
+
return [];
|
|
504
|
+
return sanitized
|
|
505
|
+
.split(/\s+/)
|
|
506
|
+
.map((token) => token.trim())
|
|
507
|
+
.filter((token) => token.length >= 3)
|
|
508
|
+
.filter((token) => !stopWords.has(token));
|
|
509
|
+
}
|
|
510
|
+
function topEntries(map, limit = 10) {
|
|
511
|
+
return Array.from(map.entries())
|
|
512
|
+
.sort((a, b) => b[1] - a[1])
|
|
513
|
+
.slice(0, limit)
|
|
514
|
+
.map(([value, count]) => ({ value, count }));
|
|
515
|
+
}
|
|
516
|
+
function topHotelEntries(map, limit = 10) {
|
|
517
|
+
return Array.from(map.entries())
|
|
518
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
519
|
+
.slice(0, limit)
|
|
520
|
+
.map(([id, meta]) => ({ id, name: meta.name, count: meta.count }));
|
|
521
|
+
}
|
|
522
|
+
function computeAnalytics(options) {
|
|
523
|
+
const queryCounts = new Map();
|
|
524
|
+
const keywordCounts = new Map();
|
|
525
|
+
const minRatingCounts = new Map();
|
|
526
|
+
const cityCounts = new Map();
|
|
527
|
+
const countryCounts = new Map();
|
|
528
|
+
const hotelViewCounts = new Map();
|
|
529
|
+
const bookingClickCounts = new Map();
|
|
530
|
+
const websiteClickCounts = new Map();
|
|
531
|
+
const searchPriceCounts = new Map();
|
|
532
|
+
const searchStyleCounts = new Map();
|
|
533
|
+
const searchNeighborhoodCounts = new Map();
|
|
534
|
+
const searchLimitCounts = new Map();
|
|
535
|
+
const viewedPriceCounts = new Map();
|
|
536
|
+
const viewedStyleCounts = new Map();
|
|
537
|
+
const viewedCategoryCounts = new Map();
|
|
538
|
+
const viewedNeighborhoodCounts = new Map();
|
|
539
|
+
const stopWords = new Set([
|
|
540
|
+
'a',
|
|
541
|
+
'an',
|
|
542
|
+
'and',
|
|
543
|
+
'are',
|
|
544
|
+
'as',
|
|
545
|
+
'at',
|
|
546
|
+
'be',
|
|
547
|
+
'but',
|
|
548
|
+
'by',
|
|
549
|
+
'for',
|
|
550
|
+
'from',
|
|
551
|
+
'has',
|
|
552
|
+
'have',
|
|
553
|
+
'hotel',
|
|
554
|
+
'hotels',
|
|
555
|
+
'i',
|
|
556
|
+
'in',
|
|
557
|
+
'is',
|
|
558
|
+
'it',
|
|
559
|
+
'of',
|
|
560
|
+
'on',
|
|
561
|
+
'or',
|
|
562
|
+
'the',
|
|
563
|
+
'to',
|
|
564
|
+
'with',
|
|
565
|
+
]);
|
|
566
|
+
const recordQuery = (query) => {
|
|
567
|
+
const normalized = query.trim();
|
|
568
|
+
if (!normalized)
|
|
569
|
+
return;
|
|
570
|
+
incMap(queryCounts, normalized);
|
|
571
|
+
tokenize(normalized, stopWords).forEach((token) => incMap(keywordCounts, token));
|
|
572
|
+
};
|
|
573
|
+
const recordMinRating = (value) => {
|
|
574
|
+
const str = asString(value);
|
|
575
|
+
if (!str)
|
|
576
|
+
return;
|
|
577
|
+
incMap(minRatingCounts, str);
|
|
578
|
+
};
|
|
579
|
+
const recordCityCountry = (city, country) => {
|
|
580
|
+
if (city)
|
|
581
|
+
incMap(cityCounts, city);
|
|
582
|
+
if (country)
|
|
583
|
+
incMap(countryCounts, country);
|
|
584
|
+
};
|
|
585
|
+
const recordHotelView = (hotelId, name) => {
|
|
586
|
+
if (!hotelId)
|
|
587
|
+
return;
|
|
588
|
+
const existing = hotelViewCounts.get(hotelId) ?? {
|
|
589
|
+
count: 0,
|
|
590
|
+
name: undefined,
|
|
591
|
+
};
|
|
592
|
+
hotelViewCounts.set(hotelId, {
|
|
593
|
+
count: existing.count + 1,
|
|
594
|
+
name: existing.name ?? (name || undefined),
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
const recordHotelClick = (map, hotelId, name) => {
|
|
598
|
+
if (!hotelId)
|
|
599
|
+
return;
|
|
600
|
+
const existing = map.get(hotelId) ?? { count: 0, name: undefined };
|
|
601
|
+
map.set(hotelId, {
|
|
602
|
+
count: existing.count + 1,
|
|
603
|
+
name: existing.name ?? (name || undefined),
|
|
604
|
+
});
|
|
605
|
+
};
|
|
606
|
+
const recordList = (map, values) => {
|
|
607
|
+
const list = asStringArray(values);
|
|
608
|
+
list.forEach((value) => incMap(map, value));
|
|
609
|
+
};
|
|
610
|
+
// Business events (tool-level signals)
|
|
611
|
+
for (const event of options.businessEvents) {
|
|
612
|
+
if (event.name === 'hotel_search') {
|
|
613
|
+
const q = asString(event.properties?.q);
|
|
614
|
+
if (q)
|
|
615
|
+
recordQuery(q);
|
|
616
|
+
recordMinRating(event.properties?.minRating ?? event.properties?.min_rating);
|
|
617
|
+
recordCityCountry(asString(event.properties?.city), asString(event.properties?.country));
|
|
618
|
+
recordList(searchPriceCounts, event.properties?.priceLevels);
|
|
619
|
+
recordList(searchStyleCounts, event.properties?.styles);
|
|
620
|
+
recordList(searchNeighborhoodCounts, event.properties?.neighborhoods);
|
|
621
|
+
const limit = asString(event.properties?.limit);
|
|
622
|
+
if (limit)
|
|
623
|
+
incMap(searchLimitCounts, limit);
|
|
624
|
+
}
|
|
625
|
+
if (event.name === 'hotel_details') {
|
|
626
|
+
const hotelId = asString(event.properties?.id);
|
|
627
|
+
const name = asString(event.properties?.name);
|
|
628
|
+
recordHotelView(hotelId, name);
|
|
629
|
+
recordCityCountry(asString(event.properties?.city), asString(event.properties?.country));
|
|
630
|
+
const priceLevel = asString(event.properties?.priceLevel);
|
|
631
|
+
if (priceLevel)
|
|
632
|
+
incMap(viewedPriceCounts, priceLevel);
|
|
633
|
+
recordList(viewedStyleCounts, event.properties?.styles);
|
|
634
|
+
recordList(viewedCategoryCounts, event.properties?.categories);
|
|
635
|
+
recordList(viewedNeighborhoodCounts, event.properties?.neighborhoods);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// UI events (engagement/CTR)
|
|
639
|
+
for (const event of options.uiEvents) {
|
|
640
|
+
if (event.name === 'search_results_rendered') {
|
|
641
|
+
const filters = asObject(event.properties?.rawFilters) ??
|
|
642
|
+
asObject(event.properties?.filters);
|
|
643
|
+
const q = asString(filters?.q ?? filters?.query);
|
|
644
|
+
if (q)
|
|
645
|
+
recordQuery(q);
|
|
646
|
+
recordMinRating(filters?.minRating ?? filters?.min_rating);
|
|
647
|
+
recordList(searchPriceCounts, filters?.priceLevels ?? filters?.price_levels);
|
|
648
|
+
recordList(searchStyleCounts, filters?.styles);
|
|
649
|
+
recordList(searchNeighborhoodCounts, filters?.neighborhoods);
|
|
650
|
+
const limit = asString(filters?.limit);
|
|
651
|
+
if (limit)
|
|
652
|
+
incMap(searchLimitCounts, limit);
|
|
653
|
+
}
|
|
654
|
+
if (event.name === 'hotel_detail_viewed') {
|
|
655
|
+
const props = asObject(event.properties);
|
|
656
|
+
recordHotelView(asString(props?.hotelId), asString(props?.hotelName));
|
|
657
|
+
recordCityCountry(asString(props?.city), asString(props?.country));
|
|
658
|
+
const priceLevel = asString(props?.priceLevel);
|
|
659
|
+
if (priceLevel)
|
|
660
|
+
incMap(viewedPriceCounts, priceLevel);
|
|
661
|
+
recordList(viewedStyleCounts, props?.styles);
|
|
662
|
+
recordList(viewedCategoryCounts, props?.categories);
|
|
663
|
+
recordList(viewedNeighborhoodCounts, props?.neighborhoods);
|
|
664
|
+
}
|
|
665
|
+
if (event.name === 'hotel_selected') {
|
|
666
|
+
const props = asObject(event.properties);
|
|
667
|
+
recordHotelView(asString(props?.hotelId), asString(props?.hotelName));
|
|
668
|
+
}
|
|
669
|
+
if (event.name === 'booking_cta_click') {
|
|
670
|
+
const props = asObject(event.properties);
|
|
671
|
+
const hotelId = asString(props?.hotelId);
|
|
672
|
+
recordHotelClick(bookingClickCounts, hotelId, asString(props?.hotelName));
|
|
673
|
+
}
|
|
674
|
+
if (event.name === 'website_click') {
|
|
675
|
+
const props = asObject(event.properties);
|
|
676
|
+
const hotelId = asString(props?.hotelId);
|
|
677
|
+
recordHotelClick(websiteClickCounts, hotelId, asString(props?.hotelName));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const impressions = options.uiEvents.filter((e) => e.name === 'widget_impression').length;
|
|
681
|
+
const clicks = options.uiEvents.filter((e) => e.name === 'widget_click').length;
|
|
682
|
+
const detailViews = options.uiEvents.filter((e) => e.name === 'hotel_detail_viewed').length;
|
|
683
|
+
const bookingClicks = Array.from(bookingClickCounts.values()).reduce((acc, entry) => acc + (entry.count || 0), 0);
|
|
684
|
+
const websiteClicks = Array.from(websiteClickCounts.values()).reduce((acc, entry) => acc + (entry.count || 0), 0);
|
|
685
|
+
// Backfill missing names from any known hotel views (older clients may omit hotelName).
|
|
686
|
+
for (const [hotelId, meta] of bookingClickCounts.entries()) {
|
|
687
|
+
if (!meta.name) {
|
|
688
|
+
const known = hotelViewCounts.get(hotelId)?.name;
|
|
689
|
+
if (known)
|
|
690
|
+
bookingClickCounts.set(hotelId, { ...meta, name: known });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
for (const [hotelId, meta] of websiteClickCounts.entries()) {
|
|
694
|
+
if (!meta.name) {
|
|
695
|
+
const known = hotelViewCounts.get(hotelId)?.name;
|
|
696
|
+
if (known)
|
|
697
|
+
websiteClickCounts.set(hotelId, { ...meta, name: known });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const ctrFromImpressions = impressions ? bookingClicks / impressions : null;
|
|
701
|
+
const ctrFromDetails = detailViews ? bookingClicks / detailViews : null;
|
|
702
|
+
const topBookingCtr = Array.from(bookingClickCounts.entries())
|
|
703
|
+
.map(([hotelId, meta]) => {
|
|
704
|
+
const clicks = meta.count || 0;
|
|
705
|
+
const views = hotelViewCounts.get(hotelId)?.count ?? 0;
|
|
706
|
+
const name = meta.name ?? hotelViewCounts.get(hotelId)?.name;
|
|
707
|
+
return {
|
|
708
|
+
id: hotelId,
|
|
709
|
+
name,
|
|
710
|
+
clicks,
|
|
711
|
+
views,
|
|
712
|
+
ctr: views ? clicks / views : null,
|
|
713
|
+
};
|
|
714
|
+
})
|
|
715
|
+
.filter((row) => row.ctr !== null && row.views >= 2)
|
|
716
|
+
.sort((a, b) => (b.ctr ?? 0) - (a.ctr ?? 0))
|
|
717
|
+
.slice(0, 8);
|
|
718
|
+
const sessionsAll = new Set();
|
|
719
|
+
const sessionsFromImpressions = new Set();
|
|
720
|
+
const sessionsBooking = new Set();
|
|
721
|
+
const sessionsWebsite = new Set();
|
|
722
|
+
let unknownSessionUiEvents = 0;
|
|
723
|
+
for (const event of options.uiEvents) {
|
|
724
|
+
const sid = event.sessionId;
|
|
725
|
+
if (!sid) {
|
|
726
|
+
unknownSessionUiEvents += 1;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
sessionsAll.add(sid);
|
|
730
|
+
if (event.name === 'widget_impression')
|
|
731
|
+
sessionsFromImpressions.add(sid);
|
|
732
|
+
if (event.name === 'booking_cta_click')
|
|
733
|
+
sessionsBooking.add(sid);
|
|
734
|
+
if (event.name === 'website_click')
|
|
735
|
+
sessionsWebsite.add(sid);
|
|
736
|
+
}
|
|
737
|
+
const totalSessions = sessionsFromImpressions.size > 0
|
|
738
|
+
? sessionsFromImpressions.size
|
|
739
|
+
: sessionsAll.size;
|
|
740
|
+
const convertedSessions = new Set([
|
|
741
|
+
...Array.from(sessionsBooking),
|
|
742
|
+
...Array.from(sessionsWebsite),
|
|
743
|
+
]).size;
|
|
744
|
+
return {
|
|
745
|
+
search: {
|
|
746
|
+
topQueries: topEntries(queryCounts, 12),
|
|
747
|
+
topKeywords: topEntries(keywordCounts, 18),
|
|
748
|
+
minRatings: topEntries(minRatingCounts, 8),
|
|
749
|
+
priceLevels: topEntries(searchPriceCounts, 10),
|
|
750
|
+
neighborhoods: topEntries(searchNeighborhoodCounts, 10),
|
|
751
|
+
styles: topEntries(searchStyleCounts, 10),
|
|
752
|
+
limits: topEntries(searchLimitCounts, 8),
|
|
753
|
+
},
|
|
754
|
+
geo: {
|
|
755
|
+
topCities: topEntries(cityCounts, 12),
|
|
756
|
+
topCountries: topEntries(countryCounts, 12),
|
|
757
|
+
},
|
|
758
|
+
hotels: {
|
|
759
|
+
topViewed: topHotelEntries(hotelViewCounts, 12),
|
|
760
|
+
topBookingClicks: topHotelEntries(bookingClickCounts, 12),
|
|
761
|
+
topBookingCtr,
|
|
762
|
+
topWebsiteClicks: topHotelEntries(websiteClickCounts, 12),
|
|
763
|
+
viewedPriceLevels: topEntries(viewedPriceCounts, 10),
|
|
764
|
+
viewedStyles: topEntries(viewedStyleCounts, 10),
|
|
765
|
+
viewedCategories: topEntries(viewedCategoryCounts, 10),
|
|
766
|
+
viewedNeighborhoods: topEntries(viewedNeighborhoodCounts, 10),
|
|
767
|
+
},
|
|
768
|
+
ctr: {
|
|
769
|
+
impressions,
|
|
770
|
+
clicks,
|
|
771
|
+
detailViews,
|
|
772
|
+
bookingClicks,
|
|
773
|
+
websiteClicks,
|
|
774
|
+
bookingPerImpression: ctrFromImpressions,
|
|
775
|
+
bookingPerDetailView: ctrFromDetails,
|
|
776
|
+
},
|
|
777
|
+
sessions: {
|
|
778
|
+
total: totalSessions,
|
|
779
|
+
converted: convertedSessions,
|
|
780
|
+
booking: sessionsBooking.size,
|
|
781
|
+
website: sessionsWebsite.size,
|
|
782
|
+
conversionRate: totalSessions ? convertedSessions / totalSessions : null,
|
|
783
|
+
unknownSessionUiEvents,
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
export { RemoteSink, createRemoteSink, } from './remote-sink.js';
|
|
788
|
+
export { createMcpObservabilityEasy, } from './easy-setup.js';
|
|
789
|
+
export { createTelemetryRouter, expressTelemetry } from './endpoints.js';
|
|
790
|
+
//# sourceMappingURL=index.js.map
|