autotel-devtools 23.0.0 → 24.0.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/dist/cli.cjs +2 -2
- package/dist/cli.js +2 -2
- package/dist/{error-aggregator-DSwUvgFF.d.cts → error-aggregator-C0at0wzF.d.cts} +1 -1
- package/dist/{error-aggregator-B52JI8jL.d.ts → error-aggregator-C25_JiVI.d.ts} +1 -1
- package/dist/{exporter-C4uKgo7l.d.cts → exporter-AkgWRDpc.d.cts} +55 -2
- package/dist/{exporter-BrEcnzoT.d.ts → exporter-yo7lLDIs.d.ts} +55 -2
- package/dist/fullpage.global.js +37 -30
- package/dist/genai/index.d.cts +1 -1
- package/dist/genai/index.d.ts +1 -1
- package/dist/{grpc-CSWjMRiB.cjs → grpc-CSW1Evnt.cjs} +1 -1
- package/dist/{grpc-CZEDUYCM.js → grpc-D1tt8UPi.js} +1 -1
- package/dist/{http-Cqm_MAtc.cjs → http-CahjEnpE.cjs} +291 -5
- package/dist/{http-Dbd9TIYU.js → http-z8UbJsqk.js} +292 -6
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/server/exporter.d.cts +1 -1
- package/dist/server/exporter.d.ts +1 -1
- package/dist/server/index.cjs +2 -2
- package/dist/server/index.d.cts +4 -4
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.js +2 -2
- package/dist/types-B86Qkqg5.d.cts +120 -0
- package/dist/types-B86Qkqg5.d.ts +120 -0
- package/dist/{types-DM8y4A9Z.d.ts → types-BhbHVeiy.d.ts} +1 -1
- package/dist/{types-B0tjwFqj.d.cts → types-C-ORUrT2.d.cts} +1 -1
- package/dist/widget.global.js +14 -14
- package/dist/wire/index.d.cts +1 -1
- package/dist/wire/index.d.ts +1 -1
- package/package.json +2 -2
- package/dist/types-DHDvZnnn.d.cts +0 -47
- package/dist/types-DHDvZnnn.d.ts +0 -47
package/dist/genai/index.d.cts
CHANGED
package/dist/genai/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as decodeOtlpTraceRequest, o as decodeOtlpLogsRequest, s as decodeOtlpMetricsRequest } from "./http-
|
|
1
|
+
import { c as decodeOtlpTraceRequest, o as decodeOtlpLogsRequest, s as decodeOtlpMetricsRequest } from "./http-z8UbJsqk.js";
|
|
2
2
|
import { Server, ServerCredentials, status } from "@grpc/grpc-js";
|
|
3
3
|
|
|
4
4
|
//#region src/server/grpc.ts
|
|
@@ -316,6 +316,181 @@ var ErrorAggregator = class {
|
|
|
316
316
|
}
|
|
317
317
|
};
|
|
318
318
|
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/server/webmcp-aggregator.ts
|
|
321
|
+
/** Span names this fold consumes. Everything else in the trace is ignored. */
|
|
322
|
+
const INSTALL = "webmcp.install";
|
|
323
|
+
const REGISTER = "webmcp.tool.register";
|
|
324
|
+
const EXECUTE = "webmcp.tool.execute";
|
|
325
|
+
const WITHDRAW = "webmcp.tool.withdraw";
|
|
326
|
+
/** How many recent executions a tool carries. Enough to see a pattern, not a log. */
|
|
327
|
+
const RECENT_CALLS = 5;
|
|
328
|
+
/**
|
|
329
|
+
* `{"content":[{"type":"text","text":` + `}]}` and the quoting around it.
|
|
330
|
+
*
|
|
331
|
+
* Chrome does not unwrap the MCP envelope, so an enveloped result spends these
|
|
332
|
+
* bytes of the agent's context on structure carrying no information. Measured
|
|
333
|
+
* rather than derived: 45 bytes wrapped versus 13 plain for the same text.
|
|
334
|
+
*/
|
|
335
|
+
const ENVELOPE_OVERHEAD_BYTES = 32;
|
|
336
|
+
const median = (values) => {
|
|
337
|
+
if (values.length === 0) return 0;
|
|
338
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
339
|
+
const mid = Math.floor(sorted.length / 2);
|
|
340
|
+
return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
|
|
341
|
+
};
|
|
342
|
+
const csv = (value) => {
|
|
343
|
+
const raw = require_json_fields.asString(value);
|
|
344
|
+
if (!raw) return [];
|
|
345
|
+
return raw.split(",").map((part) => part.trim()).filter(Boolean);
|
|
346
|
+
};
|
|
347
|
+
/**
|
|
348
|
+
* Spans with no installation id — emitted by an `autotel-webmcp` older than the
|
|
349
|
+
* lifecycle release. Grouping them under one key per service keeps them
|
|
350
|
+
* readable as a single legacy installation rather than scattering one
|
|
351
|
+
* installation per tool.
|
|
352
|
+
*/
|
|
353
|
+
const UNKNOWN_INSTALLATION = "unknown";
|
|
354
|
+
const isWebMcpSpan = (span) => span.name === INSTALL || span.name === REGISTER || span.name === EXECUTE || span.name === WITHDRAW;
|
|
355
|
+
function foldWebMcpTools(traces, activityWindow) {
|
|
356
|
+
const tools = /* @__PURE__ */ new Map();
|
|
357
|
+
/** Installations seen, and whether each one ever registered anything. */
|
|
358
|
+
const installations = /* @__PURE__ */ new Map();
|
|
359
|
+
const latestInstallation = /* @__PURE__ */ new Map();
|
|
360
|
+
const spans = traces.flatMap((trace) => trace.spans.map((span) => ({
|
|
361
|
+
span,
|
|
362
|
+
service: trace.service
|
|
363
|
+
}))).sort((a, b) => a.span.startTime - b.span.startTime);
|
|
364
|
+
for (const { span, service } of spans) {
|
|
365
|
+
if (!isWebMcpSpan(span)) continue;
|
|
366
|
+
if (activityWindow && span.startTime > activityWindow.end) continue;
|
|
367
|
+
const attrs = span.attributes ?? {};
|
|
368
|
+
const installationId = require_json_fields.asString(attrs["webmcp.installation.id"]) ?? UNKNOWN_INSTALLATION;
|
|
369
|
+
const installationKey = `${service}${installationId}`;
|
|
370
|
+
if (!installations.has(installationKey)) installations.set(installationKey, false);
|
|
371
|
+
if (span.name === INSTALL) {
|
|
372
|
+
latestInstallation.set(service, installationId);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const name = require_json_fields.asString(attrs["webmcp.tool.name"]) ?? require_json_fields.asString(attrs["gen_ai.tool.name"]);
|
|
376
|
+
if (!name) continue;
|
|
377
|
+
const key = `${service}${installationId}${name}`;
|
|
378
|
+
let tool = tools.get(key);
|
|
379
|
+
if (!tool) {
|
|
380
|
+
tool = {
|
|
381
|
+
name,
|
|
382
|
+
installationId,
|
|
383
|
+
service,
|
|
384
|
+
sessionId: require_json_fields.asString(attrs["session.id"]),
|
|
385
|
+
observedAtRegistration: false,
|
|
386
|
+
offered: false,
|
|
387
|
+
firstSeen: span.startTime,
|
|
388
|
+
lastSeen: span.startTime,
|
|
389
|
+
annotationsSent: [],
|
|
390
|
+
annotationsDropped: [],
|
|
391
|
+
calls: 0,
|
|
392
|
+
errors: 0,
|
|
393
|
+
envelopeCalls: 0,
|
|
394
|
+
envelopeBytes: 0,
|
|
395
|
+
substitutedCalls: 0,
|
|
396
|
+
resultBytes: 0,
|
|
397
|
+
medianResultBytes: 0,
|
|
398
|
+
resultSizes: [],
|
|
399
|
+
recentCalls: [],
|
|
400
|
+
traceId: span.traceId,
|
|
401
|
+
spanId: span.spanId
|
|
402
|
+
};
|
|
403
|
+
tools.set(key, tool);
|
|
404
|
+
}
|
|
405
|
+
tool.firstSeen = Math.min(tool.firstSeen, span.startTime);
|
|
406
|
+
tool.lastSeen = Math.max(tool.lastSeen, span.startTime);
|
|
407
|
+
tool.sessionId ??= require_json_fields.asString(attrs["session.id"]);
|
|
408
|
+
switch (span.name) {
|
|
409
|
+
case REGISTER:
|
|
410
|
+
installations.set(installationKey, true);
|
|
411
|
+
tool.observedAtRegistration = true;
|
|
412
|
+
tool.offered = true;
|
|
413
|
+
tool.descriptionLength = require_json_fields.asNumber(attrs["webmcp.tool.description.length"]);
|
|
414
|
+
tool.hasInputSchema = require_json_fields.asBoolean(attrs["webmcp.tool.has_input_schema"]);
|
|
415
|
+
tool.annotationsSent = csv(attrs["webmcp.annotations.sent"]);
|
|
416
|
+
tool.annotationsDropped = csv(attrs["webmcp.annotations.dropped"]);
|
|
417
|
+
tool.traceId = span.traceId;
|
|
418
|
+
tool.spanId = span.spanId;
|
|
419
|
+
break;
|
|
420
|
+
case WITHDRAW:
|
|
421
|
+
tool.offered = false;
|
|
422
|
+
break;
|
|
423
|
+
case EXECUTE: {
|
|
424
|
+
if (activityWindow && span.startTime < activityWindow.start) break;
|
|
425
|
+
tool.calls += 1;
|
|
426
|
+
const bytes = require_json_fields.asNumber(attrs["webmcp.result.bytes"]) ?? 0;
|
|
427
|
+
tool.resultBytes += bytes;
|
|
428
|
+
tool.resultSizes.push(bytes);
|
|
429
|
+
if (require_json_fields.asBoolean(attrs["webmcp.result.envelope"])) {
|
|
430
|
+
tool.envelopeCalls += 1;
|
|
431
|
+
tool.envelopeBytes += ENVELOPE_OVERHEAD_BYTES;
|
|
432
|
+
}
|
|
433
|
+
if (require_json_fields.asBoolean(attrs["webmcp.result.substituted"])) tool.substitutedCalls += 1;
|
|
434
|
+
const failed = span.status.code === "ERROR" || (require_json_fields.asBoolean(attrs["webmcp.result.error"]) ?? false) || require_json_fields.asString(attrs["error.type"]) !== void 0;
|
|
435
|
+
if (failed) tool.errors += 1;
|
|
436
|
+
tool.recentCalls.push({
|
|
437
|
+
timestamp: span.startTime,
|
|
438
|
+
durationMs: span.duration,
|
|
439
|
+
resultBytes: bytes,
|
|
440
|
+
resultType: require_json_fields.asString(attrs["webmcp.result.type"]),
|
|
441
|
+
envelope: require_json_fields.asBoolean(attrs["webmcp.result.envelope"]) ?? false,
|
|
442
|
+
substituted: require_json_fields.asBoolean(attrs["webmcp.result.substituted"]) ?? false,
|
|
443
|
+
error: failed,
|
|
444
|
+
input: require_json_fields.asString(attrs["webmcp.input"]) ?? require_json_fields.asString(attrs["gen_ai.tool.call.arguments"]),
|
|
445
|
+
result: require_json_fields.asString(attrs["webmcp.result"]) ?? require_json_fields.asString(attrs["gen_ai.tool.call.result"]),
|
|
446
|
+
traceId: span.traceId,
|
|
447
|
+
spanId: span.spanId
|
|
448
|
+
});
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const list = [...tools.values()].map((tool) => {
|
|
454
|
+
const { resultSizes, ...rest } = tool;
|
|
455
|
+
return {
|
|
456
|
+
...rest,
|
|
457
|
+
offered: rest.offered && (latestInstallation.get(rest.service) ?? rest.installationId) === rest.installationId,
|
|
458
|
+
medianResultBytes: median(resultSizes),
|
|
459
|
+
recentCalls: [...tool.recentCalls].sort((a, b) => b.timestamp - a.timestamp).slice(0, RECENT_CALLS)
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
list.sort((a, b) => Number(b.offered) - Number(a.offered) || b.calls - a.calls || a.name.localeCompare(b.name));
|
|
463
|
+
return {
|
|
464
|
+
tools: list,
|
|
465
|
+
summary: summarize(list, installations)
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function summarize(tools, installations) {
|
|
469
|
+
const summary = {
|
|
470
|
+
installations: installations.size,
|
|
471
|
+
emptyInstallations: [...installations.values()].filter((saw) => !saw).length,
|
|
472
|
+
toolsOffered: 0,
|
|
473
|
+
toolsWithdrawn: 0,
|
|
474
|
+
calls: 0,
|
|
475
|
+
errors: 0,
|
|
476
|
+
resultBytes: 0,
|
|
477
|
+
envelopeBytes: 0,
|
|
478
|
+
toolsWithDroppedAnnotations: 0,
|
|
479
|
+
toolsWithoutInputSchema: 0
|
|
480
|
+
};
|
|
481
|
+
for (const tool of tools) {
|
|
482
|
+
if (tool.offered) summary.toolsOffered += 1;
|
|
483
|
+
else summary.toolsWithdrawn += 1;
|
|
484
|
+
summary.calls += tool.calls;
|
|
485
|
+
summary.errors += tool.errors;
|
|
486
|
+
summary.resultBytes += tool.resultBytes;
|
|
487
|
+
summary.envelopeBytes += tool.envelopeBytes;
|
|
488
|
+
if (tool.annotationsDropped.length > 0) summary.toolsWithDroppedAnnotations += 1;
|
|
489
|
+
if (tool.observedAtRegistration && tool.hasInputSchema === false) summary.toolsWithoutInputSchema += 1;
|
|
490
|
+
}
|
|
491
|
+
return summary;
|
|
492
|
+
}
|
|
493
|
+
|
|
319
494
|
//#endregion
|
|
320
495
|
//#region src/server/telemetry-limits.ts
|
|
321
496
|
const defaultLimit = 100;
|
|
@@ -1528,6 +1703,42 @@ var DevtoolsStore = class {
|
|
|
1528
1703
|
LIMIT ?`).all(Math.max(1, Math.min(limit, 500)));
|
|
1529
1704
|
return [.../* @__PURE__ */ new Set([...Object.keys(schema.columns), ...rows.map((row) => row.key)])];
|
|
1530
1705
|
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Values of one attribute paired with another on the same entity.
|
|
1708
|
+
*
|
|
1709
|
+
* `searchAttributes` matches on value text, which cannot answer "what arms
|
|
1710
|
+
* does this experiment have". Pairing two keys across the same span can, and
|
|
1711
|
+
* that is what turns a pair of cohorts into something the viewer offers
|
|
1712
|
+
* rather than something the reader has to type.
|
|
1713
|
+
*
|
|
1714
|
+
* Rows arrive grouped by `key`, each group's values commonest first, so a
|
|
1715
|
+
* caller can build the groups in one pass and take the two commonest as a
|
|
1716
|
+
* default pair.
|
|
1717
|
+
*
|
|
1718
|
+
* The join runs over `attribute_occurrences`, not the `attribute_values`
|
|
1719
|
+
* dictionary: occurrences are deleted with their span, so retention prunes
|
|
1720
|
+
* them, and they carry the entity a value was seen on, so an arm is only
|
|
1721
|
+
* offered for the experiment it actually ran under. The dictionary can do
|
|
1722
|
+
* neither — it counts values for the lifetime of the database and forgets
|
|
1723
|
+
* which span each came from, which would offer arms belonging to a different
|
|
1724
|
+
* experiment and experiments whose spans are long gone.
|
|
1725
|
+
*/
|
|
1726
|
+
pairedAttributeValues(signal, key, pairedKey, limit = 200) {
|
|
1727
|
+
return this.db.prepare(`
|
|
1728
|
+
SELECT a.value_json AS value_json, b.value_json AS paired_json, count(*) AS count
|
|
1729
|
+
FROM attribute_occurrences a
|
|
1730
|
+
JOIN attribute_occurrences b
|
|
1731
|
+
ON b.signal = a.signal AND b.entity_id = a.entity_id AND b.key = ?
|
|
1732
|
+
WHERE a.signal = ? AND a.key = ?
|
|
1733
|
+
GROUP BY a.value_json, b.value_json
|
|
1734
|
+
ORDER BY value_json ASC, count DESC, paired_json ASC
|
|
1735
|
+
LIMIT ?
|
|
1736
|
+
`).all(pairedKey, signal, key, Math.max(1, Math.min(limit, 500))).map((row) => ({
|
|
1737
|
+
value: JSON.parse(row.value_json),
|
|
1738
|
+
paired: JSON.parse(row.paired_json),
|
|
1739
|
+
count: Number(row.count)
|
|
1740
|
+
}));
|
|
1741
|
+
}
|
|
1531
1742
|
searchAttributes(signal, value, limit = 50) {
|
|
1532
1743
|
return this.db.prepare(`
|
|
1533
1744
|
SELECT key, value_json, seen_count
|
|
@@ -2503,11 +2714,15 @@ var DevtoolsServer = class {
|
|
|
2503
2714
|
this.log(`Client disconnected (${this.clients.size} total)`);
|
|
2504
2715
|
});
|
|
2505
2716
|
});
|
|
2506
|
-
if (!options.server)
|
|
2507
|
-
const
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2717
|
+
if (!options.server) {
|
|
2718
|
+
const listening = () => {
|
|
2719
|
+
const addr = this.httpServer.address();
|
|
2720
|
+
if (addr && typeof addr === "object") this._port = addr.port;
|
|
2721
|
+
this.log(`WebSocket server listening on port ${this._port}`);
|
|
2722
|
+
};
|
|
2723
|
+
if (options.host == null) this.httpServer.listen(this._port, listening);
|
|
2724
|
+
else this.httpServer.listen(this._port, options.host, listening);
|
|
2725
|
+
}
|
|
2511
2726
|
}
|
|
2512
2727
|
get port() {
|
|
2513
2728
|
const addr = this.httpServer.address();
|
|
@@ -2714,6 +2929,44 @@ var DevtoolsServer = class {
|
|
|
2714
2929
|
} while (cursor);
|
|
2715
2930
|
return { errors: aggregator.getErrorGroups() };
|
|
2716
2931
|
}
|
|
2932
|
+
/**
|
|
2933
|
+
* Fold WebMCP lifecycle history through the window end into the tool surface
|
|
2934
|
+
* an agent is offered, while counting executions only inside the window.
|
|
2935
|
+
*
|
|
2936
|
+
* Drains every page rather than folding the first one: an inventory built
|
|
2937
|
+
* from a page of results does not fail, it *under-reports* — "2 tools dropped
|
|
2938
|
+
* annotations" when the answer is 6 — and gets more wrong the more traffic
|
|
2939
|
+
* there is, which is backwards for an observability answer.
|
|
2940
|
+
*
|
|
2941
|
+
* The span-name filter is composed here rather than accepted from the client:
|
|
2942
|
+
* this endpoint answers one question, and a caller-supplied predicate could
|
|
2943
|
+
* only narrow it into a wrong answer.
|
|
2944
|
+
*/
|
|
2945
|
+
queryWebMcp(args) {
|
|
2946
|
+
const traces = [];
|
|
2947
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
2948
|
+
let cursor;
|
|
2949
|
+
do {
|
|
2950
|
+
const result = this.store.queryTraces({
|
|
2951
|
+
query: "name ^ \"webmcp.\"",
|
|
2952
|
+
window: args.window ? {
|
|
2953
|
+
start: 0,
|
|
2954
|
+
end: args.window.end
|
|
2955
|
+
} : void 0,
|
|
2956
|
+
limit: args.limit,
|
|
2957
|
+
cursor
|
|
2958
|
+
});
|
|
2959
|
+
if (result.errors) return {
|
|
2960
|
+
webmcp: foldWebMcpTools([]),
|
|
2961
|
+
errors_parse: result.errors
|
|
2962
|
+
};
|
|
2963
|
+
traces.push(...result.traces);
|
|
2964
|
+
cursor = result.nextCursor ?? void 0;
|
|
2965
|
+
if (cursor && seenCursors.has(cursor)) break;
|
|
2966
|
+
if (cursor) seenCursors.add(cursor);
|
|
2967
|
+
} while (cursor);
|
|
2968
|
+
return { webmcp: foldWebMcpTools(traces, args.window) };
|
|
2969
|
+
}
|
|
2717
2970
|
/** Run a log query against the durable store. */
|
|
2718
2971
|
queryLogs(args) {
|
|
2719
2972
|
return this.store.queryLogs(args);
|
|
@@ -2721,6 +2974,9 @@ var DevtoolsServer = class {
|
|
|
2721
2974
|
listQueryFields(signal) {
|
|
2722
2975
|
return this.store.listQueryFields(signal);
|
|
2723
2976
|
}
|
|
2977
|
+
pairedAttributeValues(signal, key, pairedKey, limit) {
|
|
2978
|
+
return this.store.pairedAttributeValues(signal, key, pairedKey, limit);
|
|
2979
|
+
}
|
|
2724
2980
|
searchAttributes(signal, value, limit) {
|
|
2725
2981
|
return this.store.searchAttributes(signal, value, limit);
|
|
2726
2982
|
}
|
|
@@ -3435,6 +3691,12 @@ function attachDevtoolsRoutes(httpServer, devtools, options = {}) {
|
|
|
3435
3691
|
}
|
|
3436
3692
|
const params = new URL(url, "http://localhost").searchParams;
|
|
3437
3693
|
const signal = params.get("signal") === "logs" ? "logs" : "traces";
|
|
3694
|
+
const key = params.get("key");
|
|
3695
|
+
const pair = params.get("pair");
|
|
3696
|
+
if (key !== null && pair !== null) {
|
|
3697
|
+
sendJson(res, 200, { pairs: devtools.pairedAttributeValues(signal, key, pair) });
|
|
3698
|
+
return;
|
|
3699
|
+
}
|
|
3438
3700
|
sendJson(res, 200, { attributes: devtools.searchAttributes(signal, params.get("value") ?? "") });
|
|
3439
3701
|
return;
|
|
3440
3702
|
}
|
|
@@ -3708,6 +3970,30 @@ function attachDevtoolsRoutes(httpServer, devtools, options = {}) {
|
|
|
3708
3970
|
}
|
|
3709
3971
|
return;
|
|
3710
3972
|
}
|
|
3973
|
+
if (req.method === "POST" && url === "/api/query/webmcp") {
|
|
3974
|
+
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3975
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
try {
|
|
3979
|
+
const body = await readJsonBody(req);
|
|
3980
|
+
const result = devtools.queryWebMcp({
|
|
3981
|
+
window: body.window,
|
|
3982
|
+
limit: body.limit
|
|
3983
|
+
});
|
|
3984
|
+
if (result.errors_parse) {
|
|
3985
|
+
sendJson(res, 400, { errors: result.errors_parse });
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
sendJson(res, 200, { webmcp: result.webmcp });
|
|
3989
|
+
} catch (e) {
|
|
3990
|
+
sendJson(res, 400, {
|
|
3991
|
+
error: "Invalid query request",
|
|
3992
|
+
message: e instanceof Error ? e.message : String(e)
|
|
3993
|
+
});
|
|
3994
|
+
}
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3711
3997
|
if (req.method === "GET" && url === "/api/metrics") {
|
|
3712
3998
|
if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
|
|
3713
3999
|
sendJson(res, 403, { error: "Forbidden" });
|