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.
Files changed (50) hide show
  1. package/README.md +34 -1
  2. package/dist/agent.js +6 -2
  3. package/dist/cli.js +8 -1
  4. package/dist/context/error.d.ts +15 -0
  5. package/dist/context/error.js +17 -0
  6. package/dist/context/guard.d.ts +8 -0
  7. package/dist/context/guard.js +41 -0
  8. package/dist/context/index.d.ts +1 -0
  9. package/dist/context/index.js +4 -0
  10. package/dist/core/metrics.d.ts +4 -1
  11. package/dist/core/metrics.js +74 -2
  12. package/dist/core/pricing.d.ts +2 -0
  13. package/dist/core/pricing.js +61 -16
  14. package/dist/core/spans.d.ts +2 -0
  15. package/dist/core/spans.js +48 -9
  16. package/dist/core/tracer.js +7 -1
  17. package/dist/instrumentations/anthropic.js +8 -2
  18. package/dist/instrumentations/create.js +67 -8
  19. package/dist/instrumentations/gemini.js +7 -0
  20. package/dist/instrumentations/openai.js +5 -0
  21. package/dist/instrumentations/types.d.ts +8 -0
  22. package/dist/mcp/context.d.ts +12 -0
  23. package/dist/mcp/context.js +22 -0
  24. package/dist/mcp/index.d.ts +3 -0
  25. package/dist/mcp/index.js +1 -0
  26. package/dist/mcp/metrics.d.ts +11 -0
  27. package/dist/mcp/metrics.js +58 -0
  28. package/dist/mcp/middleware.d.ts +48 -0
  29. package/dist/mcp/middleware.js +253 -0
  30. package/dist/mcp/spans.d.ts +28 -0
  31. package/dist/mcp/spans.js +67 -0
  32. package/dist/mcp/types.d.ts +34 -0
  33. package/dist/mcp/types.js +1 -0
  34. package/dist/types/attributes.d.ts +1 -0
  35. package/dist/types/attributes.js +1 -0
  36. package/dist/types/config.d.ts +9 -0
  37. package/dist/types/metrics.d.ts +8 -0
  38. package/dist/types/metrics.js +10 -0
  39. package/dist/types/spans.d.ts +4 -0
  40. package/package.json +11 -2
  41. package/templates/docker-compose.yml +4 -4
  42. package/templates/grafana/dashboards/agent-workflow.json +154 -31
  43. package/templates/grafana/dashboards/cost-breakdown.json +168 -40
  44. package/templates/grafana/dashboards/error-drilldown.json +155 -37
  45. package/templates/grafana/dashboards/finops-attribution.json +192 -43
  46. package/templates/grafana/dashboards/latency-analysis.json +103 -24
  47. package/templates/grafana/dashboards/llm-overview.json +268 -66
  48. package/templates/grafana/dashboards/mcp-server.json +519 -0
  49. package/templates/grafana/dashboards/model-comparison.json +119 -26
  50. 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 span = tracer.startSpan(`chat ${effectiveModel}`);
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]: "chat",
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
- recordTimeToFirstToken(performance.now() - start, effectiveProvider, effectiveModel);
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
- span.setStatus({ code: SpanStatusCode.OK });
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,3 @@
1
+ export { toadEyeMiddleware, traceSampling } from "./middleware.js";
2
+ export type { TraceSamplingOptions } from "./middleware.js";
3
+ export type { ToadMcpOptions, McpSpanAttributes } from "./types.js";
@@ -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 {};