toad-eye 2.5.0 → 2.6.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/README.md CHANGED
@@ -124,6 +124,39 @@ const result = await traceLLMCall(
124
124
 
125
125
  </details>
126
126
 
127
+ <details>
128
+ <summary>MCP Server observability</summary>
129
+
130
+ Drop-in middleware for MCP servers — one line for full tracing of every tool call, resource read, and prompt request.
131
+
132
+ ```typescript
133
+ import { McpServer } from "@modelcontextprotocol/sdk/server/index.js";
134
+ import { toadEyeMiddleware } from "toad-eye/mcp";
135
+
136
+ const server = new McpServer({ name: "my-server", version: "1.0.0" });
137
+ toadEyeMiddleware(server);
138
+
139
+ // All handlers are now instrumented — spans appear in Jaeger
140
+ server.tool("calculate", { expression: z.string() }, async ({ expression }) => {
141
+ return { content: [{ type: "text", text: String(eval(expression)) }] };
142
+ });
143
+ ```
144
+
145
+ Spans follow OTel GenAI semconv: `execute_tool calculate`, `retrieval file:///docs`, `prompt greeting`.
146
+
147
+ Privacy by default — tool arguments and results are NOT recorded unless you opt in:
148
+
149
+ ```typescript
150
+ toadEyeMiddleware(server, {
151
+ recordInputs: true,
152
+ redactKeys: ["apiKey", "token"],
153
+ });
154
+ ```
155
+
156
+ > Safe for stdio transport — OTel diagnostics are redirected to stderr.
157
+
158
+ </details>
159
+
127
160
  ## Budget guards
128
161
 
129
162
  Prevent cost overruns at runtime. Three modes: warn, block, or auto-downgrade to a cheaper model.
package/dist/cli.js CHANGED
@@ -207,7 +207,14 @@ async function exportTraceCommand() {
207
207
  i++;
208
208
  }
209
209
  else if (args[i] === "--jaeger-url" && args[i + 1]) {
210
- jaegerUrl = args[i + 1];
210
+ try {
211
+ new URL(args[i + 1]);
212
+ jaegerUrl = args[i + 1];
213
+ }
214
+ catch {
215
+ console.error(`❌ Invalid --jaeger-url: ${args[i + 1]}`);
216
+ process.exit(1);
217
+ }
211
218
  i++;
212
219
  }
213
220
  }
@@ -1,2 +1 @@
1
- export { ToadContextExceededError } from "./error.js";
2
- export { checkContextGuard, resetContextGuardState } from "./guard.js";
1
+ export {};
@@ -1,2 +1,4 @@
1
- export { ToadContextExceededError } from "./error.js";
2
- export { checkContextGuard, resetContextGuardState } from "./guard.js";
1
+ export {};
2
+ // Context module — post-call utilization monitoring only.
3
+ // Pre-call blocking was removed (see #198) — API providers handle
4
+ // context overflow natively, and pre-call token estimation is unreliable.
@@ -92,6 +92,10 @@ export function initMetrics() {
92
92
  export function resetMetrics() {
93
93
  initialized = false;
94
94
  }
95
+ /** Returns true if metrics are safe to record. Prevents crash if called before initMetrics(). */
96
+ function isReady() {
97
+ return initialized;
98
+ }
95
99
  /** Build metric labels: provider + model + optional FinOps attributes. */
96
100
  function baseLabels(provider, model, attrs) {
97
101
  return {
@@ -101,78 +105,116 @@ function baseLabels(provider, model, attrs) {
101
105
  };
102
106
  }
103
107
  export function recordRequestDuration(ms, provider, model, attrs) {
108
+ if (!isReady())
109
+ return;
104
110
  requestDuration.record(ms, baseLabels(provider, model, attrs));
105
111
  }
106
112
  export function recordRequestCost(usd, provider, model, attrs) {
113
+ if (!isReady())
114
+ return;
107
115
  requestCost.record(usd, baseLabels(provider, model, attrs));
108
116
  }
109
117
  export function recordTokens(count, provider, model, attrs) {
118
+ if (!isReady())
119
+ return;
110
120
  tokenUsage.add(count, baseLabels(provider, model, attrs));
111
121
  }
112
122
  export function recordRequest(provider, model, attrs) {
123
+ if (!isReady())
124
+ return;
113
125
  requestsTotal.add(1, baseLabels(provider, model, attrs));
114
126
  }
115
127
  export function recordError(provider, model, attrs) {
128
+ if (!isReady())
129
+ return;
116
130
  errorsTotal.add(1, baseLabels(provider, model, attrs));
117
131
  }
118
132
  export function recordAgentSteps(stepCount) {
133
+ if (!isReady())
134
+ return;
119
135
  agentStepsPerQuery.record(stepCount);
120
136
  }
121
137
  export function recordAgentToolUsage(toolName, status = "success") {
138
+ if (!isReady())
139
+ return;
122
140
  agentToolUsage.add(1, {
123
141
  [GEN_AI_ATTRS.TOOL_NAME]: toolName,
124
142
  "tool.status": status,
125
143
  });
126
144
  }
127
145
  export function recordAgentToolDuration(toolName, durationMs) {
146
+ if (!isReady())
147
+ return;
128
148
  agentToolDuration.record(durationMs, {
129
149
  [GEN_AI_ATTRS.TOOL_NAME]: toolName,
130
150
  });
131
151
  }
132
152
  export function recordGuardEvaluation(ruleName) {
153
+ if (!isReady())
154
+ return;
133
155
  guardEvaluations.add(1, {
134
156
  [GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
135
157
  });
136
158
  }
137
159
  export function recordGuardWouldBlock(ruleName) {
160
+ if (!isReady())
161
+ return;
138
162
  guardWouldBlock.add(1, {
139
163
  [GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
140
164
  });
141
165
  }
142
166
  export function recordSemanticDrift(drift, provider, model) {
167
+ if (!isReady())
168
+ return;
143
169
  semanticDrift.record(drift, {
144
170
  [GEN_AI_ATTRS.PROVIDER]: provider,
145
171
  [GEN_AI_ATTRS.REQUEST_MODEL]: model,
146
172
  });
147
173
  }
148
174
  export function recordTimeToFirstToken(ms, provider, model) {
175
+ if (!isReady())
176
+ return;
149
177
  timeToFirstToken.record(ms, {
150
178
  [GEN_AI_ATTRS.PROVIDER]: provider,
151
179
  [GEN_AI_ATTRS.REQUEST_MODEL]: model,
152
180
  });
153
181
  }
154
182
  export function recordBudgetExceeded(budgetType) {
183
+ if (!isReady())
184
+ return;
155
185
  budgetExceeded.add(1, { budget_type: budgetType });
156
186
  }
157
187
  export function recordBudgetBlocked(budgetType) {
188
+ if (!isReady())
189
+ return;
158
190
  budgetBlocked.add(1, { budget_type: budgetType });
159
191
  }
160
192
  export function recordBudgetDowngraded(budgetType) {
193
+ if (!isReady())
194
+ return;
161
195
  budgetDowngraded.add(1, { budget_type: budgetType });
162
196
  }
163
197
  export function recordResponseEmpty(provider, model, attrs) {
198
+ if (!isReady())
199
+ return;
164
200
  responseEmpty.add(1, baseLabels(provider, model, attrs));
165
201
  }
166
202
  export function recordResponseLatencyPerToken(msPerToken, provider, model, attrs) {
203
+ if (!isReady())
204
+ return;
167
205
  responseLatencyPerToken.record(msPerToken, baseLabels(provider, model, attrs));
168
206
  }
169
207
  export function recordContextUtilization(utilization, provider, model) {
208
+ if (!isReady())
209
+ return;
170
210
  contextUtilization.record(utilization, {
171
211
  [GEN_AI_ATTRS.PROVIDER]: provider,
172
212
  [GEN_AI_ATTRS.REQUEST_MODEL]: model,
173
213
  });
174
214
  }
175
215
  export function recordContextBlocked(model) {
216
+ if (!isReady())
217
+ return;
176
218
  contextBlocked.add(1, {
177
219
  [GEN_AI_ATTRS.REQUEST_MODEL]: model,
178
220
  });
@@ -60,6 +60,7 @@ const BUILT_IN_PRICING = {
60
60
  },
61
61
  };
62
62
  let customPricing = {};
63
+ const warnedModels = new Set();
63
64
  /**
64
65
  * Set custom pricing for models (overrides built-in prices).
65
66
  * Useful for enterprise contracts or custom/fine-tuned models.
@@ -70,6 +71,7 @@ export function setCustomPricing(pricing) {
70
71
  /** Reset custom pricing to empty. Called from shutdown() to avoid stale state across re-inits. */
71
72
  export function resetCustomPricing() {
72
73
  customPricing = {};
74
+ warnedModels.clear();
73
75
  }
74
76
  /**
75
77
  * Get pricing for a model. Custom pricing takes precedence.
@@ -86,8 +88,15 @@ export function calculateCost(model, inputTokens, outputTokens) {
86
88
  if (inputTokens < 0 || outputTokens < 0)
87
89
  return 0;
88
90
  const pricing = getModelPricing(model);
89
- if (!pricing)
91
+ if (!pricing) {
92
+ if (inputTokens + outputTokens > 0 && !warnedModels.has(model)) {
93
+ if (warnedModels.size >= 100)
94
+ warnedModels.clear();
95
+ warnedModels.add(model);
96
+ console.warn(`toad-eye: unknown pricing for "${model}" — cost reported as $0. Use setCustomPricing() to set rates.`);
97
+ }
90
98
  return 0;
99
+ }
91
100
  const cost = (inputTokens / 1_000_000) * pricing.inputPer1M +
92
101
  (outputTokens / 1_000_000) * pricing.outputPer1M;
93
102
  return Math.round(cost * 1_000_000) / 1_000_000;
@@ -5,6 +5,8 @@ export interface LLMCallInput {
5
5
  readonly model: string;
6
6
  readonly prompt: string;
7
7
  readonly temperature?: number | undefined;
8
+ /** OTel operation name for span naming. Default: "chat". */
9
+ readonly operationName?: string | undefined;
8
10
  /** Per-request FinOps attributes (team, userId, feature, etc). Merged with global attributes. */
9
11
  readonly attributes?: Readonly<Record<string, string>> | undefined;
10
12
  }
@@ -46,6 +46,11 @@ export function processContent(text) {
46
46
  const config = getConfig();
47
47
  if (config?.recordContent === false)
48
48
  return undefined;
49
+ // Content sampling — record only a fraction of calls
50
+ if (config?.contentSamplingRate !== undefined) {
51
+ if (Math.random() >= config.contentSamplingRate)
52
+ return undefined;
53
+ }
49
54
  let processed = text;
50
55
  let totalRedacted = 0;
51
56
  // Apply default PII patterns if enabled
@@ -59,9 +64,14 @@ export function processContent(text) {
59
64
  // Apply custom patterns
60
65
  if (config?.redactPatterns?.length) {
61
66
  for (const pattern of config.redactPatterns) {
62
- const [result, count] = applyRedaction(processed, pattern);
63
- processed = result;
64
- totalRedacted += count;
67
+ try {
68
+ const [result, count] = applyRedaction(processed, pattern);
69
+ processed = result;
70
+ totalRedacted += count;
71
+ }
72
+ catch {
73
+ console.warn(`toad-eye: invalid redact pattern skipped: ${pattern.source}`);
74
+ }
65
75
  }
66
76
  }
67
77
  // Audit mode — log a summary only, never the original content (would re-expose PII)
@@ -93,7 +103,7 @@ function setBaseAttributes(span, input) {
93
103
  [GEN_AI_ATTRS.PROVIDER]: input.provider,
94
104
  [GEN_AI_ATTRS.REQUEST_MODEL]: input.model,
95
105
  [GEN_AI_ATTRS.TEMPERATURE]: input.temperature ?? 1.0,
96
- [GEN_AI_ATTRS.OPERATION]: "chat",
106
+ [GEN_AI_ATTRS.OPERATION]: input.operationName ?? "chat",
97
107
  ...(prompt !== undefined && { [GEN_AI_ATTRS.PROMPT]: prompt }),
98
108
  ...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
99
109
  ...attrs,
@@ -153,7 +163,8 @@ export async function traceLLMCall(input, fn) {
153
163
  recordBudgetDowngraded(override.budget);
154
164
  }
155
165
  }
156
- return tracer.startActiveSpan(`chat ${effectiveInput.model}`, async (span) => {
166
+ const op = effectiveInput.operationName ?? "chat";
167
+ return tracer.startActiveSpan(`${op} ${effectiveInput.model}`, async (span) => {
157
168
  const start = performance.now();
158
169
  setBaseAttributes(span, effectiveInput);
159
170
  try {
@@ -184,13 +195,13 @@ export async function traceLLMCall(input, fn) {
184
195
  // can compress context before the NEXT call.
185
196
  const guard = getConfig()?.contextGuard;
186
197
  if (guard) {
187
- if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
198
+ if (guard.alertAt !== undefined && utilization >= guard.alertAt) {
188
199
  recordContextBlocked(effectiveInput.model);
189
200
  span.addEvent("gen_ai.context.limit_exceeded", {
190
201
  "gen_ai.toad_eye.context_utilization": utilization,
191
- "gen_ai.toad_eye.context.threshold": guard.blockAt,
202
+ "gen_ai.toad_eye.context.threshold": guard.alertAt,
192
203
  });
193
- console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} — exceeds blockAt threshold ${(guard.blockAt * 100).toFixed(0)}%. Compress context before next call.`);
204
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} — exceeds alertAt threshold ${(guard.alertAt * 100).toFixed(0)}%. Compress context before next call.`);
194
205
  }
195
206
  else if (guard.warnAt !== undefined &&
196
207
  utilization >= guard.warnAt) {
@@ -215,15 +226,18 @@ export async function traceLLMCall(input, fn) {
215
226
  const attrs = resolveAttributes(effectiveInput);
216
227
  setErrorAttributes(span, message);
217
228
  recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
218
- recordError(effectiveInput.provider, effectiveInput.model, attrs);
219
229
  // Release cost reservation on any error (no cost was incurred)
220
230
  if (budget) {
221
231
  budget.releaseReservation(estimatedCost);
222
232
  }
223
- // If this was a budget block, record the metric with the correct budget type
224
233
  if (error instanceof ToadBudgetExceededError) {
234
+ // Budget blocks are intentional guardrails, not errors — record
235
+ // only the budget-specific metric to avoid inflating error rate
225
236
  recordBudgetBlocked(error.budget);
226
237
  }
238
+ else {
239
+ recordError(effectiveInput.provider, effectiveInput.model, attrs);
240
+ }
227
241
  throw error;
228
242
  }
229
243
  finally {
@@ -7,7 +7,6 @@ import { TraceIdRatioBasedSampler, ParentBasedSampler, BatchSpanProcessor, } fro
7
7
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
8
8
  import { initMetrics, resetMetrics } from "./metrics.js";
9
9
  import { resetCustomPricing } from "./pricing.js";
10
- import { resetContextGuardState } from "../context/guard.js";
11
10
  import { enableAll, disableAll } from "../instrumentations/registry.js";
12
11
  import { BudgetTracker } from "../budget/index.js";
13
12
  import { ToadEyeAISpanProcessor } from "../vercel.js";
@@ -41,6 +40,10 @@ function validateConfig(config) {
41
40
  if (config.apiKey !== undefined && !config.apiKey.startsWith("toad_")) {
42
41
  throw new Error('toad-eye: apiKey must start with "toad_" prefix');
43
42
  }
43
+ if (config.onBudgetExceeded === "downgrade" &&
44
+ config.downgradeCallback === undefined) {
45
+ throw new Error('toad-eye: downgradeCallback is required when onBudgetExceeded is "downgrade"');
46
+ }
44
47
  }
45
48
  /** Resolve endpoint and auth headers based on config mode (self-hosted vs cloud). */
46
49
  function resolveTransport(config) {
@@ -124,7 +127,9 @@ export function initObservability(config) {
124
127
  if (patchProviders.length > 0) {
125
128
  // enableAll is async (lazy-loads provider modules on first call).
126
129
  // Fire-and-forget: patching completes before any async SDK call.
127
- void enableAll(patchProviders);
130
+ void enableAll(patchProviders).catch((err) => {
131
+ console.warn(`toad-eye: auto-instrumentation failed: ${err instanceof Error ? err.message : err}`);
132
+ });
128
133
  }
129
134
  }
130
135
  }
@@ -138,5 +143,4 @@ export async function shutdown() {
138
143
  budgetTracker = null;
139
144
  resetMetrics();
140
145
  resetCustomPricing();
141
- resetContextGuardState();
142
146
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
2
2
  export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
3
- export { ToadContextExceededError } from "./context/index.js";
4
3
  export type { BudgetConfig, BudgetExceededMode, BudgetExceededInfo, DowngradeCallback, } from "./budget/index.js";
5
4
  export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
6
5
  export type { AlertsConfig, AlertRule, AlertChannelConfig, FiredAlert, } from "./alerts/index.js";
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
2
2
  export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
3
- export { ToadContextExceededError } from "./context/index.js";
4
3
  export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
5
4
  export { traceLLMCall } from "./core/spans.js";
6
5
  export { traceAgentStep, traceAgentQuery } from "./agent.js";
@@ -94,7 +94,8 @@ original, patch, providerName) {
94
94
  recordBudgetDowngraded(override.budget);
95
95
  }
96
96
  }
97
- const span = tracer.startSpan(`chat ${effectiveModel}`);
97
+ const op = patch.operationName ?? "chat";
98
+ const span = tracer.startSpan(`${op} ${effectiveModel}`);
98
99
  const ctx = trace.setSpan(context.active(), span);
99
100
  const sessionId = config?.sessionExtractor?.() ?? config?.sessionId;
100
101
  let ttftMs = 0;
@@ -102,7 +103,7 @@ original, patch, providerName) {
102
103
  [GEN_AI_ATTRS.PROVIDER]: effectiveProvider,
103
104
  [GEN_AI_ATTRS.REQUEST_MODEL]: effectiveModel,
104
105
  [GEN_AI_ATTRS.TEMPERATURE]: req.temperature ?? 1.0,
105
- [GEN_AI_ATTRS.OPERATION]: "chat",
106
+ [GEN_AI_ATTRS.OPERATION]: op,
106
107
  ...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
107
108
  });
108
109
  let response;
@@ -167,10 +168,18 @@ original, patch, providerName) {
167
168
  [GEN_AI_ATTRS.INPUT_TOKENS]: acc.inputTokens,
168
169
  [GEN_AI_ATTRS.OUTPUT_TOKENS]: acc.outputTokens,
169
170
  [GEN_AI_ATTRS.COST]: cost,
170
- [GEN_AI_ATTRS.STATUS]: "success",
171
- [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"],
172
173
  });
173
- 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
+ }
174
183
  span.end();
175
184
  recordRequest(effectiveProvider, effectiveModel);
176
185
  recordRequestDuration(duration, effectiveProvider, effectiveModel);
@@ -192,13 +201,13 @@ original, patch, providerName) {
192
201
  // Context guard — post-stream warning
193
202
  const guard = config?.contextGuard;
194
203
  if (guard) {
195
- if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
204
+ if (guard.alertAt !== undefined && utilization >= guard.alertAt) {
196
205
  recordContextBlocked(effectiveModel);
197
206
  span.addEvent("gen_ai.context.limit_exceeded", {
198
207
  "gen_ai.toad_eye.context_utilization": utilization,
199
- "gen_ai.toad_eye.context.threshold": guard.blockAt,
208
+ "gen_ai.toad_eye.context.threshold": guard.alertAt,
200
209
  });
201
- console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} — exceeds blockAt threshold ${(guard.blockAt * 100).toFixed(0)}%. Compress context before next call.`);
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.`);
202
211
  }
203
212
  else if (guard.warnAt !== undefined &&
204
213
  utilization >= guard.warnAt) {
@@ -269,6 +278,7 @@ original, patch, providerName) {
269
278
  model: req.model,
270
279
  prompt: req.prompt,
271
280
  temperature: req.temperature,
281
+ operationName: patch.operationName,
272
282
  }, async () => {
273
283
  const response = await original.call(this, body, ...rest);
274
284
  const res = patch.extractResponse(response, req.model);
@@ -301,6 +311,11 @@ export function createInstrumentation(config) {
301
311
  return activePatches.length > 0;
302
312
  }
303
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;
304
319
  diag.warn(`toad-eye: failed to patch ${config.name}: ${err}`);
305
320
  return false;
306
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;
@@ -20,13 +20,19 @@ export interface StreamAccumulator {
20
20
  inputTokens: number;
21
21
  outputTokens: number;
22
22
  toolCalls: AccumulatedToolCall[];
23
+ /** Finish reason from the final chunk (e.g., "stop", "SAFETY"). */
24
+ finishReason?: string | undefined;
23
25
  }
26
+ /** OTel GenAI operation types. Default is "chat". */
27
+ export type OperationName = "chat" | "text_completion" | "embeddings" | "generate_content";
24
28
  /** Describes a single method to monkey-patch on an SDK prototype. */
25
29
  export interface PatchTarget {
26
30
  /** How to find the prototype from the loaded SDK module */
27
31
  getPrototype: (sdk: any) => any | undefined;
28
32
  /** Method name on the prototype to patch */
29
33
  method: string;
34
+ /** OTel operation name for span naming. Default: "chat". */
35
+ operationName?: OperationName | undefined;
30
36
  /** Extract LLMCallInput fields from the request arguments.
31
37
  * thisArg is the SDK object instance — use it when the model name is not in the request body
32
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
+ }