toad-eye 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/alerts/channels.js +4 -1
- package/dist/alerts/conditions.js +2 -2
- package/dist/alerts/grafana.js +1 -0
- package/dist/alerts/manager.js +2 -0
- package/dist/core/span-end-processor.js +1 -1
- package/dist/instrumentations/mcp.d.ts +5 -1
- package/dist/instrumentations/mcp.js +15 -6
- package/dist/mcp/client.d.ts +5 -1
- package/dist/mcp/client.js +23 -6
- package/dist/mcp/middleware.js +14 -4
- package/package.json +1 -1
- package/templates/grafana/dashboards/agent-workflow.json +7 -39
- package/templates/grafana/dashboards/finops-attribution.json +1 -0
- package/templates/grafana/dashboards/mcp-analytics.json +1 -0
- package/templates/grafana/dashboards/mcp-e2e.json +3 -2
- package/templates/grafana/dashboards/provider-health.json +5 -3
package/README.md
CHANGED
|
@@ -169,4 +169,4 @@ Full metrics and span attribute reference: [COMPATIBILITY.md](https://github.com
|
|
|
169
169
|
|
|
170
170
|
## Tech Stack
|
|
171
171
|
|
|
172
|
-
TypeScript · OpenTelemetry SDK 2.x · Hono · Docker Compose (Prometheus, Jaeger, Grafana, OTel Collector) · Vitest (
|
|
172
|
+
TypeScript · OpenTelemetry SDK 2.x · Hono · Docker Compose (Prometheus, Jaeger, Grafana, OTel Collector) · Vitest (285+ tests)
|
package/dist/alerts/channels.js
CHANGED
|
@@ -7,7 +7,7 @@ function smtpCacheKey(config) {
|
|
|
7
7
|
}
|
|
8
8
|
function formatMessage(alert) {
|
|
9
9
|
const topModelsText = alert.topModels.length > 0
|
|
10
|
-
? `\nTop
|
|
10
|
+
? `\nTop contributors:\n${alert.topModels.map((m) => ` • ${m.model}: ${m.value.toFixed(4)}`).join("\n")}`
|
|
11
11
|
: "";
|
|
12
12
|
return [
|
|
13
13
|
`🚨 Alert: ${alert.rule.name}`,
|
|
@@ -27,6 +27,7 @@ async function sendTelegram(config, alert) {
|
|
|
27
27
|
chat_id: config.chatId,
|
|
28
28
|
text: formatMessage(alert),
|
|
29
29
|
}),
|
|
30
|
+
signal: AbortSignal.timeout(10_000),
|
|
30
31
|
});
|
|
31
32
|
if (!res.ok)
|
|
32
33
|
throw new Error(`Telegram send failed: ${res.status}`);
|
|
@@ -36,6 +37,7 @@ async function sendSlackWebhook(config, alert) {
|
|
|
36
37
|
method: "POST",
|
|
37
38
|
headers: { "Content-Type": "application/json" },
|
|
38
39
|
body: JSON.stringify({ text: formatMessage(alert) }),
|
|
40
|
+
signal: AbortSignal.timeout(10_000),
|
|
39
41
|
});
|
|
40
42
|
if (!res.ok)
|
|
41
43
|
throw new Error(`Slack webhook failed: ${res.status}`);
|
|
@@ -44,6 +46,7 @@ async function sendWebhook(config, alert) {
|
|
|
44
46
|
const res = await fetch(config.url, {
|
|
45
47
|
method: "POST",
|
|
46
48
|
headers: { "Content-Type": "application/json", ...config.headers },
|
|
49
|
+
signal: AbortSignal.timeout(10_000),
|
|
47
50
|
body: JSON.stringify({
|
|
48
51
|
alertName: alert.rule.name,
|
|
49
52
|
metric: alert.rule.metric,
|
|
@@ -93,7 +93,7 @@ function matches(value, comparator, threshold) {
|
|
|
93
93
|
}
|
|
94
94
|
async function queryScalar(prometheusUrl, promql) {
|
|
95
95
|
const url = `${prometheusUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
|
|
96
|
-
const res = await fetch(url);
|
|
96
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
97
97
|
if (!res.ok)
|
|
98
98
|
throw new Error(`Prometheus query failed: ${res.status} ${res.statusText}`);
|
|
99
99
|
const data = (await res.json());
|
|
@@ -103,7 +103,7 @@ async function queryScalar(prometheusUrl, promql) {
|
|
|
103
103
|
async function queryTopModels(prometheusUrl, metric, window) {
|
|
104
104
|
const promql = buildTopModelsQuery(metric, window);
|
|
105
105
|
const url = `${prometheusUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
|
|
106
|
-
const res = await fetch(url);
|
|
106
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
107
107
|
if (!res.ok)
|
|
108
108
|
return [];
|
|
109
109
|
const labelKey = isMcpMetric(metric)
|
package/dist/alerts/grafana.js
CHANGED
|
@@ -12,6 +12,7 @@ export async function postGrafanaAnnotation(grafanaUrl, alert, apiKey) {
|
|
|
12
12
|
const res = await fetch(`${grafanaUrl}/api/annotations`, {
|
|
13
13
|
method: "POST",
|
|
14
14
|
headers,
|
|
15
|
+
signal: AbortSignal.timeout(10_000),
|
|
15
16
|
body: JSON.stringify({
|
|
16
17
|
time: Date.now(),
|
|
17
18
|
tags: ["toad-eye", "alert", alert.name],
|
package/dist/alerts/manager.js
CHANGED
|
@@ -14,6 +14,8 @@ export class AlertManager {
|
|
|
14
14
|
this.config = config;
|
|
15
15
|
}
|
|
16
16
|
start() {
|
|
17
|
+
if (this.intervalId !== undefined)
|
|
18
|
+
return;
|
|
17
19
|
const configured = this.config.evalIntervalSeconds ?? DEFAULT_EVAL_INTERVAL_SECONDS;
|
|
18
20
|
const clampedSeconds = Math.max(configured, MIN_EVAL_INTERVAL_SECONDS);
|
|
19
21
|
if (configured < MIN_EVAL_INTERVAL_SECONDS) {
|
|
@@ -34,7 +34,7 @@ function toSpanEndData(span) {
|
|
|
34
34
|
return {
|
|
35
35
|
traceId: ctx.traceId,
|
|
36
36
|
spanId: ctx.spanId,
|
|
37
|
-
parentSpanId: span.
|
|
37
|
+
parentSpanId: span.parentSpanContext?.spanId,
|
|
38
38
|
name: span.name,
|
|
39
39
|
kind: SPAN_KIND_MAP[span.kind] ?? "internal",
|
|
40
40
|
status: span.status.code === SpanStatusCode.ERROR
|
|
@@ -8,5 +8,9 @@
|
|
|
8
8
|
* Uses __toad_eye_mcp_patched flag to prevent double-instrumentation
|
|
9
9
|
* when both auto and manual approaches are used.
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Enable MCP server auto-instrumentation.
|
|
13
|
+
* For ESM projects, pass McpServer class directly to avoid CJS/ESM module mismatch.
|
|
14
|
+
*/
|
|
15
|
+
export declare function enableMcpInstrumentation(McpServerClass?: any): boolean;
|
|
12
16
|
export declare function disableMcpInstrumentation(): void;
|
|
@@ -17,16 +17,25 @@ const MODULE_NAME = "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
17
17
|
let mcpServerProto = null;
|
|
18
18
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
19
|
let originals = null;
|
|
20
|
-
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
function resolveServerClass() {
|
|
21
22
|
try {
|
|
22
|
-
require
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
|
+
const sdk = require(MODULE_NAME);
|
|
25
|
+
return sdk.McpServer ?? sdk.default?.McpServer;
|
|
23
26
|
}
|
|
24
27
|
catch {
|
|
25
|
-
return
|
|
28
|
+
return null;
|
|
26
29
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Enable MCP server auto-instrumentation.
|
|
33
|
+
* For ESM projects, pass McpServer class directly to avoid CJS/ESM module mismatch.
|
|
34
|
+
*/
|
|
35
|
+
export function enableMcpInstrumentation(
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
McpServerClass) {
|
|
38
|
+
const McpServer = McpServerClass ?? resolveServerClass();
|
|
30
39
|
if (!McpServer?.prototype)
|
|
31
40
|
return false;
|
|
32
41
|
if (McpServer.prototype[PATCHED_FLAG])
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -5,5 +5,9 @@
|
|
|
5
5
|
* Injects W3C traceparent into _meta field so server-side middleware
|
|
6
6
|
* can link client and server spans into one distributed trace.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Enable MCP client instrumentation by auto-resolving the Client class.
|
|
10
|
+
* Works in CJS projects. For ESM projects, prefer passing the Client class directly.
|
|
11
|
+
*/
|
|
12
|
+
export declare function enableMcpClientInstrumentation(ClientClass?: any): boolean;
|
|
9
13
|
export declare function disableMcpClientInstrumentation(): void;
|
package/dist/mcp/client.js
CHANGED
|
@@ -34,18 +34,35 @@ function injectTraceContext(params) {
|
|
|
34
34
|
}
|
|
35
35
|
return params;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
function resolveClientClass() {
|
|
39
|
+
// Try CJS first (works when consumer uses require)
|
|
38
40
|
try {
|
|
39
|
-
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
|
+
const sdk = require("@modelcontextprotocol/sdk/client/index.js");
|
|
43
|
+
return sdk.Client ?? sdk.default?.Client;
|
|
40
44
|
}
|
|
41
45
|
catch {
|
|
42
|
-
|
|
46
|
+
// CJS failed — will try ESM in async path
|
|
47
|
+
return null;
|
|
43
48
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Enable MCP client instrumentation by auto-resolving the Client class.
|
|
52
|
+
* Works in CJS projects. For ESM projects, prefer passing the Client class directly.
|
|
53
|
+
*/
|
|
54
|
+
export function enableMcpClientInstrumentation(
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
ClientClass) {
|
|
57
|
+
const Client = ClientClass ?? resolveClientClass();
|
|
58
|
+
return Client ? patchClient(Client) : false;
|
|
59
|
+
}
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
function patchClient(Client) {
|
|
47
62
|
if (!Client?.prototype)
|
|
48
63
|
return false;
|
|
64
|
+
if (typeof Client.prototype.callTool !== "function")
|
|
65
|
+
return false;
|
|
49
66
|
if (Client.prototype[PATCHED_FLAG])
|
|
50
67
|
return true;
|
|
51
68
|
const proto = Client.prototype;
|
package/dist/mcp/middleware.js
CHANGED
|
@@ -33,6 +33,15 @@ function truncate(value, maxBytes) {
|
|
|
33
33
|
return value;
|
|
34
34
|
return value.slice(0, maxBytes) + "...[truncated]";
|
|
35
35
|
}
|
|
36
|
+
function redactValue(v, keys) {
|
|
37
|
+
if (Array.isArray(v)) {
|
|
38
|
+
return v.map((item) => redactValue(item, keys));
|
|
39
|
+
}
|
|
40
|
+
if (typeof v === "object" && v !== null) {
|
|
41
|
+
return redactObject(v, keys);
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
36
45
|
function redactObject(obj, keys) {
|
|
37
46
|
if (typeof obj !== "object" || obj === null)
|
|
38
47
|
return {};
|
|
@@ -41,11 +50,8 @@ function redactObject(obj, keys) {
|
|
|
41
50
|
if (keys.includes(k)) {
|
|
42
51
|
result[k] = "[REDACTED]";
|
|
43
52
|
}
|
|
44
|
-
else if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
45
|
-
result[k] = redactObject(v, keys);
|
|
46
|
-
}
|
|
47
53
|
else {
|
|
48
|
-
result[k] = v;
|
|
54
|
+
result[k] = redactValue(v, keys);
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
return result;
|
|
@@ -74,7 +80,11 @@ function redactObject(obj, keys) {
|
|
|
74
80
|
* In stdio MCP transport, stdout IS the JSON-RPC channel —
|
|
75
81
|
* any stray console.log or OTel diagnostic would crash the connection.
|
|
76
82
|
*/
|
|
83
|
+
let stdioSafe = false;
|
|
77
84
|
function ensureStdioSafe() {
|
|
85
|
+
if (stdioSafe)
|
|
86
|
+
return;
|
|
87
|
+
stdioSafe = true;
|
|
78
88
|
const stderrLogger = new DiagConsoleLogger();
|
|
79
89
|
// Override the logger to use stderr-only methods
|
|
80
90
|
const safeLogger = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toad-eye",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Observability for MCP servers and LLM applications — traces, metrics, dashboards. One-line instrumentation for OpenAI, Anthropic, Gemini, and MCP. Self-hosted with Grafana + Prometheus + Jaeger.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
},
|
|
119
119
|
"targets": [
|
|
120
120
|
{
|
|
121
|
-
"expr": "count(count by (
|
|
121
|
+
"expr": "count(count by (gen_ai_tool_name) (gen_ai_agent_tool_usage_total))",
|
|
122
122
|
"refId": "A"
|
|
123
123
|
}
|
|
124
124
|
],
|
|
@@ -175,8 +175,8 @@
|
|
|
175
175
|
},
|
|
176
176
|
"targets": [
|
|
177
177
|
{
|
|
178
|
-
"expr": "sort_desc(sum by (
|
|
179
|
-
"legendFormat": "{{
|
|
178
|
+
"expr": "sort_desc(sum by (gen_ai_tool_name) (gen_ai_agent_tool_usage_total))",
|
|
179
|
+
"legendFormat": "{{gen_ai_tool_name}}",
|
|
180
180
|
"refId": "A"
|
|
181
181
|
}
|
|
182
182
|
]
|
|
@@ -231,8 +231,8 @@
|
|
|
231
231
|
},
|
|
232
232
|
"targets": [
|
|
233
233
|
{
|
|
234
|
-
"expr": "sum by (
|
|
235
|
-
"legendFormat": "{{
|
|
234
|
+
"expr": "sum by (gen_ai_tool_name) (rate(gen_ai_agent_tool_usage_total[5m]))",
|
|
235
|
+
"legendFormat": "{{gen_ai_tool_name}}",
|
|
236
236
|
"refId": "A"
|
|
237
237
|
}
|
|
238
238
|
],
|
|
@@ -283,45 +283,13 @@
|
|
|
283
283
|
"schemaVersion": 39,
|
|
284
284
|
"tags": ["toad-eye", "agent"],
|
|
285
285
|
"templating": {
|
|
286
|
-
"list": [
|
|
287
|
-
{
|
|
288
|
-
"name": "service",
|
|
289
|
-
"type": "query",
|
|
290
|
-
"datasource": {
|
|
291
|
-
"type": "prometheus",
|
|
292
|
-
"uid": "prometheus"
|
|
293
|
-
},
|
|
294
|
-
"query": "label_values(gen_ai_client_requests_total, job)",
|
|
295
|
-
"includeAll": true,
|
|
296
|
-
"allValue": ".*",
|
|
297
|
-
"current": {
|
|
298
|
-
"text": "All",
|
|
299
|
-
"value": "$__all"
|
|
300
|
-
},
|
|
301
|
-
"multi": true
|
|
302
|
-
},
|
|
303
|
-
{
|
|
304
|
-
"name": "tool",
|
|
305
|
-
"type": "query",
|
|
306
|
-
"datasource": {
|
|
307
|
-
"type": "prometheus",
|
|
308
|
-
"uid": "prometheus"
|
|
309
|
-
},
|
|
310
|
-
"query": "label_values(gen_ai_agent_tool_usage_total, gen_ai_agent_tool_name)",
|
|
311
|
-
"includeAll": true,
|
|
312
|
-
"multi": true,
|
|
313
|
-
"current": {
|
|
314
|
-
"text": "All",
|
|
315
|
-
"value": "$__all"
|
|
316
|
-
},
|
|
317
|
-
"regex": ""
|
|
318
|
-
}
|
|
319
|
-
]
|
|
286
|
+
"list": []
|
|
320
287
|
},
|
|
321
288
|
"time": {
|
|
322
289
|
"from": "now-6h",
|
|
323
290
|
"to": "now"
|
|
324
291
|
},
|
|
292
|
+
"refresh": "30s",
|
|
325
293
|
"timepicker": {},
|
|
326
294
|
"timezone": "utc",
|
|
327
295
|
"title": "toad-eye / Agent Workflow",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
44
44
|
"targets": [
|
|
45
45
|
{
|
|
46
|
-
"expr": "sum(rate(
|
|
46
|
+
"expr": "sum(rate(gen_ai_client_operation_duration_milliseconds_sum[5m])) / sum(rate(gen_ai_client_operation_duration_milliseconds_count[5m])) or vector(0)",
|
|
47
47
|
"refId": "A"
|
|
48
48
|
}
|
|
49
49
|
],
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
125
125
|
"targets": [
|
|
126
126
|
{
|
|
127
|
-
"expr": "sum(rate(
|
|
127
|
+
"expr": "sum(rate(gen_ai_client_operation_duration_milliseconds_sum[5m])) / sum(rate(gen_ai_client_operation_duration_milliseconds_count[5m]))",
|
|
128
128
|
"legendFormat": "LLM Avg Latency",
|
|
129
129
|
"refId": "A"
|
|
130
130
|
},
|
|
@@ -315,6 +315,7 @@
|
|
|
315
315
|
"from": "now-1h",
|
|
316
316
|
"to": "now"
|
|
317
317
|
},
|
|
318
|
+
"refresh": "30s",
|
|
318
319
|
"timepicker": {},
|
|
319
320
|
"timezone": "browser",
|
|
320
321
|
"title": "MCP End-to-End",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
}
|
|
167
167
|
},
|
|
168
168
|
{
|
|
169
|
-
"title": "Error Breakdown by
|
|
169
|
+
"title": "Error Breakdown by Model",
|
|
170
170
|
"type": "timeseries",
|
|
171
171
|
"gridPos": {
|
|
172
172
|
"h": 8,
|
|
@@ -180,8 +180,8 @@
|
|
|
180
180
|
},
|
|
181
181
|
"targets": [
|
|
182
182
|
{
|
|
183
|
-
"expr": "sum by (gen_ai_provider_name,
|
|
184
|
-
"legendFormat": "{{gen_ai_provider_name}} — {{
|
|
183
|
+
"expr": "sum by (gen_ai_provider_name, gen_ai_request_model) (rate(gen_ai_client_errors_total{job=~\"$service\", gen_ai_provider_name=~\"$provider\"}[5m]))",
|
|
184
|
+
"legendFormat": "{{gen_ai_provider_name}} — {{gen_ai_request_model}}",
|
|
185
185
|
"refId": "A"
|
|
186
186
|
}
|
|
187
187
|
],
|
|
@@ -276,6 +276,7 @@
|
|
|
276
276
|
},
|
|
277
277
|
"query": "label_values(gen_ai_client_requests_total, gen_ai_provider_name)",
|
|
278
278
|
"includeAll": true,
|
|
279
|
+
"allValue": ".*",
|
|
279
280
|
"multi": true,
|
|
280
281
|
"current": {
|
|
281
282
|
"text": "All",
|
|
@@ -289,6 +290,7 @@
|
|
|
289
290
|
"from": "now-6h",
|
|
290
291
|
"to": "now"
|
|
291
292
|
},
|
|
293
|
+
"refresh": "30s",
|
|
292
294
|
"timepicker": {},
|
|
293
295
|
"timezone": "utc",
|
|
294
296
|
"title": "toad-eye / Provider Health",
|