toad-eye 2.4.5 → 2.6.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 +34 -1
- package/dist/agent.js +6 -2
- package/dist/cli.js +8 -1
- package/dist/context/error.d.ts +15 -0
- package/dist/context/error.js +17 -0
- package/dist/context/guard.d.ts +8 -0
- package/dist/context/guard.js +41 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +4 -0
- package/dist/core/metrics.d.ts +4 -1
- package/dist/core/metrics.js +74 -2
- package/dist/core/pricing.d.ts +2 -0
- package/dist/core/pricing.js +61 -16
- package/dist/core/spans.d.ts +2 -0
- package/dist/core/spans.js +48 -9
- package/dist/core/tracer.js +7 -1
- package/dist/instrumentations/anthropic.js +8 -2
- package/dist/instrumentations/create.js +67 -8
- package/dist/instrumentations/gemini.js +7 -0
- package/dist/instrumentations/openai.js +5 -0
- package/dist/instrumentations/types.d.ts +8 -0
- package/dist/mcp/context.d.ts +12 -0
- package/dist/mcp/context.js +22 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/metrics.d.ts +11 -0
- package/dist/mcp/metrics.js +58 -0
- package/dist/mcp/middleware.d.ts +48 -0
- package/dist/mcp/middleware.js +253 -0
- package/dist/mcp/spans.d.ts +28 -0
- package/dist/mcp/spans.js +67 -0
- package/dist/mcp/types.d.ts +34 -0
- package/dist/mcp/types.js +1 -0
- package/dist/types/attributes.d.ts +1 -0
- package/dist/types/attributes.js +1 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/metrics.d.ts +8 -0
- package/dist/types/metrics.js +10 -0
- package/dist/types/spans.d.ts +4 -0
- package/package.json +11 -2
- package/templates/docker-compose.yml +4 -4
- package/templates/grafana/dashboards/agent-workflow.json +154 -31
- package/templates/grafana/dashboards/cost-breakdown.json +168 -40
- package/templates/grafana/dashboards/error-drilldown.json +155 -37
- package/templates/grafana/dashboards/finops-attribution.json +192 -43
- package/templates/grafana/dashboards/latency-analysis.json +103 -24
- package/templates/grafana/dashboards/llm-overview.json +268 -66
- package/templates/grafana/dashboards/mcp-server.json +519 -0
- package/templates/grafana/dashboards/model-comparison.json +119 -26
- package/templates/grafana/dashboards/provider-health.json +137 -31
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { trace, context, diag, SpanStatusCode, } from "@opentelemetry/api";
|
|
3
3
|
import { traceLLMCall, processContent } from "../core/spans.js";
|
|
4
|
-
import { calculateCost } from "../core/pricing.js";
|
|
5
|
-
import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, recordResponseEmpty, recordResponseLatencyPerToken, recordBudgetExceeded, recordBudgetDowngraded, } from "../core/metrics.js";
|
|
4
|
+
import { calculateCost, getModelPricing } from "../core/pricing.js";
|
|
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
7
|
import { ToadBudgetExceededError } from "../budget/index.js";
|
|
8
8
|
import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
|
|
@@ -31,6 +31,7 @@ function loadModule(moduleName) {
|
|
|
31
31
|
async function* wrapAsyncIterable(stream, accumulate, onFirstChunk, onComplete, onError) {
|
|
32
32
|
const acc = {
|
|
33
33
|
completion: "",
|
|
34
|
+
thinkingContent: "",
|
|
34
35
|
inputTokens: 0,
|
|
35
36
|
outputTokens: 0,
|
|
36
37
|
toolCalls: [],
|
|
@@ -93,14 +94,16 @@ original, patch, providerName) {
|
|
|
93
94
|
recordBudgetDowngraded(override.budget);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
|
-
const
|
|
97
|
+
const op = patch.operationName ?? "chat";
|
|
98
|
+
const span = tracer.startSpan(`${op} ${effectiveModel}`);
|
|
97
99
|
const ctx = trace.setSpan(context.active(), span);
|
|
98
100
|
const sessionId = config?.sessionExtractor?.() ?? config?.sessionId;
|
|
101
|
+
let ttftMs = 0;
|
|
99
102
|
span.setAttributes({
|
|
100
103
|
[GEN_AI_ATTRS.PROVIDER]: effectiveProvider,
|
|
101
104
|
[GEN_AI_ATTRS.REQUEST_MODEL]: effectiveModel,
|
|
102
105
|
[GEN_AI_ATTRS.TEMPERATURE]: req.temperature ?? 1.0,
|
|
103
|
-
[GEN_AI_ATTRS.OPERATION]:
|
|
106
|
+
[GEN_AI_ATTRS.OPERATION]: op,
|
|
104
107
|
...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
|
|
105
108
|
});
|
|
106
109
|
let response;
|
|
@@ -136,11 +139,22 @@ original, patch, providerName) {
|
|
|
136
139
|
? resp["stream"]
|
|
137
140
|
: response;
|
|
138
141
|
const wrapped = context.bind(ctx, wrapAsyncIterable(streamIterable, (acc, chunk) => patch.accumulateChunk(acc, chunk), () => {
|
|
139
|
-
|
|
142
|
+
const ttft = performance.now() - start;
|
|
143
|
+
ttftMs = ttft;
|
|
144
|
+
recordTimeToFirstToken(ttft, effectiveProvider, effectiveModel);
|
|
145
|
+
// Span event for per-trace TTFT debugging in Jaeger
|
|
146
|
+
span.addEvent("gen_ai.content.first_token", {
|
|
147
|
+
"gen_ai.response.time_to_first_token_ms": ttft,
|
|
148
|
+
});
|
|
149
|
+
span.setAttribute("gen_ai.response.time_to_first_token_ms", ttft);
|
|
140
150
|
}, (acc) => {
|
|
141
151
|
const duration = performance.now() - start;
|
|
142
152
|
const cost = calculateCost(effectiveModel, acc.inputTokens, acc.outputTokens);
|
|
143
153
|
const processedCompletion = processContent(acc.completion);
|
|
154
|
+
// Anthropic extended thinking — track separately from completion
|
|
155
|
+
if (acc.thinkingContent.length > 0) {
|
|
156
|
+
span.setAttribute("gen_ai.toad_eye.thinking.content_length", acc.thinkingContent.length);
|
|
157
|
+
}
|
|
144
158
|
// Record tool calls from streaming chunks
|
|
145
159
|
if (acc.toolCalls.length > 0) {
|
|
146
160
|
span.setAttribute(GEN_AI_ATTRS.TOOL_NAME, acc.toolCalls.map((t) => t.name).join(", "));
|
|
@@ -154,10 +168,18 @@ original, patch, providerName) {
|
|
|
154
168
|
[GEN_AI_ATTRS.INPUT_TOKENS]: acc.inputTokens,
|
|
155
169
|
[GEN_AI_ATTRS.OUTPUT_TOKENS]: acc.outputTokens,
|
|
156
170
|
[GEN_AI_ATTRS.COST]: cost,
|
|
157
|
-
[GEN_AI_ATTRS.STATUS]: "success",
|
|
158
|
-
[GEN_AI_ATTRS.FINISH_REASONS]: ["stop"],
|
|
171
|
+
[GEN_AI_ATTRS.STATUS]: acc.finishReason === "SAFETY" ? "error" : "success",
|
|
172
|
+
[GEN_AI_ATTRS.FINISH_REASONS]: [acc.finishReason ?? "stop"],
|
|
159
173
|
});
|
|
160
|
-
|
|
174
|
+
if (acc.finishReason === "SAFETY") {
|
|
175
|
+
span.setStatus({
|
|
176
|
+
code: SpanStatusCode.ERROR,
|
|
177
|
+
message: "Content blocked by safety filter",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
182
|
+
}
|
|
161
183
|
span.end();
|
|
162
184
|
recordRequest(effectiveProvider, effectiveModel);
|
|
163
185
|
recordRequestDuration(duration, effectiveProvider, effectiveModel);
|
|
@@ -170,6 +192,37 @@ original, patch, providerName) {
|
|
|
170
192
|
if (acc.outputTokens > 0) {
|
|
171
193
|
recordResponseLatencyPerToken(duration / acc.outputTokens, effectiveProvider, effectiveModel);
|
|
172
194
|
}
|
|
195
|
+
// Context window utilization — ratio of input tokens to model's max context
|
|
196
|
+
const pricing = getModelPricing(effectiveModel);
|
|
197
|
+
if (pricing?.maxContextTokens && acc.inputTokens > 0) {
|
|
198
|
+
const utilization = acc.inputTokens / pricing.maxContextTokens;
|
|
199
|
+
span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
|
|
200
|
+
recordContextUtilization(utilization, effectiveProvider, effectiveModel);
|
|
201
|
+
// Context guard — post-stream warning
|
|
202
|
+
const guard = config?.contextGuard;
|
|
203
|
+
if (guard) {
|
|
204
|
+
if (guard.alertAt !== undefined && utilization >= guard.alertAt) {
|
|
205
|
+
recordContextBlocked(effectiveModel);
|
|
206
|
+
span.addEvent("gen_ai.context.limit_exceeded", {
|
|
207
|
+
"gen_ai.toad_eye.context_utilization": utilization,
|
|
208
|
+
"gen_ai.toad_eye.context.threshold": guard.alertAt,
|
|
209
|
+
});
|
|
210
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} — exceeds alertAt threshold ${(guard.alertAt * 100).toFixed(0)}%. Compress context before next call.`);
|
|
211
|
+
}
|
|
212
|
+
else if (guard.warnAt !== undefined &&
|
|
213
|
+
utilization >= guard.warnAt) {
|
|
214
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} (${acc.inputTokens}/${pricing.maxContextTokens} tokens)`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Prefill/decode latency split — TTFT = prefill, rest = decode
|
|
219
|
+
if (ttftMs > 0) {
|
|
220
|
+
const decodeMs = duration - ttftMs;
|
|
221
|
+
span.setAttribute("gen_ai.toad_eye.latency.decode_ms", decodeMs);
|
|
222
|
+
if (acc.outputTokens > 0 && decodeMs > 0) {
|
|
223
|
+
span.setAttribute("gen_ai.toad_eye.throughput.tokens_per_second", acc.outputTokens / (decodeMs / 1000));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
173
226
|
// Budget recording — releases the reservation made in checkBefore
|
|
174
227
|
if (budget) {
|
|
175
228
|
const exceeded = budget.recordCost(cost, effectiveModel, userId, estimatedCost);
|
|
@@ -225,6 +278,7 @@ original, patch, providerName) {
|
|
|
225
278
|
model: req.model,
|
|
226
279
|
prompt: req.prompt,
|
|
227
280
|
temperature: req.temperature,
|
|
281
|
+
operationName: patch.operationName,
|
|
228
282
|
}, async () => {
|
|
229
283
|
const response = await original.call(this, body, ...rest);
|
|
230
284
|
const res = patch.extractResponse(response, req.model);
|
|
@@ -257,6 +311,11 @@ export function createInstrumentation(config) {
|
|
|
257
311
|
return activePatches.length > 0;
|
|
258
312
|
}
|
|
259
313
|
catch (err) {
|
|
314
|
+
// Clean up any patches applied before the failure
|
|
315
|
+
for (const { proto, method, original } of activePatches) {
|
|
316
|
+
proto[method] = original;
|
|
317
|
+
}
|
|
318
|
+
activePatches.length = 0;
|
|
260
319
|
diag.warn(`toad-eye: failed to patch ${config.name}: ${err}`);
|
|
261
320
|
return false;
|
|
262
321
|
}
|
|
@@ -17,6 +17,7 @@ function extractPrompt(request) {
|
|
|
17
17
|
const generateContent = {
|
|
18
18
|
getPrototype: (sdk) => sdk?.GenerativeModel?.prototype,
|
|
19
19
|
method: "generateContent",
|
|
20
|
+
operationName: "chat",
|
|
20
21
|
extractRequest(body, thisArg) {
|
|
21
22
|
return {
|
|
22
23
|
prompt: extractPrompt(body),
|
|
@@ -42,6 +43,7 @@ const generateContent = {
|
|
|
42
43
|
const generateContentStream = {
|
|
43
44
|
getPrototype: (sdk) => sdk?.GenerativeModel?.prototype,
|
|
44
45
|
method: "generateContentStream",
|
|
46
|
+
operationName: "chat",
|
|
45
47
|
extractRequest(body, thisArg) {
|
|
46
48
|
return {
|
|
47
49
|
prompt: extractPrompt(body),
|
|
@@ -78,6 +80,11 @@ const generateContentStream = {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
}
|
|
83
|
+
// Track finish reason (especially SAFETY blocks)
|
|
84
|
+
const finishReason = c?.candidates?.[0]?.finishReason;
|
|
85
|
+
if (finishReason) {
|
|
86
|
+
acc.finishReason = finishReason;
|
|
87
|
+
}
|
|
81
88
|
if (c?.usageMetadata) {
|
|
82
89
|
acc.inputTokens = c.usageMetadata.promptTokenCount ?? acc.inputTokens;
|
|
83
90
|
acc.outputTokens =
|
|
@@ -59,6 +59,10 @@ const chatCompletions = {
|
|
|
59
59
|
existing.arguments += tc.function.arguments;
|
|
60
60
|
}
|
|
61
61
|
else {
|
|
62
|
+
// Fill gaps to avoid sparse array with non-contiguous indices
|
|
63
|
+
while (acc.toolCalls.length < tc.index) {
|
|
64
|
+
acc.toolCalls.push({ name: "", arguments: "" });
|
|
65
|
+
}
|
|
62
66
|
acc.toolCalls[tc.index] = {
|
|
63
67
|
name: tc.function?.name ?? "",
|
|
64
68
|
arguments: tc.function?.arguments ?? "",
|
|
@@ -76,6 +80,7 @@ const chatCompletions = {
|
|
|
76
80
|
const embeddings = {
|
|
77
81
|
getPrototype: (sdk) => sdk?.Embeddings?.prototype,
|
|
78
82
|
method: "create",
|
|
83
|
+
operationName: "embeddings",
|
|
79
84
|
extractRequest: (body) => {
|
|
80
85
|
const b = body;
|
|
81
86
|
const input = b?.input;
|
|
@@ -15,16 +15,24 @@ export interface AccumulatedToolCall {
|
|
|
15
15
|
/** Accumulated stream data — only primitives, no raw SDK objects. */
|
|
16
16
|
export interface StreamAccumulator {
|
|
17
17
|
completion: string;
|
|
18
|
+
/** Anthropic extended thinking content (tracked separately from completion). */
|
|
19
|
+
thinkingContent: string;
|
|
18
20
|
inputTokens: number;
|
|
19
21
|
outputTokens: number;
|
|
20
22
|
toolCalls: AccumulatedToolCall[];
|
|
23
|
+
/** Finish reason from the final chunk (e.g., "stop", "SAFETY"). */
|
|
24
|
+
finishReason?: string | undefined;
|
|
21
25
|
}
|
|
26
|
+
/** OTel GenAI operation types. Default is "chat". */
|
|
27
|
+
export type OperationName = "chat" | "text_completion" | "embeddings" | "generate_content";
|
|
22
28
|
/** Describes a single method to monkey-patch on an SDK prototype. */
|
|
23
29
|
export interface PatchTarget {
|
|
24
30
|
/** How to find the prototype from the loaded SDK module */
|
|
25
31
|
getPrototype: (sdk: any) => any | undefined;
|
|
26
32
|
/** Method name on the prototype to patch */
|
|
27
33
|
method: string;
|
|
34
|
+
/** OTel operation name for span naming. Default: "chat". */
|
|
35
|
+
operationName?: OperationName | undefined;
|
|
28
36
|
/** Extract LLMCallInput fields from the request arguments.
|
|
29
37
|
* thisArg is the SDK object instance — use it when the model name is not in the request body
|
|
30
38
|
* (e.g., Gemini's GenerativeModel stores it as this.model). */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C trace context extraction from MCP _meta field.
|
|
3
|
+
*
|
|
4
|
+
* In stdio transport, there are no HTTP headers. Context is passed
|
|
5
|
+
* via the _meta field in JSON-RPC params:
|
|
6
|
+
* { "params": { "_meta": { "traceparent": "00-..." } } }
|
|
7
|
+
*
|
|
8
|
+
* For SSE/HTTP transports, standard header propagation works too,
|
|
9
|
+
* but _meta is the universal path that works everywhere.
|
|
10
|
+
*/
|
|
11
|
+
import { type Context } from "@opentelemetry/api";
|
|
12
|
+
export declare function extractContextFromMeta(meta?: Record<string, unknown>): Context;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C trace context extraction from MCP _meta field.
|
|
3
|
+
*
|
|
4
|
+
* In stdio transport, there are no HTTP headers. Context is passed
|
|
5
|
+
* via the _meta field in JSON-RPC params:
|
|
6
|
+
* { "params": { "_meta": { "traceparent": "00-..." } } }
|
|
7
|
+
*
|
|
8
|
+
* For SSE/HTTP transports, standard header propagation works too,
|
|
9
|
+
* but _meta is the universal path that works everywhere.
|
|
10
|
+
*/
|
|
11
|
+
import { context, propagation } from "@opentelemetry/api";
|
|
12
|
+
export function extractContextFromMeta(meta) {
|
|
13
|
+
if (!meta?.["traceparent"])
|
|
14
|
+
return context.active();
|
|
15
|
+
const carrier = {
|
|
16
|
+
traceparent: String(meta["traceparent"]),
|
|
17
|
+
};
|
|
18
|
+
if (meta["tracestate"]) {
|
|
19
|
+
carrier["tracestate"] = String(meta["tracestate"]);
|
|
20
|
+
}
|
|
21
|
+
return propagation.extract(context.active(), carrier);
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { toadEyeMiddleware, traceSampling } from "./middleware.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-specific OTel metrics.
|
|
3
|
+
*
|
|
4
|
+
* Separate from core metrics (core/metrics.ts) — these only exist
|
|
5
|
+
* when MCP middleware is active. Initialized on first middleware call.
|
|
6
|
+
*/
|
|
7
|
+
export declare function recordMcpToolCall(toolName: string, durationMs: number, status: "success" | "error"): void;
|
|
8
|
+
export declare function recordMcpToolError(toolName: string, errorType: string): void;
|
|
9
|
+
export declare function recordMcpResourceRead(uri: string): void;
|
|
10
|
+
export declare function recordMcpSessionStart(): void;
|
|
11
|
+
export declare function recordMcpSessionEnd(): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-specific OTel metrics.
|
|
3
|
+
*
|
|
4
|
+
* Separate from core metrics (core/metrics.ts) — these only exist
|
|
5
|
+
* when MCP middleware is active. Initialized on first middleware call.
|
|
6
|
+
*/
|
|
7
|
+
import { metrics, } from "@opentelemetry/api";
|
|
8
|
+
import { GEN_AI_METRICS } from "../types/metrics.js";
|
|
9
|
+
const METER_NAME = "toad-eye-mcp";
|
|
10
|
+
let toolDuration;
|
|
11
|
+
let toolCalls;
|
|
12
|
+
let toolErrors;
|
|
13
|
+
let resourceReads;
|
|
14
|
+
let sessionActive;
|
|
15
|
+
let initialized = false;
|
|
16
|
+
function ensureInit() {
|
|
17
|
+
if (initialized)
|
|
18
|
+
return;
|
|
19
|
+
initialized = true;
|
|
20
|
+
const meter = metrics.getMeter(METER_NAME);
|
|
21
|
+
toolDuration = meter.createHistogram(GEN_AI_METRICS.MCP_TOOL_DURATION, {
|
|
22
|
+
description: "MCP tool execution duration",
|
|
23
|
+
unit: "ms",
|
|
24
|
+
});
|
|
25
|
+
toolCalls = meter.createCounter(GEN_AI_METRICS.MCP_TOOL_CALLS, {
|
|
26
|
+
description: "MCP tool call count by tool name and status",
|
|
27
|
+
});
|
|
28
|
+
toolErrors = meter.createCounter(GEN_AI_METRICS.MCP_TOOL_ERRORS, {
|
|
29
|
+
description: "MCP tool errors by tool name and error type",
|
|
30
|
+
});
|
|
31
|
+
resourceReads = meter.createCounter(GEN_AI_METRICS.MCP_RESOURCE_READS, {
|
|
32
|
+
description: "MCP resource read count by URI",
|
|
33
|
+
});
|
|
34
|
+
sessionActive = meter.createUpDownCounter(GEN_AI_METRICS.MCP_SESSION_ACTIVE, {
|
|
35
|
+
description: "Number of active MCP sessions",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function recordMcpToolCall(toolName, durationMs, status) {
|
|
39
|
+
ensureInit();
|
|
40
|
+
toolCalls.add(1, { "gen_ai.tool.name": toolName, status });
|
|
41
|
+
toolDuration.record(durationMs, { "gen_ai.tool.name": toolName });
|
|
42
|
+
}
|
|
43
|
+
export function recordMcpToolError(toolName, errorType) {
|
|
44
|
+
ensureInit();
|
|
45
|
+
toolErrors.add(1, { "gen_ai.tool.name": toolName, "error.type": errorType });
|
|
46
|
+
}
|
|
47
|
+
export function recordMcpResourceRead(uri) {
|
|
48
|
+
ensureInit();
|
|
49
|
+
resourceReads.add(1, { "gen_ai.data_source.id": uri });
|
|
50
|
+
}
|
|
51
|
+
export function recordMcpSessionStart() {
|
|
52
|
+
ensureInit();
|
|
53
|
+
sessionActive.add(1);
|
|
54
|
+
}
|
|
55
|
+
export function recordMcpSessionEnd() {
|
|
56
|
+
ensureInit();
|
|
57
|
+
sessionActive.add(-1);
|
|
58
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Middleware — wraps McpServer handler registration
|
|
3
|
+
* to automatically instrument tool calls, resource reads, and prompt gets.
|
|
4
|
+
*
|
|
5
|
+
* Uses Wrapper Pattern: intercepts .tool(), .resource(), .prompt() methods
|
|
6
|
+
* on the McpServer instance. Each handler is wrapped with OTel span creation.
|
|
7
|
+
*
|
|
8
|
+
* Why not monkey-patching: MCP SDK uses private class members (_requestHandlers)
|
|
9
|
+
* and strict TypeScript. Wrapping the public API is the stable path.
|
|
10
|
+
*/
|
|
11
|
+
import type { ToadMcpOptions } from "./types.js";
|
|
12
|
+
export declare function toadEyeMiddleware(server: any, options?: ToadMcpOptions): void;
|
|
13
|
+
/**
|
|
14
|
+
* Trace a sampling/createMessage call (server → client LLM request).
|
|
15
|
+
*
|
|
16
|
+
* Sampling is an outgoing request from the MCP server to the client,
|
|
17
|
+
* asking the client's LLM to generate a response. This cannot be
|
|
18
|
+
* auto-intercepted via wrapper pattern, so wrap manually.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { traceSampling } from "toad-eye/mcp";
|
|
23
|
+
*
|
|
24
|
+
* server.tool("summarize", { text: z.string() }, async ({ text }, ctx) => {
|
|
25
|
+
* const response = await traceSampling(
|
|
26
|
+
* () => ctx.mcpReq.requestSampling({
|
|
27
|
+
* messages: [{ role: "user", content: { type: "text", text } }],
|
|
28
|
+
* maxTokens: 500,
|
|
29
|
+
* }),
|
|
30
|
+
* { model: "gpt-4" }
|
|
31
|
+
* );
|
|
32
|
+
* return { content: [{ type: "text", text: response.content.text }] };
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function traceSampling<T>(fn: () => Promise<T>, options: TraceSamplingOptions): Promise<T>;
|
|
37
|
+
export interface TraceSamplingOptions {
|
|
38
|
+
/** The model being requested for sampling. */
|
|
39
|
+
readonly model?: string | undefined;
|
|
40
|
+
/** Max tokens requested. */
|
|
41
|
+
readonly maxTokens?: number | undefined;
|
|
42
|
+
/** MCP server name for span attributes. */
|
|
43
|
+
readonly serverName?: string | undefined;
|
|
44
|
+
/** MCP server version for span attributes. */
|
|
45
|
+
readonly serverVersion?: string | undefined;
|
|
46
|
+
/** Parent OTel context. */
|
|
47
|
+
readonly parentContext?: import("@opentelemetry/api").Context | undefined;
|
|
48
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Middleware — wraps McpServer handler registration
|
|
3
|
+
* to automatically instrument tool calls, resource reads, and prompt gets.
|
|
4
|
+
*
|
|
5
|
+
* Uses Wrapper Pattern: intercepts .tool(), .resource(), .prompt() methods
|
|
6
|
+
* on the McpServer instance. Each handler is wrapped with OTel span creation.
|
|
7
|
+
*
|
|
8
|
+
* Why not monkey-patching: MCP SDK uses private class members (_requestHandlers)
|
|
9
|
+
* and strict TypeScript. Wrapping the public API is the stable path.
|
|
10
|
+
*/
|
|
11
|
+
import { context, diag, DiagConsoleLogger, DiagLogLevel, } from "@opentelemetry/api";
|
|
12
|
+
import { extractContextFromMeta } from "./context.js";
|
|
13
|
+
import { startToolSpan, startResourceSpan, startPromptSpan, startSamplingSpan, endSpanSuccess, endSpanError, } from "./spans.js";
|
|
14
|
+
import { recordMcpToolCall, recordMcpToolError, recordMcpResourceRead, recordMcpSessionStart, } from "./metrics.js";
|
|
15
|
+
const DEFAULT_MAX_PAYLOAD_SIZE = 4096;
|
|
16
|
+
function truncate(value, maxBytes) {
|
|
17
|
+
if (value.length <= maxBytes)
|
|
18
|
+
return value;
|
|
19
|
+
return value.slice(0, maxBytes) + "...[truncated]";
|
|
20
|
+
}
|
|
21
|
+
function redactObject(obj, keys) {
|
|
22
|
+
if (typeof obj !== "object" || obj === null)
|
|
23
|
+
return {};
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
26
|
+
result[k] = keys.includes(k) ? "[REDACTED]" : v;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Instrument an MCP server with OpenTelemetry tracing.
|
|
32
|
+
*
|
|
33
|
+
* Wraps .tool(), .resource(), .prompt() registration methods so every
|
|
34
|
+
* handler execution produces an OTel span with correct GenAI semconv attributes.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
39
|
+
* import { toadEyeMiddleware } from "toad-eye/mcp";
|
|
40
|
+
*
|
|
41
|
+
* const server = new McpServer({ name: "my-server", version: "1.0.0" });
|
|
42
|
+
* toadEyeMiddleware(server);
|
|
43
|
+
*
|
|
44
|
+
* server.tool("calculate", { expression: z.string() }, async ({ expression }) => {
|
|
45
|
+
* return { content: [{ type: "text", text: String(eval(expression)) }] };
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Redirect OTel diagnostics to stderr to avoid polluting stdout.
|
|
51
|
+
* In stdio MCP transport, stdout IS the JSON-RPC channel —
|
|
52
|
+
* any stray console.log or OTel diagnostic would crash the connection.
|
|
53
|
+
*/
|
|
54
|
+
function ensureStdioSafe() {
|
|
55
|
+
const stderrLogger = new DiagConsoleLogger();
|
|
56
|
+
// Override the logger to use stderr-only methods
|
|
57
|
+
const safeLogger = {
|
|
58
|
+
verbose: (...args) => stderrLogger.verbose(String(args[0])),
|
|
59
|
+
debug: (...args) => stderrLogger.debug(String(args[0])),
|
|
60
|
+
info: (...args) => stderrLogger.info(String(args[0])),
|
|
61
|
+
warn: (...args) => stderrLogger.warn(String(args[0])),
|
|
62
|
+
error: (...args) => stderrLogger.error(String(args[0])),
|
|
63
|
+
};
|
|
64
|
+
diag.setLogger(safeLogger, DiagLogLevel.WARN);
|
|
65
|
+
}
|
|
66
|
+
export function toadEyeMiddleware(
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
server, options = {}) {
|
|
69
|
+
ensureStdioSafe();
|
|
70
|
+
recordMcpSessionStart();
|
|
71
|
+
const serverName = server.name ?? server._name ?? "mcp-server";
|
|
72
|
+
const serverVersion = server.version ?? server._version ?? "unknown";
|
|
73
|
+
const recordInputs = options.recordInputs ?? false;
|
|
74
|
+
const recordOutputs = options.recordOutputs ?? false;
|
|
75
|
+
const redactKeys = options.redactKeys ?? [];
|
|
76
|
+
const maxPayload = options.maxPayloadSize ?? DEFAULT_MAX_PAYLOAD_SIZE;
|
|
77
|
+
const propagate = options.propagateContext ?? true;
|
|
78
|
+
const spanOpts = { serverName, serverVersion };
|
|
79
|
+
// --- Wrap .tool() ---
|
|
80
|
+
const originalTool = server.tool.bind(server);
|
|
81
|
+
server.tool = function wrappedTool(name, ...rest) {
|
|
82
|
+
// Find the handler — last function argument
|
|
83
|
+
const handlerIndex = rest.findIndex((arg) => typeof arg === "function");
|
|
84
|
+
if (handlerIndex === -1) {
|
|
85
|
+
return originalTool(name, ...rest);
|
|
86
|
+
}
|
|
87
|
+
const originalHandler = rest[handlerIndex];
|
|
88
|
+
rest[handlerIndex] = async function wrappedHandler(...handlerArgs) {
|
|
89
|
+
// Extract _meta for context propagation
|
|
90
|
+
const firstArg = handlerArgs[0];
|
|
91
|
+
const meta = propagate
|
|
92
|
+
? firstArg?._meta
|
|
93
|
+
: undefined;
|
|
94
|
+
const parentCtx = propagate
|
|
95
|
+
? extractContextFromMeta(meta)
|
|
96
|
+
: context.active();
|
|
97
|
+
const span = startToolSpan(name, {
|
|
98
|
+
...spanOpts,
|
|
99
|
+
parentContext: parentCtx,
|
|
100
|
+
});
|
|
101
|
+
// Record inputs if enabled
|
|
102
|
+
if (recordInputs && firstArg) {
|
|
103
|
+
const sanitized = redactKeys.length > 0 ? redactObject(firstArg, redactKeys) : firstArg;
|
|
104
|
+
const serialized = truncate(JSON.stringify(sanitized), maxPayload);
|
|
105
|
+
span.setAttribute("gen_ai.tool.call.arguments", serialized);
|
|
106
|
+
}
|
|
107
|
+
// Custom hook
|
|
108
|
+
if (options.onToolCall) {
|
|
109
|
+
options.onToolCall(span, name, firstArg);
|
|
110
|
+
}
|
|
111
|
+
const start = performance.now();
|
|
112
|
+
try {
|
|
113
|
+
const result = await context.with(context.active(), () => originalHandler(...handlerArgs));
|
|
114
|
+
const durationMs = performance.now() - start;
|
|
115
|
+
// Record outputs if enabled
|
|
116
|
+
if (recordOutputs && result) {
|
|
117
|
+
const serialized = truncate(JSON.stringify(result), maxPayload);
|
|
118
|
+
span.setAttribute("gen_ai.tool.call.result", serialized);
|
|
119
|
+
}
|
|
120
|
+
endSpanSuccess(span);
|
|
121
|
+
recordMcpToolCall(name, durationMs, "success");
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
const durationMs = performance.now() - start;
|
|
126
|
+
const errorType = error instanceof Error ? error.constructor.name : "UnknownError";
|
|
127
|
+
endSpanError(span, error);
|
|
128
|
+
recordMcpToolCall(name, durationMs, "error");
|
|
129
|
+
recordMcpToolError(name, errorType);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
return originalTool(name, ...rest);
|
|
134
|
+
};
|
|
135
|
+
// --- Wrap .resource() ---
|
|
136
|
+
const originalResource = server.resource?.bind(server);
|
|
137
|
+
if (originalResource) {
|
|
138
|
+
server.resource = function wrappedResource(name, ...rest) {
|
|
139
|
+
const handlerIndex = rest.findIndex((arg) => typeof arg === "function");
|
|
140
|
+
if (handlerIndex === -1) {
|
|
141
|
+
return originalResource(name, ...rest);
|
|
142
|
+
}
|
|
143
|
+
const originalHandler = rest[handlerIndex];
|
|
144
|
+
rest[handlerIndex] = async function wrappedHandler(...handlerArgs) {
|
|
145
|
+
const uri = handlerArgs[0]?.uri ?? name;
|
|
146
|
+
const parentCtx = propagate
|
|
147
|
+
? extractContextFromMeta(handlerArgs[0]?._meta)
|
|
148
|
+
: context.active();
|
|
149
|
+
const span = startResourceSpan(uri, {
|
|
150
|
+
...spanOpts,
|
|
151
|
+
parentContext: parentCtx,
|
|
152
|
+
});
|
|
153
|
+
if (options.onResourceRead) {
|
|
154
|
+
options.onResourceRead(span, uri);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const result = await originalHandler(...handlerArgs);
|
|
158
|
+
endSpanSuccess(span);
|
|
159
|
+
recordMcpResourceRead(uri);
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
endSpanError(span, error);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
return originalResource(name, ...rest);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// --- Wrap .prompt() ---
|
|
171
|
+
const originalPrompt = server.prompt?.bind(server);
|
|
172
|
+
if (originalPrompt) {
|
|
173
|
+
server.prompt = function wrappedPrompt(name, ...rest) {
|
|
174
|
+
const handlerIndex = rest.findIndex((arg) => typeof arg === "function");
|
|
175
|
+
if (handlerIndex === -1) {
|
|
176
|
+
return originalPrompt(name, ...rest);
|
|
177
|
+
}
|
|
178
|
+
const originalHandler = rest[handlerIndex];
|
|
179
|
+
rest[handlerIndex] = async function wrappedHandler(...handlerArgs) {
|
|
180
|
+
const parentCtx = propagate
|
|
181
|
+
? extractContextFromMeta(handlerArgs[0]?._meta)
|
|
182
|
+
: context.active();
|
|
183
|
+
const span = startPromptSpan(name, {
|
|
184
|
+
...spanOpts,
|
|
185
|
+
parentContext: parentCtx,
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
const result = await originalHandler(...handlerArgs);
|
|
189
|
+
endSpanSuccess(span);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
endSpanError(span, error);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
return originalPrompt(name, ...rest);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Trace a sampling/createMessage call (server → client LLM request).
|
|
203
|
+
*
|
|
204
|
+
* Sampling is an outgoing request from the MCP server to the client,
|
|
205
|
+
* asking the client's LLM to generate a response. This cannot be
|
|
206
|
+
* auto-intercepted via wrapper pattern, so wrap manually.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* import { traceSampling } from "toad-eye/mcp";
|
|
211
|
+
*
|
|
212
|
+
* server.tool("summarize", { text: z.string() }, async ({ text }, ctx) => {
|
|
213
|
+
* const response = await traceSampling(
|
|
214
|
+
* () => ctx.mcpReq.requestSampling({
|
|
215
|
+
* messages: [{ role: "user", content: { type: "text", text } }],
|
|
216
|
+
* maxTokens: 500,
|
|
217
|
+
* }),
|
|
218
|
+
* { model: "gpt-4" }
|
|
219
|
+
* );
|
|
220
|
+
* return { content: [{ type: "text", text: response.content.text }] };
|
|
221
|
+
* });
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
export async function traceSampling(fn, options) {
|
|
225
|
+
const model = options.model ?? "unknown";
|
|
226
|
+
const span = startSamplingSpan(model, {
|
|
227
|
+
serverName: options.serverName ?? "mcp-server",
|
|
228
|
+
serverVersion: options.serverVersion ?? "unknown",
|
|
229
|
+
parentContext: options.parentContext,
|
|
230
|
+
});
|
|
231
|
+
if (options.maxTokens !== undefined) {
|
|
232
|
+
span.setAttribute("gen_ai.request.max_tokens", options.maxTokens);
|
|
233
|
+
}
|
|
234
|
+
const start = performance.now();
|
|
235
|
+
try {
|
|
236
|
+
const result = await fn();
|
|
237
|
+
const durationMs = performance.now() - start;
|
|
238
|
+
// Extract token usage from response if available
|
|
239
|
+
const response = result;
|
|
240
|
+
if (response && typeof response === "object") {
|
|
241
|
+
if (response["model"]) {
|
|
242
|
+
span.setAttribute("gen_ai.response.model", String(response["model"]));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
span.setAttribute("gen_ai.mcp.sampling.duration_ms", durationMs);
|
|
246
|
+
endSpanSuccess(span);
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
endSpanError(span, error);
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTel span creation for MCP operations.
|
|
3
|
+
*
|
|
4
|
+
* Maps MCP methods to OTel GenAI semantic conventions:
|
|
5
|
+
* - tools/call → execute_tool {tool_name}
|
|
6
|
+
* - resources/read → retrieval {uri}
|
|
7
|
+
* - prompts/get → prompt {name}
|
|
8
|
+
* - sampling/createMessage → chat {model}
|
|
9
|
+
*/
|
|
10
|
+
import { type Context } from "@opentelemetry/api";
|
|
11
|
+
declare const tracer: import("@opentelemetry/api").Tracer;
|
|
12
|
+
export interface SpanOptions {
|
|
13
|
+
readonly serverName: string;
|
|
14
|
+
readonly serverVersion: string;
|
|
15
|
+
readonly parentContext: Context;
|
|
16
|
+
}
|
|
17
|
+
export declare function startToolSpan(toolName: string, options: SpanOptions): import("@opentelemetry/api").Span;
|
|
18
|
+
export declare function startResourceSpan(uri: string, options: SpanOptions): import("@opentelemetry/api").Span;
|
|
19
|
+
export declare function startPromptSpan(promptName: string, options: SpanOptions): import("@opentelemetry/api").Span;
|
|
20
|
+
export interface SamplingSpanOptions {
|
|
21
|
+
readonly serverName: string;
|
|
22
|
+
readonly serverVersion: string;
|
|
23
|
+
readonly parentContext?: Context | undefined;
|
|
24
|
+
}
|
|
25
|
+
export declare function startSamplingSpan(model: string, options: SamplingSpanOptions): import("@opentelemetry/api").Span;
|
|
26
|
+
export declare function endSpanSuccess(span: ReturnType<typeof tracer.startSpan>): void;
|
|
27
|
+
export declare function endSpanError(span: ReturnType<typeof tracer.startSpan>, error: unknown): void;
|
|
28
|
+
export {};
|