toad-eye 2.10.0 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/alerts/conditions.js +1 -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/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/mcp/client.js +6 -0
- 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/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 +25 -8
- 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 +38 -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 +176 -43
- package/templates/grafana/dashboards/mcp-e2e.json +195 -49
- 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 +10 -5
|
@@ -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",
|
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
|
+
}
|
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) {
|
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";
|
|
@@ -92,6 +93,11 @@ function patchClient(Client) {
|
|
|
92
93
|
}
|
|
93
94
|
catch (error) {
|
|
94
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
|
+
}
|
|
95
101
|
throw error;
|
|
96
102
|
}
|
|
97
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);
|
package/dist/types/config.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface SamplingConfig {
|
|
|
22
22
|
export interface ToadEyeConfig {
|
|
23
23
|
readonly serviceName: string;
|
|
24
24
|
readonly endpoint?: string | undefined;
|
|
25
|
+
/** Output mode: "otlp" (default) sends to OTel Collector, "console" prints spans to stderr (no Docker needed). */
|
|
26
|
+
readonly output?: "otlp" | "console" | undefined;
|
|
25
27
|
/** API key for toad-eye cloud. When set, transport switches to HTTPS cloud endpoint automatically. */
|
|
26
28
|
readonly apiKey?: string | undefined;
|
|
27
29
|
/** Custom cloud endpoint override. Defaults to https://cloud.toad-eye.dev. */
|
package/dist/types/metrics.d.ts
CHANGED
|
@@ -26,12 +26,14 @@ export declare const GEN_AI_METRICS: {
|
|
|
26
26
|
readonly RESPONSE_LATENCY_PER_TOKEN: "gen_ai.toad_eye.response.latency_per_token";
|
|
27
27
|
readonly CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization";
|
|
28
28
|
readonly CONTEXT_BLOCKED: "gen_ai.toad_eye.context.blocked";
|
|
29
|
+
readonly THINKING_RATIO: "gen_ai.toad_eye.thinking.ratio";
|
|
29
30
|
readonly MCP_TOOL_DURATION: "gen_ai.mcp.tool.duration";
|
|
30
31
|
readonly MCP_TOOL_CALLS: "gen_ai.mcp.tool.calls";
|
|
31
32
|
readonly MCP_TOOL_ERRORS: "gen_ai.mcp.tool.errors";
|
|
32
33
|
readonly MCP_RESOURCE_READS: "gen_ai.mcp.resource.reads";
|
|
33
34
|
readonly MCP_SESSION_ACTIVE: "gen_ai.mcp.session.active";
|
|
34
35
|
readonly MCP_TOOL_CALLERS: "gen_ai.mcp.tool.callers";
|
|
36
|
+
readonly MCP_TOOL_HALLUCINATIONS: "gen_ai.mcp.tool.hallucinations";
|
|
35
37
|
};
|
|
36
38
|
/** @deprecated Use GEN_AI_METRICS instead. Kept for backward compatibility. */
|
|
37
39
|
export declare const LLM_METRICS: {
|
package/dist/types/metrics.js
CHANGED
|
@@ -33,6 +33,8 @@ export const GEN_AI_METRICS = {
|
|
|
33
33
|
// Context utilization
|
|
34
34
|
CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization",
|
|
35
35
|
CONTEXT_BLOCKED: "gen_ai.toad_eye.context.blocked",
|
|
36
|
+
// Thinking/reasoning models
|
|
37
|
+
THINKING_RATIO: "gen_ai.toad_eye.thinking.ratio",
|
|
36
38
|
// MCP metrics
|
|
37
39
|
MCP_TOOL_DURATION: "gen_ai.mcp.tool.duration",
|
|
38
40
|
MCP_TOOL_CALLS: "gen_ai.mcp.tool.calls",
|
|
@@ -40,6 +42,7 @@ export const GEN_AI_METRICS = {
|
|
|
40
42
|
MCP_RESOURCE_READS: "gen_ai.mcp.resource.reads",
|
|
41
43
|
MCP_SESSION_ACTIVE: "gen_ai.mcp.session.active",
|
|
42
44
|
MCP_TOOL_CALLERS: "gen_ai.mcp.tool.callers",
|
|
45
|
+
MCP_TOOL_HALLUCINATIONS: "gen_ai.mcp.tool.hallucinations",
|
|
43
46
|
};
|
|
44
47
|
/** @deprecated Use GEN_AI_METRICS instead. Kept for backward compatibility. */
|
|
45
48
|
export const LLM_METRICS = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toad-eye",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
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",
|
|
@@ -4,7 +4,7 @@ services:
|
|
|
4
4
|
volumes:
|
|
5
5
|
- ./otel-collector.yml:/etc/otelcol-contrib/config.yaml
|
|
6
6
|
ports:
|
|
7
|
-
- "4318:4318"
|
|
7
|
+
- "${COLLECTOR_PORT:-4318}:4318"
|
|
8
8
|
depends_on:
|
|
9
9
|
- jaeger
|
|
10
10
|
- prometheus
|
|
@@ -14,14 +14,14 @@ services:
|
|
|
14
14
|
volumes:
|
|
15
15
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
|
16
16
|
ports:
|
|
17
|
-
- "9090:9090"
|
|
17
|
+
- "${PROMETHEUS_PORT:-9090}:9090"
|
|
18
18
|
|
|
19
19
|
jaeger:
|
|
20
20
|
image: jaegertracing/all-in-one:1.58
|
|
21
21
|
environment:
|
|
22
22
|
- COLLECTOR_OTLP_ENABLED=true
|
|
23
23
|
ports:
|
|
24
|
-
- "16686:16686"
|
|
24
|
+
- "${JAEGER_PORT:-16686}:16686"
|
|
25
25
|
- "4317:4317"
|
|
26
26
|
|
|
27
27
|
grafana:
|
|
@@ -33,7 +33,7 @@ services:
|
|
|
33
33
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|
34
34
|
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards/llm-overview.json
|
|
35
35
|
ports:
|
|
36
|
-
- "3100:3000"
|
|
36
|
+
- "${GRAFANA_PORT:-3100}:3000"
|
|
37
37
|
depends_on:
|
|
38
38
|
- prometheus
|
|
39
39
|
- jaeger
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"value": 20
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
|
-
}
|
|
49
|
+
},
|
|
50
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
50
51
|
},
|
|
51
52
|
"overrides": []
|
|
52
53
|
}
|
|
@@ -72,7 +73,8 @@
|
|
|
72
73
|
],
|
|
73
74
|
"fieldConfig": {
|
|
74
75
|
"defaults": {
|
|
75
|
-
"unit": "short"
|
|
76
|
+
"unit": "short",
|
|
77
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
76
78
|
},
|
|
77
79
|
"overrides": []
|
|
78
80
|
}
|
|
@@ -98,7 +100,8 @@
|
|
|
98
100
|
],
|
|
99
101
|
"fieldConfig": {
|
|
100
102
|
"defaults": {
|
|
101
|
-
"unit": "short"
|
|
103
|
+
"unit": "short",
|
|
104
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
102
105
|
},
|
|
103
106
|
"overrides": []
|
|
104
107
|
}
|
|
@@ -124,7 +127,8 @@
|
|
|
124
127
|
],
|
|
125
128
|
"fieldConfig": {
|
|
126
129
|
"defaults": {
|
|
127
|
-
"unit": "short"
|
|
130
|
+
"unit": "short",
|
|
131
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
128
132
|
},
|
|
129
133
|
"overrides": []
|
|
130
134
|
}
|
|
@@ -157,6 +161,11 @@
|
|
|
157
161
|
"values": ["value", "percent"]
|
|
158
162
|
},
|
|
159
163
|
"pieType": "donut"
|
|
164
|
+
},
|
|
165
|
+
"fieldConfig": {
|
|
166
|
+
"defaults": {
|
|
167
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
168
|
+
}
|
|
160
169
|
}
|
|
161
170
|
},
|
|
162
171
|
{
|
|
@@ -179,7 +188,12 @@
|
|
|
179
188
|
"legendFormat": "{{gen_ai_tool_name}}",
|
|
180
189
|
"refId": "A"
|
|
181
190
|
}
|
|
182
|
-
]
|
|
191
|
+
],
|
|
192
|
+
"fieldConfig": {
|
|
193
|
+
"defaults": {
|
|
194
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
195
|
+
}
|
|
196
|
+
}
|
|
183
197
|
},
|
|
184
198
|
{
|
|
185
199
|
"title": "Steps per Query Over Time",
|
|
@@ -211,7 +225,8 @@
|
|
|
211
225
|
"unit": "short",
|
|
212
226
|
"custom": {
|
|
213
227
|
"fillOpacity": 10
|
|
214
|
-
}
|
|
228
|
+
},
|
|
229
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
215
230
|
},
|
|
216
231
|
"overrides": []
|
|
217
232
|
}
|
|
@@ -244,7 +259,8 @@
|
|
|
244
259
|
"stacking": {
|
|
245
260
|
"mode": "normal"
|
|
246
261
|
}
|
|
247
|
-
}
|
|
262
|
+
},
|
|
263
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
248
264
|
},
|
|
249
265
|
"overrides": []
|
|
250
266
|
}
|
|
@@ -274,7 +290,8 @@
|
|
|
274
290
|
"defaults": {
|
|
275
291
|
"custom": {
|
|
276
292
|
"fillOpacity": 80
|
|
277
|
-
}
|
|
293
|
+
},
|
|
294
|
+
"noValue": "No data yet — run: npx toad-eye demo"
|
|
278
295
|
},
|
|
279
296
|
"overrides": []
|
|
280
297
|
}
|