toad-eye 2.2.0 → 2.4.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 (47) hide show
  1. package/README.md +166 -152
  2. package/dist/agent.d.ts +31 -28
  3. package/dist/agent.js +116 -35
  4. package/dist/alerts/channels.js +24 -6
  5. package/dist/alerts/index.js +46 -2
  6. package/dist/alerts/manager.d.ts +1 -0
  7. package/dist/alerts/manager.js +36 -21
  8. package/dist/budget/tracker.d.ts +16 -6
  9. package/dist/budget/tracker.js +38 -17
  10. package/dist/budget/types.d.ts +2 -0
  11. package/dist/cli.d.ts +8 -1
  12. package/dist/cli.js +106 -54
  13. package/dist/core/metrics.d.ts +4 -0
  14. package/dist/core/metrics.js +20 -1
  15. package/dist/core/pricing.d.ts +2 -0
  16. package/dist/core/pricing.js +4 -0
  17. package/dist/core/spans.d.ts +5 -3
  18. package/dist/core/spans.js +80 -21
  19. package/dist/core/tracer.d.ts +6 -3
  20. package/dist/core/tracer.js +49 -7
  21. package/dist/drift/baseline.js +7 -1
  22. package/dist/drift/monitor.js +6 -6
  23. package/dist/index.d.ts +7 -2
  24. package/dist/index.js +6 -1
  25. package/dist/instrumentations/anthropic.d.ts +1 -1
  26. package/dist/instrumentations/anthropic.js +2 -3
  27. package/dist/instrumentations/create.js +80 -19
  28. package/dist/instrumentations/gemini.d.ts +1 -1
  29. package/dist/instrumentations/gemini.js +6 -7
  30. package/dist/instrumentations/openai.d.ts +1 -1
  31. package/dist/instrumentations/openai.js +2 -3
  32. package/dist/instrumentations/registry.d.ts +3 -1
  33. package/dist/instrumentations/registry.js +28 -5
  34. package/dist/instrumentations/types.d.ts +4 -2
  35. package/dist/types/attributes.d.ts +20 -0
  36. package/dist/types/attributes.js +23 -1
  37. package/dist/types/config.d.ts +22 -2
  38. package/dist/types/index.d.ts +3 -3
  39. package/dist/types/metrics.d.ts +2 -0
  40. package/dist/types/metrics.js +3 -0
  41. package/dist/types/providers.d.ts +6 -1
  42. package/dist/types/spans.d.ts +21 -2
  43. package/dist/vercel.d.ts +53 -0
  44. package/dist/vercel.js +106 -0
  45. package/package.json +29 -3
  46. package/templates/grafana/dashboards/agent-workflow.json +207 -0
  47. package/templates/otel-collector.yml +25 -1
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { execSync, spawn } from "node:child_process";
2
+ import { execFileSync, spawn } from "node:child_process";
3
3
  import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -12,10 +12,19 @@ const DEMO_TICK_INTERVAL_MS = 2000;
12
12
  const DEMO_TOKEN_INPUT_RANGE = [50, 500];
13
13
  const DEMO_TOKEN_OUTPUT_RANGE = [20, 300];
14
14
  const SERVICES = [
15
- { name: "Grafana", url: "http://localhost:3100", login: "admin / admin" },
16
- { name: "Jaeger UI", url: "http://localhost:16686" },
17
- { name: "Prometheus", url: "http://localhost:9090" },
18
- { name: "OTel Collector", url: "http://localhost:4318" },
15
+ {
16
+ name: "Grafana",
17
+ service: "grafana",
18
+ url: "http://localhost:3100",
19
+ login: "admin / admin",
20
+ },
21
+ { name: "Jaeger UI", service: "jaeger", url: "http://localhost:16686" },
22
+ { name: "Prometheus", service: "prometheus", url: "http://localhost:9090" },
23
+ {
24
+ name: "OTel Collector",
25
+ service: "collector",
26
+ url: "http://localhost:4318",
27
+ },
19
28
  ];
20
29
  function getTemplatesDir() {
21
30
  const thisFile = fileURLToPath(import.meta.url);
@@ -35,8 +44,9 @@ function requireInfra(message = `Run \`npx toad-eye init\` first.`) {
35
44
  }
36
45
  function init() {
37
46
  const dest = join(process.cwd(), INFRA_DIR);
38
- if (existsSync(dest)) {
39
- console.log(`\u2705 ${INFRA_DIR}/ already exists, skipping.`);
47
+ const force = process.argv.includes("--force");
48
+ if (existsSync(dest) && !force) {
49
+ console.log(`✅ ${INFRA_DIR}/ already exists, skipping. Use --force to overwrite.`);
40
50
  return;
41
51
  }
42
52
  const templatesDir = getTemplatesDir();
@@ -44,39 +54,59 @@ function init() {
44
54
  console.error(`\u274c Templates not found at ${templatesDir}. Reinstall toad-eye.`);
45
55
  process.exit(1);
46
56
  }
57
+ const existed = existsSync(dest);
47
58
  mkdirSync(dest, { recursive: true });
48
59
  cpSync(templatesDir, dest, { recursive: true });
49
- console.log(`\u2705 Created ${INFRA_DIR}/ with observability stack config.`);
60
+ const action = force && existed ? "Updated" : "Created";
61
+ console.log(`✅ ${action} ${INFRA_DIR}/ with observability stack config.`);
50
62
  console.log();
51
63
  console.log("Next steps:");
52
64
  console.log(" npx toad-eye up Start the stack");
53
65
  console.log(" npx toad-eye status Check running services");
54
66
  }
67
+ function requireDocker() {
68
+ try {
69
+ execFileSync("docker", ["--version"], { stdio: "ignore" });
70
+ }
71
+ catch {
72
+ console.error("❌ Docker not found. Install Docker Desktop: https://docs.docker.com/get-started/get-docker/");
73
+ process.exit(1);
74
+ }
75
+ }
55
76
  function up() {
77
+ requireDocker();
56
78
  const composeFile = requireInfra();
57
- console.log("\u{1f438} Starting observability stack...");
58
- execSync(`docker compose -f ${composeFile} up -d`, { stdio: "inherit" });
79
+ console.log("🐸 Starting observability stack...");
80
+ console.log(" (first run downloads ~500MB of Docker images — this may take a few minutes)\n");
81
+ execFileSync("docker", ["compose", "-f", composeFile, "up", "-d"], {
82
+ stdio: "inherit",
83
+ });
59
84
  console.log();
60
85
  status();
86
+ console.log(" Run 'npx toad-eye demo' to send test data and verify the stack works.");
61
87
  }
62
88
  function down() {
63
89
  const composeFile = requireInfra("Nothing to stop.");
64
90
  console.log("\u{1f44b} Stopping observability stack...");
65
- execSync(`docker compose -f ${composeFile} down`, { stdio: "inherit" });
91
+ execFileSync("docker", ["compose", "-f", composeFile, "down"], {
92
+ stdio: "inherit",
93
+ });
66
94
  console.log("\u2705 Stack stopped.");
67
95
  }
96
+ /** Find the docker container matching a service by exact service name. Exported for testing. */
97
+ export function findContainerByService(containers, serviceKey) {
98
+ return containers.find((c) => c.Service === serviceKey);
99
+ }
68
100
  function status() {
69
101
  const composeFile = requireInfra();
70
102
  try {
71
- const output = execSync(`docker compose -f ${composeFile} ps --format json`, {
72
- encoding: "utf-8",
73
- });
103
+ const output = execFileSync("docker", ["compose", "-f", composeFile, "ps", "--format", "json"], { encoding: "utf-8" });
74
104
  const lines = output.trim().split("\n").filter(Boolean);
75
105
  const containers = lines.map((line) => JSON.parse(line));
76
106
  console.log("\u{1f438} toad-eye stack:");
77
107
  console.log();
78
108
  for (const svc of SERVICES) {
79
- const container = containers.find((c) => svc.name.toLowerCase().includes(c.Service.toLowerCase()));
109
+ const container = findContainerByService(containers, svc.service);
80
110
  const state = container?.State === "running" ? "\u{1f7e2}" : "\u{1f534}";
81
111
  const loginInfo = "login" in svc ? ` (${svc.login})` : "";
82
112
  console.log(` ${state} ${svc.name.padEnd(16)} ${svc.url}${loginInfo}`);
@@ -121,12 +151,25 @@ async function simulateLLMCall(provider, model, prompt) {
121
151
  }
122
152
  async function demo() {
123
153
  requireInfra();
154
+ // Warn early if OTel Collector is unreachable — data would be silently lost otherwise
155
+ try {
156
+ await fetch("http://localhost:4318/v1/traces", {
157
+ method: "POST",
158
+ body: "[]",
159
+ signal: AbortSignal.timeout(2000),
160
+ });
161
+ }
162
+ catch {
163
+ console.warn("⚠️ OTel Collector not reachable at localhost:4318 — data will not be recorded.\n Run 'npx toad-eye up' first, then retry.\n");
164
+ }
124
165
  initObservability({
125
166
  serviceName: "toad-eye-demo",
126
167
  endpoint: "http://localhost:4318",
127
168
  });
128
- console.log("\u{1f438}\u{1f441}\u{fe0f} toad-eye demo — sending mock LLM traffic");
169
+ console.log("🐸👁️ toad-eye demo — sending mock LLM traffic");
129
170
  console.log(" Press Ctrl+C to stop\n");
171
+ console.log("✅ Stack verified! Add this to your app:\n");
172
+ console.log(" import { initObservability } from 'toad-eye';\n initObservability({ serviceName: 'my-app', instrument: ['openai'] });\n");
130
173
  process.on("SIGINT", async () => {
131
174
  console.log("\n\u{1f44b} Shutting down...");
132
175
  await shutdown();
@@ -186,47 +229,56 @@ async function exportTraceCommand() {
186
229
  }
187
230
  }
188
231
  function help() {
232
+ const cmd = (name) => name.padEnd(15);
189
233
  console.log(`
190
- \u{1f438} toad-eye CLI — observability stack for LLM services
234
+ 🐸 toad-eye CLI — observability stack for LLM services
191
235
 
192
236
  Commands:
193
- init Copy observability configs into your project
194
- up Start the stack (OTel Collector + Prometheus + Jaeger + Grafana)
195
- down Stop the stack
196
- status Show running services and URLs
197
- demo Send mock LLM traffic to see data in Grafana
198
- export-trace Export a Jaeger trace to toad-eval YAML
199
- help Show this message
237
+ ${cmd("init")}Copy observability configs into your project
238
+ ${cmd("init --force")}Overwrite existing configs with latest templates
239
+ ${cmd("up")}Start the stack (OTel Collector + Prometheus + Jaeger + Grafana)
240
+ ${cmd("down")}Stop the stack
241
+ ${cmd("status")}Show running services and URLs
242
+ ${cmd("demo")}Send mock LLM traffic to see data in Grafana
243
+ ${cmd("export-trace")}Export a Jaeger trace to toad-eval YAML
244
+ ${cmd("help")}Show this message
200
245
  `);
201
246
  }
202
- const command = process.argv[2];
203
- switch (command) {
204
- case "init":
205
- init();
206
- break;
207
- case "up":
208
- up();
209
- break;
210
- case "down":
211
- down();
212
- break;
213
- case "status":
214
- status();
215
- break;
216
- case "demo":
217
- demo();
218
- break;
219
- case "export-trace":
220
- exportTraceCommand();
221
- break;
222
- case "help":
223
- case "--help":
224
- case "-h":
225
- case undefined:
226
- help();
227
- break;
228
- default:
229
- console.error(`Unknown command: ${command}`);
230
- help();
231
- process.exit(1);
247
+ function runCli() {
248
+ const command = process.argv[2];
249
+ switch (command) {
250
+ case "init":
251
+ init();
252
+ break;
253
+ case "up":
254
+ up();
255
+ break;
256
+ case "down":
257
+ down();
258
+ break;
259
+ case "status":
260
+ status();
261
+ break;
262
+ case "demo":
263
+ demo();
264
+ break;
265
+ case "export-trace":
266
+ exportTraceCommand();
267
+ break;
268
+ case "help":
269
+ case "--help":
270
+ case "-h":
271
+ case undefined:
272
+ help();
273
+ break;
274
+ default:
275
+ console.error(`Unknown command: ${command}`);
276
+ help();
277
+ process.exit(1);
278
+ }
279
+ }
280
+ // Only auto-execute when run as CLI entry point, not when imported (e.g., in tests)
281
+ if (process.argv[1] !== undefined &&
282
+ fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
283
+ runCli();
232
284
  }
@@ -1,4 +1,6 @@
1
1
  export declare function initMetrics(): void;
2
+ /** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
3
+ export declare function resetMetrics(): void;
2
4
  export declare function recordRequestDuration(ms: number, provider: string, model: string, attrs?: Record<string, string>): void;
3
5
  export declare function recordRequestCost(usd: number, provider: string, model: string, attrs?: Record<string, string>): void;
4
6
  export declare function recordTokens(count: number, provider: string, model: string, attrs?: Record<string, string>): void;
@@ -13,3 +15,5 @@ export declare function recordTimeToFirstToken(ms: number, provider: string, mod
13
15
  export declare function recordBudgetExceeded(budgetType: string): void;
14
16
  export declare function recordBudgetBlocked(budgetType: string): void;
15
17
  export declare function recordBudgetDowngraded(budgetType: string): void;
18
+ export declare function recordResponseEmpty(provider: string, model: string, attrs?: Record<string, string>): void;
19
+ export declare function recordResponseLatencyPerToken(msPerToken: number, provider: string, model: string, attrs?: Record<string, string>): void;
@@ -14,6 +14,8 @@ let timeToFirstToken;
14
14
  let budgetExceeded;
15
15
  let budgetBlocked;
16
16
  let budgetDowngraded;
17
+ let responseEmpty;
18
+ let responseLatencyPerToken;
17
19
  let initialized = false;
18
20
  export function initMetrics() {
19
21
  if (initialized)
@@ -64,8 +66,19 @@ export function initMetrics() {
64
66
  budgetDowngraded = meter.createCounter(GEN_AI_METRICS.BUDGET_DOWNGRADED, {
65
67
  description: "Number of LLM calls downgraded due to budget limits",
66
68
  });
69
+ responseEmpty = meter.createCounter(GEN_AI_METRICS.RESPONSE_EMPTY, {
70
+ description: "Number of LLM responses with empty or whitespace-only completion",
71
+ });
72
+ responseLatencyPerToken = meter.createHistogram(GEN_AI_METRICS.RESPONSE_LATENCY_PER_TOKEN, {
73
+ description: "Generation latency normalized per output token (ms/token). Higher values indicate slower generation speed.",
74
+ unit: "ms",
75
+ });
67
76
  initialized = true;
68
77
  }
78
+ /** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
79
+ export function resetMetrics() {
80
+ initialized = false;
81
+ }
69
82
  /** Build metric labels: provider + model + optional FinOps attributes. */
70
83
  function baseLabels(provider, model, attrs) {
71
84
  return {
@@ -94,7 +107,7 @@ export function recordAgentSteps(stepCount) {
94
107
  }
95
108
  export function recordAgentToolUsage(toolName) {
96
109
  agentToolUsage.add(1, {
97
- [GEN_AI_ATTRS.AGENT_TOOL_NAME]: toolName,
110
+ [GEN_AI_ATTRS.TOOL_NAME]: toolName,
98
111
  });
99
112
  }
100
113
  export function recordGuardEvaluation(ruleName) {
@@ -128,3 +141,9 @@ export function recordBudgetBlocked(budgetType) {
128
141
  export function recordBudgetDowngraded(budgetType) {
129
142
  budgetDowngraded.add(1, { budget_type: budgetType });
130
143
  }
144
+ export function recordResponseEmpty(provider, model, attrs) {
145
+ responseEmpty.add(1, baseLabels(provider, model, attrs));
146
+ }
147
+ export function recordResponseLatencyPerToken(msPerToken, provider, model, attrs) {
148
+ responseLatencyPerToken.record(msPerToken, baseLabels(provider, model, attrs));
149
+ }
@@ -12,6 +12,8 @@ export interface ModelPricing {
12
12
  * Useful for enterprise contracts or custom/fine-tuned models.
13
13
  */
14
14
  export declare function setCustomPricing(pricing: Record<string, ModelPricing>): void;
15
+ /** Reset custom pricing to empty. Called from shutdown() to avoid stale state across re-inits. */
16
+ export declare function resetCustomPricing(): void;
15
17
  /**
16
18
  * Get pricing for a model. Custom pricing takes precedence.
17
19
  * Returns undefined if model is not in any pricing table.
@@ -31,6 +31,10 @@ let customPricing = {};
31
31
  export function setCustomPricing(pricing) {
32
32
  customPricing = { ...pricing };
33
33
  }
34
+ /** Reset custom pricing to empty. Called from shutdown() to avoid stale state across re-inits. */
35
+ export function resetCustomPricing() {
36
+ customPricing = {};
37
+ }
34
38
  /**
35
39
  * Get pricing for a model. Custom pricing takes precedence.
36
40
  * Returns undefined if model is not in any pricing table.
@@ -1,7 +1,7 @@
1
- import type { LLMSpanAttributes } from "../types/index.js";
1
+ import type { LLMProvider } from "../types/index.js";
2
2
  /** Input for traceLLMCall — what the user knows before calling the LLM */
3
3
  export interface LLMCallInput {
4
- readonly provider: LLMSpanAttributes["provider"];
4
+ readonly provider: LLMProvider;
5
5
  readonly model: string;
6
6
  readonly prompt: string;
7
7
  readonly temperature?: number | undefined;
@@ -13,6 +13,8 @@ export interface LLMCallOutput {
13
13
  readonly completion: string;
14
14
  readonly inputTokens: number;
15
15
  readonly outputTokens: number;
16
- readonly cost: number;
16
+ /** Cost in USD. If omitted, auto-calculated from model pricing table. */
17
+ readonly cost?: number | undefined;
17
18
  }
19
+ export declare function processContent(text: string): string | undefined;
18
20
  export declare function traceLLMCall(input: LLMCallInput, fn: () => Promise<LLMCallOutput>): Promise<LLMCallOutput>;
@@ -1,32 +1,73 @@
1
1
  import { createHash } from "node:crypto";
2
- import { trace, diag, SpanStatusCode } from "@opentelemetry/api";
2
+ import { trace, SpanStatusCode } from "@opentelemetry/api";
3
3
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
4
- import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, } from "./metrics.js";
4
+ import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, } from "./metrics.js";
5
5
  import { getConfig, getBudgetTracker } from "./tracer.js";
6
- import { GEN_AI_ATTRS as ATTRS } from "../types/index.js";
6
+ import { calculateCost } from "./pricing.js";
7
+ import { ToadBudgetExceededError } from "../budget/index.js";
7
8
  const tracer = trace.getTracer(INSTRUMENTATION_NAME);
8
9
  let saltWarningEmitted = false;
10
+ // Track the last config ref so saltWarningEmitted resets across re-inits without circular imports
11
+ let lastConfigRef = null;
9
12
  function sha256(text) {
10
13
  const config = getConfig();
11
14
  const salt = config?.salt ?? "";
15
+ // Auto-reset saltWarningEmitted when SDK is re-initialized (config ref changes)
16
+ if (config !== lastConfigRef) {
17
+ lastConfigRef = config;
18
+ saltWarningEmitted = false;
19
+ }
12
20
  if (config?.hashContent && !config.salt && !saltWarningEmitted) {
13
- diag.warn("toad-eye: hashContent is enabled without salt — short strings may be reversible. Set salt in config for stronger privacy.");
21
+ console.warn("toad-eye: hashContent is enabled without salt — short strings may be reversible. Set salt in config for stronger privacy.");
14
22
  saltWarningEmitted = true;
15
23
  }
16
24
  return createHash("sha256")
17
25
  .update(salt + text)
18
26
  .digest("hex");
19
27
  }
20
- function processContent(text) {
28
+ // Built-in PII patterns — enabled via redactDefaults: true
29
+ // Note: no `g` flag here — a fresh RegExp is created per call to avoid lastIndex state issues
30
+ const DEFAULT_REDACT_PATTERNS = [
31
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, // email
32
+ /\b\d{3}-\d{2}-\d{4}\b/, // SSN (US)
33
+ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // credit card
34
+ /\b\+?\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}\b/, // phone
35
+ ];
36
+ function applyRedaction(text, pattern) {
37
+ let count = 0;
38
+ const regex = new RegExp(pattern.source, "g");
39
+ const result = text.replace(regex, () => {
40
+ count++;
41
+ return "[REDACTED]";
42
+ });
43
+ return [result, count];
44
+ }
45
+ export function processContent(text) {
21
46
  const config = getConfig();
22
47
  if (config?.recordContent === false)
23
48
  return undefined;
24
49
  let processed = text;
50
+ let totalRedacted = 0;
51
+ // Apply default PII patterns if enabled
52
+ if (config?.redactDefaults) {
53
+ for (const pattern of DEFAULT_REDACT_PATTERNS) {
54
+ const [result, count] = applyRedaction(processed, pattern);
55
+ processed = result;
56
+ totalRedacted += count;
57
+ }
58
+ }
59
+ // Apply custom patterns
25
60
  if (config?.redactPatterns?.length) {
26
61
  for (const pattern of config.redactPatterns) {
27
- processed = processed.replace(pattern, "[REDACTED]");
62
+ const [result, count] = applyRedaction(processed, pattern);
63
+ processed = result;
64
+ totalRedacted += count;
28
65
  }
29
66
  }
67
+ // Audit mode — log a summary only, never the original content (would re-expose PII)
68
+ if (config?.auditMasking && totalRedacted > 0) {
69
+ console.log(`[toad-eye audit] Content masked: ${totalRedacted} pattern(s) applied, ${text.length} chars → ${processed.length} chars`);
70
+ }
30
71
  if (config?.hashContent) {
31
72
  return `sha256:${sha256(processed)}`;
32
73
  }
@@ -62,14 +103,14 @@ function recordBaseMetrics(duration, provider, model, attrs) {
62
103
  recordRequest(provider, model, attrs);
63
104
  recordRequestDuration(duration, provider, model, attrs);
64
105
  }
65
- function setSuccessAttributes(span, input, output) {
106
+ function setSuccessAttributes(span, input, output, resolvedCost) {
66
107
  const completion = processContent(output.completion);
67
108
  span.setAttributes({
68
109
  ...(completion !== undefined && { [GEN_AI_ATTRS.COMPLETION]: completion }),
69
110
  [GEN_AI_ATTRS.RESPONSE_MODEL]: input.model,
70
111
  [GEN_AI_ATTRS.INPUT_TOKENS]: output.inputTokens,
71
112
  [GEN_AI_ATTRS.OUTPUT_TOKENS]: output.outputTokens,
72
- [GEN_AI_ATTRS.COST]: output.cost,
113
+ [GEN_AI_ATTRS.COST]: resolvedCost,
73
114
  [GEN_AI_ATTRS.STATUS]: "success",
74
115
  [GEN_AI_ATTRS.FINISH_REASONS]: ["stop"],
75
116
  });
@@ -90,12 +131,18 @@ function setErrorAttributes(span, message) {
90
131
  });
91
132
  }
92
133
  export async function traceLLMCall(input, fn) {
134
+ if (!getConfig()) {
135
+ console.warn("toad-eye: traceLLMCall called before initObservability() — no telemetry will be recorded.");
136
+ }
93
137
  const budget = getBudgetTracker();
94
138
  let effectiveInput = input;
139
+ // Estimated cost for reservation — reduces race window for concurrent requests.
140
+ // Uses conservative token estimates; actual cost is reconciled in recordCost.
141
+ const estimatedCost = budget ? calculateCost(input.model, 500, 200) : 0;
95
142
  // Budget check BEFORE the LLM call
96
143
  if (budget) {
97
- const userId = input.attributes?.[ATTRS.USER_ID];
98
- const override = budget.checkBefore(input.provider, input.model, userId);
144
+ const userId = input.attributes?.[GEN_AI_ATTRS.USER_ID];
145
+ const override = budget.checkBefore(input.provider, input.model, userId, estimatedCost);
99
146
  if (override) {
100
147
  // Downgrade mode — use modified provider/model
101
148
  effectiveInput = {
@@ -103,24 +150,33 @@ export async function traceLLMCall(input, fn) {
103
150
  provider: override.provider,
104
151
  model: override.model,
105
152
  };
106
- recordBudgetDowngraded("daily");
153
+ recordBudgetDowngraded(override.budget);
107
154
  }
108
155
  }
109
- return tracer.startActiveSpan(`gen_ai.${effectiveInput.provider}.${effectiveInput.model}`, async (span) => {
156
+ return tracer.startActiveSpan(`chat ${effectiveInput.model}`, async (span) => {
110
157
  const start = performance.now();
111
158
  setBaseAttributes(span, effectiveInput);
112
159
  try {
113
160
  const output = await fn();
114
161
  const duration = performance.now() - start;
115
162
  const attrs = resolveAttributes(effectiveInput);
116
- setSuccessAttributes(span, effectiveInput, output);
163
+ const resolvedCost = output.cost ??
164
+ calculateCost(effectiveInput.model, output.inputTokens, output.outputTokens);
165
+ setSuccessAttributes(span, effectiveInput, output, resolvedCost);
117
166
  recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
118
- recordRequestCost(output.cost, effectiveInput.provider, effectiveInput.model, attrs);
167
+ recordRequestCost(resolvedCost, effectiveInput.provider, effectiveInput.model, attrs);
119
168
  recordTokens(output.inputTokens + output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
120
- // Budget recording AFTER the call
169
+ // Proxy quality metrics — no extra dependencies, response text analysis only
170
+ if (output.completion.trim() === "") {
171
+ recordResponseEmpty(effectiveInput.provider, effectiveInput.model, attrs);
172
+ }
173
+ if (output.outputTokens > 0) {
174
+ recordResponseLatencyPerToken(duration / output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
175
+ }
176
+ // Budget recording AFTER the call — releases the cost reservation
121
177
  if (budget) {
122
- const userId = effectiveInput.attributes?.[ATTRS.USER_ID];
123
- const exceeded = budget.recordCost(output.cost, effectiveInput.model, userId);
178
+ const userId = effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID];
179
+ const exceeded = budget.recordCost(resolvedCost, effectiveInput.model, userId, estimatedCost);
124
180
  if (exceeded) {
125
181
  recordBudgetExceeded(exceeded.budget);
126
182
  console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
@@ -135,10 +191,13 @@ export async function traceLLMCall(input, fn) {
135
191
  setErrorAttributes(span, message);
136
192
  recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
137
193
  recordError(effectiveInput.provider, effectiveInput.model, attrs);
138
- // If this was a budget block, record the metric
139
- if (error instanceof Error &&
140
- error.name === "ToadBudgetExceededError") {
141
- recordBudgetBlocked("daily");
194
+ // Release cost reservation on any error (no cost was incurred)
195
+ if (budget) {
196
+ budget.releaseReservation(estimatedCost);
197
+ }
198
+ // If this was a budget block, record the metric with the correct budget type
199
+ if (error instanceof ToadBudgetExceededError) {
200
+ recordBudgetBlocked(error.budget);
142
201
  }
143
202
  throw error;
144
203
  }
@@ -1,9 +1,12 @@
1
1
  import type { ToadEyeConfig } from "../types/index.js";
2
2
  import { BudgetTracker } from "../budget/index.js";
3
- import "../instrumentations/openai.js";
4
- import "../instrumentations/anthropic.js";
5
- import "../instrumentations/gemini.js";
6
3
  export declare function getConfig(): ToadEyeConfig | null;
4
+ /**
5
+ * Check if deprecated semconv aliases should be emitted.
6
+ * When OTEL_SEMCONV_STABILITY_OPT_IN includes "gen_ai_latest_experimental",
7
+ * only new (canonical) attributes are emitted — deprecated aliases are skipped.
8
+ */
9
+ export declare function shouldEmitDeprecatedAttrs(): boolean;
7
10
  export declare function getBudgetTracker(): BudgetTracker | null;
8
11
  export declare function initObservability(config: ToadEyeConfig): void;
9
12
  export declare function shutdown(): Promise<void>;
@@ -3,14 +3,13 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
3
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
4
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
5
5
  import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
6
+ import { TraceIdRatioBasedSampler, ParentBasedSampler, } from "@opentelemetry/sdk-trace-node";
6
7
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
7
- import { initMetrics } from "./metrics.js";
8
+ import { initMetrics, resetMetrics } from "./metrics.js";
9
+ import { resetCustomPricing } from "./pricing.js";
8
10
  import { enableAll, disableAll } from "../instrumentations/registry.js";
9
11
  import { BudgetTracker } from "../budget/index.js";
10
- // Side-effect imports: register provider instrumentations
11
- import "../instrumentations/openai.js";
12
- import "../instrumentations/anthropic.js";
13
- import "../instrumentations/gemini.js";
12
+ import { ToadEyeAISpanProcessor } from "../vercel.js";
14
13
  const DEFAULT_ENDPOINT = "http://localhost:4318";
15
14
  const DEFAULT_CLOUD_ENDPOINT = "https://cloud.toad-eye.dev";
16
15
  let sdk = null;
@@ -19,6 +18,15 @@ let budgetTracker = null;
19
18
  export function getConfig() {
20
19
  return currentConfig;
21
20
  }
21
+ /**
22
+ * Check if deprecated semconv aliases should be emitted.
23
+ * When OTEL_SEMCONV_STABILITY_OPT_IN includes "gen_ai_latest_experimental",
24
+ * only new (canonical) attributes are emitted — deprecated aliases are skipped.
25
+ */
26
+ export function shouldEmitDeprecatedAttrs() {
27
+ const optIn = process.env["OTEL_SEMCONV_STABILITY_OPT_IN"] ?? "";
28
+ return !optIn.split(",").includes("gen_ai_latest_experimental");
29
+ }
22
30
  export function getBudgetTracker() {
23
31
  return budgetTracker;
24
32
  }
@@ -45,8 +53,10 @@ function resolveTransport(config) {
45
53
  return { endpoint, headers, isCloudMode };
46
54
  }
47
55
  export function initObservability(config) {
48
- if (sdk)
56
+ if (sdk) {
57
+ console.warn("toad-eye: initObservability() already called. Call shutdown() first to reconfigure.");
49
58
  return;
59
+ }
50
60
  validateConfig(config);
51
61
  const { endpoint, headers, isCloudMode } = resolveTransport(config);
52
62
  const resource = resourceFromAttributes({
@@ -64,14 +74,38 @@ export function initObservability(config) {
64
74
  exporter: metricExporter,
65
75
  exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
66
76
  });
77
+ // Add Vercel AI SDK SpanProcessor if 'ai' is in instrument list
78
+ const spanProcessors = config.instrument?.includes("ai")
79
+ ? [new ToadEyeAISpanProcessor()]
80
+ : [];
81
+ // SDK-side head sampling (default: 1.0 = send everything to Collector)
82
+ const sdkRate = config.sampling?.sdkRate ?? 1.0;
83
+ const sampler = sdkRate < 1.0
84
+ ? new ParentBasedSampler({
85
+ root: new TraceIdRatioBasedSampler(sdkRate),
86
+ })
87
+ : undefined;
67
88
  sdk = new NodeSDK({
68
89
  resource,
69
90
  traceExporter,
70
91
  metricReader,
92
+ spanProcessors,
93
+ ...(sampler !== undefined && { sampler }),
71
94
  });
72
95
  sdk.start();
73
96
  currentConfig = config;
74
97
  initMetrics();
98
+ // Non-blocking connectivity check — warns the user if the OTel Collector is unreachable.
99
+ // Fire-and-forget: does not block initObservability(). Cloud mode skips this check.
100
+ if (!isCloudMode) {
101
+ void fetch(`${endpoint}/v1/traces`, {
102
+ method: "POST",
103
+ body: "[]",
104
+ signal: AbortSignal.timeout(2000),
105
+ }).catch(() => {
106
+ console.warn(`toad-eye: cannot reach OTel Collector at ${endpoint} — no telemetry will be exported. Is the stack running? Run: npx toad-eye up`);
107
+ });
108
+ }
75
109
  if (isCloudMode) {
76
110
  const masked = config.apiKey
77
111
  ? config.apiKey.slice(0, 8) + "..." + config.apiKey.slice(-4)
@@ -82,7 +116,13 @@ export function initObservability(config) {
82
116
  budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
83
117
  }
84
118
  if (config.instrument?.length) {
85
- enableAll(config.instrument);
119
+ // Filter out 'ai' — it uses SpanProcessor, not monkey-patching
120
+ const patchProviders = config.instrument.filter((i) => i !== "ai");
121
+ if (patchProviders.length > 0) {
122
+ // enableAll is async (lazy-loads provider modules on first call).
123
+ // Fire-and-forget: patching completes before any async SDK call.
124
+ void enableAll(patchProviders);
125
+ }
86
126
  }
87
127
  }
88
128
  export async function shutdown() {
@@ -93,4 +133,6 @@ export async function shutdown() {
93
133
  sdk = null;
94
134
  currentConfig = null;
95
135
  budgetTracker = null;
136
+ resetMetrics();
137
+ resetCustomPricing();
96
138
  }
@@ -12,5 +12,11 @@ export function loadBaseline(filepath) {
12
12
  if (!existsSync(filepath))
13
13
  return undefined;
14
14
  const raw = readFileSync(filepath, "utf-8");
15
- return JSON.parse(raw);
15
+ const parsed = JSON.parse(raw);
16
+ if (typeof parsed !== "object" ||
17
+ parsed === null ||
18
+ !Array.isArray(parsed["embeddings"])) {
19
+ throw new Error(`toad-eye: baseline file at "${filepath}" is malformed — missing "embeddings" array`);
20
+ }
21
+ return parsed;
16
22
  }