toad-eye 2.9.1 → 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 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 (278+ tests)
172
+ TypeScript · OpenTelemetry SDK 2.x · Hono · Docker Compose (Prometheus, Jaeger, Grafana, OTel Collector) · Vitest (285+ tests)
@@ -7,7 +7,7 @@ function smtpCacheKey(config) {
7
7
  }
8
8
  function formatMessage(alert) {
9
9
  const topModelsText = alert.topModels.length > 0
10
- ? `\nTop models by spend:\n${alert.topModels.map((m) => ` • ${m.model}: $${m.value.toFixed(4)}`).join("\n")}`
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)
@@ -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],
@@ -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.parentSpanId || undefined,
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
- export declare function enableMcpInstrumentation(): boolean;
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
- export function enableMcpInstrumentation() {
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ function resolveServerClass() {
21
22
  try {
22
- require.resolve(MODULE_NAME);
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 false;
28
+ return null;
26
29
  }
27
- // eslint-disable-next-line @typescript-eslint/no-require-imports
28
- const sdk = require(MODULE_NAME);
29
- const McpServer = sdk.McpServer ?? sdk.default?.McpServer;
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])
@@ -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
- export declare function enableMcpClientInstrumentation(): boolean;
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;
@@ -34,18 +34,35 @@ function injectTraceContext(params) {
34
34
  }
35
35
  return params;
36
36
  }
37
- export function enableMcpClientInstrumentation() {
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
- require.resolve("@modelcontextprotocol/sdk/client/index.js");
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
- return false;
46
+ // CJS failed — will try ESM in async path
47
+ return null;
43
48
  }
44
- // eslint-disable-next-line @typescript-eslint/no-require-imports
45
- const sdk = require("@modelcontextprotocol/sdk/client/index.js");
46
- const Client = sdk.Client ?? sdk.default?.Client;
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;
@@ -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.9.1",
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 (gen_ai_agent_tool_name) (gen_ai_agent_tool_usage_total))",
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 (gen_ai_agent_tool_name) (gen_ai_agent_tool_usage_total))",
179
- "legendFormat": "{{gen_ai_agent_tool_name}}",
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 (gen_ai_agent_tool_name) (rate(gen_ai_agent_tool_usage_total[5m]))",
235
- "legendFormat": "{{gen_ai_agent_tool_name}}",
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",
@@ -419,6 +419,7 @@
419
419
  "from": "now-7d",
420
420
  "to": "now"
421
421
  },
422
+ "refresh": "1m",
422
423
  "timepicker": {},
423
424
  "timezone": "utc",
424
425
  "title": "toad-eye / FinOps Attribution",
@@ -287,6 +287,7 @@
287
287
  "from": "now-1h",
288
288
  "to": "now"
289
289
  },
290
+ "refresh": "30s",
290
291
  "timepicker": {},
291
292
  "timezone": "browser",
292
293
  "title": "MCP Tool Analytics",
@@ -43,7 +43,7 @@
43
43
  "datasource": { "type": "prometheus", "uid": "prometheus" },
44
44
  "targets": [
45
45
  {
46
- "expr": "sum(rate(gen_ai_client_operation_duration_sum[5m])) / sum(rate(gen_ai_client_operation_duration_count[5m])) or vector(0)",
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(gen_ai_client_operation_duration_sum[5m])) / sum(rate(gen_ai_client_operation_duration_count[5m]))",
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 Type",
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, error_type) (rate(gen_ai_client_errors_total{job=~\"$service\", gen_ai_provider_name=~\"$provider\"}[5m]))",
184
- "legendFormat": "{{gen_ai_provider_name}} — {{error_type}}",
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",