toad-eye 2.5.0 → 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 +33 -0
- package/dist/cli.js +8 -1
- package/dist/context/index.d.ts +1 -2
- package/dist/context/index.js +4 -2
- package/dist/core/metrics.js +42 -0
- package/dist/core/pricing.js +10 -1
- package/dist/core/spans.d.ts +2 -0
- package/dist/core/spans.js +24 -10
- package/dist/core/tracer.js +7 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/instrumentations/create.js +23 -8
- package/dist/instrumentations/gemini.js +7 -0
- package/dist/instrumentations/openai.js +5 -0
- package/dist/instrumentations/types.d.ts +6 -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/config.d.ts +5 -3
- package/dist/types/metrics.d.ts +5 -0
- package/dist/types/metrics.js +6 -0
- package/package.json +11 -2
- package/templates/docker-compose.yml +4 -4
- package/templates/grafana/dashboards/mcp-server.json +519 -0
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
|
-
|
|
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
|
}
|
package/dist/context/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export { checkContextGuard, resetContextGuardState } from "./guard.js";
|
|
1
|
+
export {};
|
package/dist/context/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
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.
|
package/dist/core/metrics.js
CHANGED
|
@@ -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
|
});
|
package/dist/core/pricing.js
CHANGED
|
@@ -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;
|
package/dist/core/spans.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/core/spans.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 {
|
package/dist/core/tracer.js
CHANGED
|
@@ -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
|
|
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]:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 @@
|
|
|
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
|
+
}
|