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.
- package/README.md +1 -1
- package/dist/alerts/channels.js +4 -1
- package/dist/alerts/conditions.js +3 -2
- package/dist/alerts/grafana.js +1 -0
- package/dist/alerts/manager.js +2 -0
- package/dist/cli.js +76 -8
- package/dist/core/console-exporter.d.ts +17 -0
- package/dist/core/console-exporter.js +55 -0
- package/dist/core/metrics.d.ts +1 -0
- package/dist/core/metrics.js +12 -0
- package/dist/core/span-end-processor.js +1 -1
- package/dist/core/spans.d.ts +4 -0
- package/dist/core/spans.js +21 -1
- package/dist/core/tracer.js +27 -16
- package/dist/instrumentations/create.js +0 -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 +29 -6
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/metrics.d.ts +1 -0
- package/dist/mcp/metrics.js +11 -0
- package/dist/mcp/middleware.js +14 -4
- package/dist/types/config.d.ts +2 -0
- package/dist/types/metrics.d.ts +2 -0
- package/dist/types/metrics.js +3 -0
- package/package.json +1 -1
- package/templates/docker-compose.yml +4 -4
- package/templates/grafana/dashboards/agent-workflow.json +32 -47
- package/templates/grafana/dashboards/cost-breakdown.json +14 -7
- package/templates/grafana/dashboards/error-drilldown.json +12 -6
- package/templates/grafana/dashboards/finops-attribution.json +39 -8
- package/templates/grafana/dashboards/latency-analysis.json +8 -4
- package/templates/grafana/dashboards/llm-overview.json +22 -11
- package/templates/grafana/dashboards/mcp-analytics.json +177 -43
- package/templates/grafana/dashboards/mcp-e2e.json +198 -51
- package/templates/grafana/dashboards/mcp-server.json +46 -9
- package/templates/grafana/dashboards/model-comparison.json +10 -5
- 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 (
|
|
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,
|
|
@@ -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)
|
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) {
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
package/dist/core/metrics.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/metrics.js
CHANGED
|
@@ -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.
|
|
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
|
package/dist/core/spans.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/spans.js
CHANGED
|
@@ -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) {
|
package/dist/core/tracer.js
CHANGED
|
@@ -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
|
|
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 =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
// CJS failed — will try ESM in async path
|
|
48
|
+
return null;
|
|
43
49
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
};
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -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
package/dist/mcp/metrics.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp/metrics.js
CHANGED
|
@@ -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);
|