toad-eye 2.9.2 → 2.11.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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/alerts/channels.js +4 -1
  3. package/dist/alerts/conditions.js +3 -2
  4. package/dist/alerts/grafana.js +1 -0
  5. package/dist/alerts/manager.js +2 -0
  6. package/dist/cli.js +76 -8
  7. package/dist/core/console-exporter.d.ts +17 -0
  8. package/dist/core/console-exporter.js +55 -0
  9. package/dist/core/metrics.d.ts +1 -0
  10. package/dist/core/metrics.js +12 -0
  11. package/dist/core/span-end-processor.js +1 -1
  12. package/dist/core/spans.d.ts +4 -0
  13. package/dist/core/spans.js +21 -1
  14. package/dist/core/tracer.js +27 -16
  15. package/dist/instrumentations/create.js +0 -1
  16. package/dist/instrumentations/mcp.d.ts +5 -1
  17. package/dist/instrumentations/mcp.js +15 -6
  18. package/dist/mcp/client.d.ts +5 -1
  19. package/dist/mcp/client.js +29 -6
  20. package/dist/mcp/index.d.ts +1 -0
  21. package/dist/mcp/index.js +1 -0
  22. package/dist/mcp/metrics.d.ts +1 -0
  23. package/dist/mcp/metrics.js +11 -0
  24. package/dist/mcp/middleware.js +14 -4
  25. package/dist/types/config.d.ts +2 -0
  26. package/dist/types/metrics.d.ts +2 -0
  27. package/dist/types/metrics.js +3 -0
  28. package/package.json +1 -1
  29. package/templates/docker-compose.yml +4 -4
  30. package/templates/grafana/dashboards/agent-workflow.json +32 -47
  31. package/templates/grafana/dashboards/cost-breakdown.json +14 -7
  32. package/templates/grafana/dashboards/error-drilldown.json +12 -6
  33. package/templates/grafana/dashboards/finops-attribution.json +39 -8
  34. package/templates/grafana/dashboards/latency-analysis.json +8 -4
  35. package/templates/grafana/dashboards/llm-overview.json +22 -11
  36. package/templates/grafana/dashboards/mcp-analytics.json +177 -43
  37. package/templates/grafana/dashboards/mcp-e2e.json +198 -51
  38. package/templates/grafana/dashboards/mcp-server.json +46 -9
  39. package/templates/grafana/dashboards/model-comparison.json +10 -5
  40. package/templates/grafana/dashboards/provider-health.json +15 -8
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,
@@ -11,6 +11,7 @@ const METRIC_MAP = {
11
11
  "gen_ai.mcp.tool.duration": "gen_ai_mcp_tool_duration",
12
12
  "gen_ai.mcp.resource.reads": "gen_ai_mcp_resource_reads_total",
13
13
  "gen_ai.mcp.tool.callers": "gen_ai_mcp_tool_callers_total",
14
+ "gen_ai.mcp.tool.hallucinations": "gen_ai_mcp_tool_hallucinations_total",
14
15
  // Legacy names (backward compat)
15
16
  "llm.request.cost": "gen_ai_client_request_cost_USD_sum",
16
17
  "llm.request.duration": "gen_ai_client_operation_duration_milliseconds",
@@ -93,7 +94,7 @@ function matches(value, comparator, threshold) {
93
94
  }
94
95
  async function queryScalar(prometheusUrl, promql) {
95
96
  const url = `${prometheusUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
96
- const res = await fetch(url);
97
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
97
98
  if (!res.ok)
98
99
  throw new Error(`Prometheus query failed: ${res.status} ${res.statusText}`);
99
100
  const data = (await res.json());
@@ -103,7 +104,7 @@ async function queryScalar(prometheusUrl, promql) {
103
104
  async function queryTopModels(prometheusUrl, metric, window) {
104
105
  const promql = buildTopModelsQuery(metric, window);
105
106
  const url = `${prometheusUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
106
- const res = await fetch(url);
107
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
107
108
  if (!res.ok)
108
109
  return [];
109
110
  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) {
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn } from "node:child_process";
3
+ import { createConnection } from "node:net";
3
4
  import { cpSync, existsSync, mkdirSync, realpathSync, writeFileSync, } from "node:fs";
4
5
  import { join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -73,17 +74,66 @@ function requireDocker() {
73
74
  process.exit(1);
74
75
  }
75
76
  }
77
+ const PORT_CONFIG = [
78
+ { env: "COLLECTOR_PORT", default: 4318, service: "OTel Collector" },
79
+ { env: "PROMETHEUS_PORT", default: 9090, service: "Prometheus" },
80
+ { env: "JAEGER_PORT", default: 16686, service: "Jaeger" },
81
+ { env: "GRAFANA_PORT", default: 3100, service: "Grafana" },
82
+ ];
83
+ function getPort(env, defaultPort) {
84
+ const val = process.env[env];
85
+ return val ? parseInt(val, 10) : defaultPort;
86
+ }
87
+ function checkPort(port) {
88
+ return new Promise((resolve) => {
89
+ const socket = createConnection({ port, host: "127.0.0.1" });
90
+ socket.setTimeout(500);
91
+ socket.on("connect", () => {
92
+ socket.destroy();
93
+ resolve(true); // port is in use
94
+ });
95
+ socket.on("error", () => resolve(false)); // port is free
96
+ socket.on("timeout", () => {
97
+ socket.destroy();
98
+ resolve(false);
99
+ });
100
+ });
101
+ }
102
+ async function checkPorts() {
103
+ const conflicts = [];
104
+ for (const { env, default: defaultPort, service } of PORT_CONFIG) {
105
+ const port = getPort(env, defaultPort);
106
+ const inUse = await checkPort(port);
107
+ if (inUse) {
108
+ conflicts.push(` ❌ Port ${port} is already in use (${service})\n Fix: stop the other service, or set ${env}=${port + 1}`);
109
+ }
110
+ }
111
+ if (conflicts.length > 0) {
112
+ console.error("Port conflicts detected:\n");
113
+ console.error(conflicts.join("\n\n"));
114
+ console.error();
115
+ process.exit(1);
116
+ }
117
+ }
76
118
  function up() {
77
119
  requireDocker();
78
120
  const composeFile = requireInfra();
79
121
  console.log("🐸 Starting observability stack...");
80
122
  console.log(" (first run downloads ~500MB of Docker images — this may take a few minutes)\n");
81
- execFileSync("docker", ["compose", "-f", composeFile, "up", "-d"], {
82
- stdio: "inherit",
83
- });
84
- console.log();
85
- status();
86
- console.log(" Run 'npx toad-eye demo' to send test data and verify the stack works.");
123
+ // Check ports synchronously via async wrapper — CLI is short-lived
124
+ const checkAndStart = async () => {
125
+ await checkPorts();
126
+ execFileSync("docker", ["compose", "-f", composeFile, "up", "-d"], {
127
+ stdio: "inherit",
128
+ });
129
+ console.log();
130
+ status();
131
+ console.log(" Dashboards will be empty until data arrives.\n");
132
+ console.log(" Next steps:");
133
+ console.log(" npx toad-eye demo ← send mock traffic, see data in Grafana");
134
+ console.log(" or add toad-eye to your app (see README)");
135
+ };
136
+ void checkAndStart();
87
137
  }
88
138
  function down() {
89
139
  const composeFile = requireInfra("Nothing to stop.");
@@ -175,19 +225,37 @@ async function demo() {
175
225
  });
176
226
  console.log("🐸👁️ toad-eye demo — sending mock LLM traffic");
177
227
  console.log(" Press Ctrl+C to stop\n");
178
- console.log("✅ Stack verified! Add this to your app:\n");
179
- console.log(" import { initObservability } from 'toad-eye';\n initObservability({ serviceName: 'my-app', instrument: ['openai'] });\n");
180
228
  process.on("SIGINT", async () => {
181
229
  console.log("\n\u{1f44b} Shutting down...");
182
230
  await shutdown();
183
231
  process.exit(0);
184
232
  });
233
+ let opened = false;
234
+ function openGrafana() {
235
+ if (opened)
236
+ return;
237
+ opened = true;
238
+ const url = "http://localhost:3100";
239
+ const cmd = process.platform === "darwin"
240
+ ? "open"
241
+ : process.platform === "win32"
242
+ ? "start"
243
+ : "xdg-open";
244
+ try {
245
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
246
+ console.log(`\n 📊 Opened Grafana: ${url}\n`);
247
+ }
248
+ catch {
249
+ console.log(`\n 📊 Open Grafana: ${url} (admin/admin)\n`);
250
+ }
251
+ }
185
252
  const tick = async () => {
186
253
  const { provider, model } = pickRandom(DEMO_MODELS);
187
254
  const prompt = pickRandom(DEMO_PROMPTS);
188
255
  try {
189
256
  await traceLLMCall({ provider, model, prompt, temperature: 0.7 }, () => simulateLLMCall(provider, model, prompt));
190
257
  console.log(` \u2705 [${provider}/${model}] ${prompt}`);
258
+ openGrafana();
191
259
  }
192
260
  catch (err) {
193
261
  const msg = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Console span exporter — pretty-prints completed spans to stderr.
3
+ *
4
+ * Used in "light mode" (output: "console") — no Docker, no OTel Collector,
5
+ * just immediate feedback in the terminal.
6
+ *
7
+ * Format:
8
+ * 🐸 tools/call calculate [42ms] ✅
9
+ * 🐸 chat gpt-4o [1.2s] $0.003 ✅
10
+ * 🐸 tools/call broken [12ms] ❌ TypeError
11
+ */
12
+ import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base";
13
+ import { type ExportResult } from "@opentelemetry/core";
14
+ export declare class ToadEyeConsoleExporter implements SpanExporter {
15
+ export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
16
+ shutdown(): Promise<void>;
17
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Console span exporter — pretty-prints completed spans to stderr.
3
+ *
4
+ * Used in "light mode" (output: "console") — no Docker, no OTel Collector,
5
+ * just immediate feedback in the terminal.
6
+ *
7
+ * Format:
8
+ * 🐸 tools/call calculate [42ms] ✅
9
+ * 🐸 chat gpt-4o [1.2s] $0.003 ✅
10
+ * 🐸 tools/call broken [12ms] ❌ TypeError
11
+ */
12
+ import { SpanStatusCode } from "@opentelemetry/api";
13
+ import { ExportResultCode } from "@opentelemetry/core";
14
+ function formatDuration(ms) {
15
+ if (ms < 1)
16
+ return "<1ms";
17
+ if (ms < 1000)
18
+ return `${Math.round(ms)}ms`;
19
+ return `${(ms / 1000).toFixed(1)}s`;
20
+ }
21
+ function getAttr(span, key) {
22
+ const val = span.attributes[key];
23
+ if (typeof val === "string" || typeof val === "number")
24
+ return val;
25
+ return undefined;
26
+ }
27
+ function formatSpan(span) {
28
+ const durationMs = (span.endTime[0] - span.startTime[0]) * 1000 +
29
+ (span.endTime[1] - span.startTime[1]) / 1_000_000;
30
+ const isError = span.status.code === SpanStatusCode.ERROR;
31
+ const icon = isError ? "❌" : "✅";
32
+ const cost = getAttr(span, "gen_ai.toad_eye.cost");
33
+ const costStr = typeof cost === "number" && cost > 0 ? ` $${cost.toFixed(4)}` : "";
34
+ const errorType = isError ? getAttr(span, "error.type") : undefined;
35
+ const errorStr = errorType ? ` ${errorType}` : "";
36
+ return ` 🐸 ${span.name} [${formatDuration(durationMs)}]${costStr} ${icon}${errorStr}`;
37
+ }
38
+ // Filter out internal spans that add noise in console mode
39
+ const SKIP_PREFIXES = ["gen_ai.agent.step."];
40
+ function shouldShow(span) {
41
+ return !SKIP_PREFIXES.some((p) => span.name.startsWith(p));
42
+ }
43
+ export class ToadEyeConsoleExporter {
44
+ export(spans, resultCallback) {
45
+ for (const span of spans) {
46
+ if (shouldShow(span)) {
47
+ process.stderr.write(formatSpan(span) + "\n");
48
+ }
49
+ }
50
+ resultCallback({ code: ExportResultCode.SUCCESS });
51
+ }
52
+ shutdown() {
53
+ return Promise.resolve();
54
+ }
55
+ }
@@ -20,3 +20,4 @@ export declare function recordResponseEmpty(provider: string, model: string, att
20
20
  export declare function recordResponseLatencyPerToken(msPerToken: number, provider: string, model: string, attrs?: Record<string, string>): void;
21
21
  export declare function recordContextUtilization(utilization: number, provider: string, model: string): void;
22
22
  export declare function recordContextBlocked(model: string): void;
23
+ export declare function recordThinkingRatio(ratio: number, provider: string, model: string): void;
@@ -19,6 +19,7 @@ let responseEmpty;
19
19
  let responseLatencyPerToken;
20
20
  let contextUtilization;
21
21
  let contextBlocked;
22
+ let thinkingRatio;
22
23
  let initialized = false;
23
24
  export function initMetrics() {
24
25
  if (initialized)
@@ -86,6 +87,9 @@ export function initMetrics() {
86
87
  contextBlocked = meter.createCounter(GEN_AI_METRICS.CONTEXT_BLOCKED, {
87
88
  description: "Number of LLM calls blocked by context guard",
88
89
  });
90
+ thinkingRatio = meter.createHistogram(GEN_AI_METRICS.THINKING_RATIO, {
91
+ description: "Ratio of thinking/reasoning tokens to total output (0.0–1.0). High values indicate deep reasoning.",
92
+ });
89
93
  initialized = true;
90
94
  }
91
95
  /** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
@@ -219,3 +223,11 @@ export function recordContextBlocked(model) {
219
223
  [GEN_AI_ATTRS.REQUEST_MODEL]: model,
220
224
  });
221
225
  }
226
+ export function recordThinkingRatio(ratio, provider, model) {
227
+ if (!isReady())
228
+ return;
229
+ thinkingRatio.record(ratio, {
230
+ [GEN_AI_ATTRS.PROVIDER]: provider,
231
+ [GEN_AI_ATTRS.REQUEST_MODEL]: model,
232
+ });
233
+ }
@@ -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
@@ -17,6 +17,10 @@ export interface LLMCallOutput {
17
17
  readonly outputTokens: number;
18
18
  /** Cost in USD. If omitted, auto-calculated from model pricing table. */
19
19
  readonly cost?: number | undefined;
20
+ /** Thinking/reasoning content from thinking models (o1, Claude extended thinking, Gemini). */
21
+ readonly thinkingContent?: string | undefined;
22
+ /** Thinking/reasoning tokens used (separate from outputTokens). */
23
+ readonly thinkingTokens?: number | undefined;
20
24
  }
21
25
  export declare function processContent(text: string): string | undefined;
22
26
  export declare function traceLLMCall(input: LLMCallInput, fn: () => Promise<LLMCallOutput>): Promise<LLMCallOutput>;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { trace, SpanStatusCode } from "@opentelemetry/api";
3
3
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
4
- import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, } from "./metrics.js";
4
+ import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, recordThinkingRatio, } from "./metrics.js";
5
5
  import { getConfig, getBudgetTracker } from "./tracer.js";
6
6
  import { calculateCost, getModelPricing } from "./pricing.js";
7
7
  import { ToadBudgetExceededError } from "../budget/index.js";
@@ -124,6 +124,26 @@ function setSuccessAttributes(span, input, output, resolvedCost) {
124
124
  [GEN_AI_ATTRS.STATUS]: "success",
125
125
  [GEN_AI_ATTRS.FINISH_REASONS]: ["stop"],
126
126
  });
127
+ // Thinking model support (o1, Claude extended thinking, Gemini)
128
+ if (output.thinkingTokens !== undefined && output.thinkingTokens > 0) {
129
+ span.setAttribute("gen_ai.usage.reasoning_tokens", output.thinkingTokens);
130
+ const ratio = output.thinkingTokens / (output.outputTokens + output.thinkingTokens);
131
+ span.setAttribute("gen_ai.toad_eye.thinking.ratio", ratio);
132
+ recordThinkingRatio(ratio, input.provider, input.model);
133
+ }
134
+ if (output.thinkingContent !== undefined) {
135
+ const config = getConfig();
136
+ const recordThinking = config?.recordContent !== false;
137
+ if (recordThinking) {
138
+ const processed = processContent(output.thinkingContent);
139
+ if (processed !== undefined) {
140
+ span.addEvent("gen_ai.thinking", {
141
+ "gen_ai.toad_eye.thinking.content": processed,
142
+ });
143
+ }
144
+ }
145
+ span.setAttribute("gen_ai.toad_eye.thinking.content_length", output.thinkingContent.length);
146
+ }
127
147
  span.setStatus({ code: SpanStatusCode.OK });
128
148
  }
129
149
  function setErrorAttributes(span, message) {
@@ -14,6 +14,7 @@ import { resetMcpMetrics } from "../mcp/metrics.js";
14
14
  import { BudgetTracker } from "../budget/index.js";
15
15
  import { ToadEyeAISpanProcessor } from "../vercel.js";
16
16
  import { ToadEyeSpanEndProcessor } from "./span-end-processor.js";
17
+ import { ToadEyeConsoleExporter } from "./console-exporter.js";
17
18
  const DEFAULT_ENDPOINT = "http://localhost:4318";
18
19
  const DEFAULT_CLOUD_ENDPOINT = "https://cloud.toad-eye.dev";
19
20
  let sdk = null;
@@ -66,22 +67,29 @@ export function initObservability(config) {
66
67
  return;
67
68
  }
68
69
  validateConfig(config);
69
- const { endpoint, headers, isCloudMode } = resolveTransport(config);
70
+ const isConsoleMode = config.output === "console";
71
+ const { endpoint, headers, isCloudMode } = isConsoleMode
72
+ ? { endpoint: "", headers: {}, isCloudMode: false }
73
+ : resolveTransport(config);
70
74
  const resource = resourceFromAttributes({
71
75
  [ATTR_SERVICE_NAME]: config.serviceName,
72
76
  });
73
- const traceExporter = new OTLPTraceExporter({
74
- url: `${endpoint}/v1/traces`,
75
- headers,
76
- });
77
- const metricExporter = new OTLPMetricExporter({
78
- url: `${endpoint}/v1/metrics`,
79
- headers,
80
- });
81
- const metricReader = new PeriodicExportingMetricReader({
82
- exporter: metricExporter,
83
- exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
84
- });
77
+ const traceExporter = isConsoleMode
78
+ ? new ToadEyeConsoleExporter()
79
+ : new OTLPTraceExporter({
80
+ url: `${endpoint}/v1/traces`,
81
+ headers,
82
+ });
83
+ // In console mode, skip metric export — no Prometheus to send to
84
+ const metricReader = isConsoleMode
85
+ ? undefined
86
+ : new PeriodicExportingMetricReader({
87
+ exporter: new OTLPMetricExporter({
88
+ url: `${endpoint}/v1/metrics`,
89
+ headers,
90
+ }),
91
+ exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
92
+ });
85
93
  // When custom SpanProcessors are needed (e.g., Vercel AI SDK, onSpanEnd),
86
94
  // we must also include a BatchSpanProcessor for trace export —
87
95
  // NodeSDK won't create one automatically when spanProcessors is provided.
@@ -107,16 +115,19 @@ export function initObservability(config) {
107
115
  sdk = new NodeSDK({
108
116
  resource,
109
117
  traceExporter,
110
- metricReader,
118
+ ...(metricReader !== undefined && { metricReader }),
111
119
  ...(spanProcessors.length > 0 && { spanProcessors }),
112
120
  ...(sampler !== undefined && { sampler }),
113
121
  });
114
122
  sdk.start();
115
123
  currentConfig = config;
116
124
  initMetrics();
125
+ if (isConsoleMode) {
126
+ console.log(`toad-eye: console mode — spans will be printed to stderr. No Docker needed.`);
127
+ }
117
128
  // Non-blocking connectivity check — warns the user if the OTel Collector is unreachable.
118
- // Fire-and-forget: does not block initObservability(). Cloud mode skips this check.
119
- if (!isCloudMode) {
129
+ // Fire-and-forget: does not block initObservability(). Cloud/console mode skips this check.
130
+ if (!isCloudMode && !isConsoleMode) {
120
131
  void fetch(`${endpoint}/v1/traces`, {
121
132
  method: "POST",
122
133
  body: "[]",
@@ -4,7 +4,6 @@ import { traceLLMCall, processContent } from "../core/spans.js";
4
4
  import { calculateCost, getModelPricing } from "../core/pricing.js";
5
5
  import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, recordBudgetExceeded, recordBudgetDowngraded, } from "../core/metrics.js";
6
6
  import { getConfig, getBudgetTracker } from "../core/tracer.js";
7
- import { ToadBudgetExceededError } from "../budget/index.js";
8
7
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
9
8
  const require = createRequire(import.meta.url);
10
9
  function isModuleInstalled(moduleName) {
@@ -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;
@@ -8,6 +8,7 @@
8
8
  import { createRequire } from "node:module";
9
9
  import { trace, context, propagation, SpanKind } from "@opentelemetry/api";
10
10
  import { MCP_METHODS, endSpanSuccess, endSpanError } from "./spans.js";
11
+ import { recordMcpToolHallucination } from "./metrics.js";
11
12
  const require = createRequire(import.meta.url);
12
13
  const TRACER_NAME = "toad-eye-mcp-client";
13
14
  const PATCHED_FLAG = "__toad_eye_mcp_client_patched";
@@ -34,18 +35,35 @@ function injectTraceContext(params) {
34
35
  }
35
36
  return params;
36
37
  }
37
- export function enableMcpClientInstrumentation() {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ function resolveClientClass() {
40
+ // Try CJS first (works when consumer uses require)
38
41
  try {
39
- require.resolve("@modelcontextprotocol/sdk/client/index.js");
42
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
43
+ const sdk = require("@modelcontextprotocol/sdk/client/index.js");
44
+ return sdk.Client ?? sdk.default?.Client;
40
45
  }
41
46
  catch {
42
- return false;
47
+ // CJS failed — will try ESM in async path
48
+ return null;
43
49
  }
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;
50
+ }
51
+ /**
52
+ * Enable MCP client instrumentation by auto-resolving the Client class.
53
+ * Works in CJS projects. For ESM projects, prefer passing the Client class directly.
54
+ */
55
+ export function enableMcpClientInstrumentation(
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ ClientClass) {
58
+ const Client = ClientClass ?? resolveClientClass();
59
+ return Client ? patchClient(Client) : false;
60
+ }
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ function patchClient(Client) {
47
63
  if (!Client?.prototype)
48
64
  return false;
65
+ if (typeof Client.prototype.callTool !== "function")
66
+ return false;
49
67
  if (Client.prototype[PATCHED_FLAG])
50
68
  return true;
51
69
  const proto = Client.prototype;
@@ -75,6 +93,11 @@ export function enableMcpClientInstrumentation() {
75
93
  }
76
94
  catch (error) {
77
95
  endSpanError(span, error);
96
+ // JSON-RPC -32601 = Method not found = agent hallucinated a tool name
97
+ const code = error?.code;
98
+ if (code === -32601) {
99
+ recordMcpToolHallucination(toolName);
100
+ }
78
101
  throw error;
79
102
  }
80
103
  };
@@ -3,3 +3,4 @@ export type { TraceSamplingOptions, ToadEyeMiddlewareDispose, } from "./middlewa
3
3
  export type { ToadMcpOptions } from "./types.js";
4
4
  export { MCP_METHODS } from "./spans.js";
5
5
  export { enableMcpClientInstrumentation, disableMcpClientInstrumentation, } from "./client.js";
6
+ export { recordMcpToolHallucination } from "./metrics.js";
package/dist/mcp/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { toadEyeMiddleware, traceSampling } from "./middleware.js";
2
2
  export { MCP_METHODS } from "./spans.js";
3
3
  export { enableMcpClientInstrumentation, disableMcpClientInstrumentation, } from "./client.js";
4
+ export { recordMcpToolHallucination } from "./metrics.js";
@@ -10,6 +10,7 @@ export declare function recordMcpToolCall(toolName: string, durationMs: number,
10
10
  export declare function recordMcpToolError(toolName: string, errorType: string): void;
11
11
  export declare function recordMcpResourceRead(uri: string): void;
12
12
  export declare function recordMcpSessionStart(): void;
13
+ export declare function recordMcpToolHallucination(toolName: string): void;
13
14
  export declare function recordMcpSessionEnd(): void;
14
15
  /** Reset MCP metrics state — must be called from shutdown() alongside resetMetrics(). */
15
16
  export declare function resetMcpMetrics(): void;
@@ -13,6 +13,7 @@ const METER_NAME = "toad-eye-mcp";
13
13
  let toolDuration;
14
14
  let toolCalls;
15
15
  let toolErrors;
16
+ let toolHallucinations;
16
17
  let resourceReads;
17
18
  let sessionActive;
18
19
  let toolCallers;
@@ -32,6 +33,9 @@ function ensureInit() {
32
33
  toolErrors = meter.createCounter(GEN_AI_METRICS.MCP_TOOL_ERRORS, {
33
34
  description: "MCP tool errors by tool name and error type",
34
35
  });
36
+ toolHallucinations = meter.createCounter(GEN_AI_METRICS.MCP_TOOL_HALLUCINATIONS, {
37
+ description: "Agent tried to call a non-existent tool (JSON-RPC -32601)",
38
+ });
35
39
  resourceReads = meter.createCounter(GEN_AI_METRICS.MCP_RESOURCE_READS, {
36
40
  description: "MCP resource read count by URI",
37
41
  });
@@ -79,6 +83,13 @@ export function recordMcpSessionStart() {
79
83
  ensureInit();
80
84
  sessionActive.add(1);
81
85
  }
86
+ export function recordMcpToolHallucination(toolName) {
87
+ ensureInit();
88
+ toolHallucinations.add(1, {
89
+ "gen_ai.tool.name": toolName,
90
+ "mcp.method.name": MCP_METHODS.TOOLS_CALL,
91
+ });
92
+ }
82
93
  export function recordMcpSessionEnd() {
83
94
  ensureInit();
84
95
  sessionActive.add(-1);