toad-eye 2.1.1 → 2.3.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 +166 -152
- package/dist/agent.d.ts +19 -28
- package/dist/agent.js +46 -27
- package/dist/alerts/channels.js +24 -6
- package/dist/alerts/index.js +46 -2
- package/dist/alerts/manager.d.ts +1 -0
- package/dist/alerts/manager.js +36 -21
- package/dist/budget/tracker.d.ts +16 -6
- package/dist/budget/tracker.js +38 -17
- package/dist/budget/types.d.ts +2 -0
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +106 -54
- package/dist/core/metrics.d.ts +5 -0
- package/dist/core/metrics.js +30 -0
- package/dist/core/pricing.d.ts +2 -0
- package/dist/core/pricing.js +6 -0
- package/dist/core/spans.d.ts +5 -3
- package/dist/core/spans.js +84 -19
- package/dist/core/tracer.d.ts +0 -3
- package/dist/core/tracer.js +44 -8
- package/dist/drift/baseline.js +7 -1
- package/dist/drift/monitor.js +6 -2
- package/dist/drift/openai-embeddings.js +5 -1
- package/dist/export.js +1 -1
- package/dist/guard.d.ts +6 -8
- package/dist/guard.js +19 -21
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/instrumentations/anthropic.d.ts +1 -1
- package/dist/instrumentations/anthropic.js +12 -20
- package/dist/instrumentations/create.d.ts +0 -4
- package/dist/instrumentations/create.js +136 -44
- package/dist/instrumentations/gemini.d.ts +1 -1
- package/dist/instrumentations/gemini.js +39 -7
- package/dist/instrumentations/openai.d.ts +1 -1
- package/dist/instrumentations/openai.js +10 -19
- package/dist/instrumentations/registry.d.ts +3 -1
- package/dist/instrumentations/registry.js +28 -5
- package/dist/instrumentations/types.d.ts +12 -8
- package/dist/types/attributes.d.ts +3 -0
- package/dist/types/attributes.js +3 -0
- package/dist/types/config.d.ts +22 -2
- package/dist/types/index.d.ts +3 -3
- package/dist/types/metrics.d.ts +3 -0
- package/dist/types/metrics.js +5 -0
- package/dist/types/providers.d.ts +6 -1
- package/dist/types/spans.d.ts +11 -2
- package/dist/vercel.d.ts +53 -0
- package/dist/vercel.js +106 -0
- package/package.json +29 -3
- package/templates/grafana/dashboards/agent-workflow.json +207 -0
- package/templates/grafana/dashboards/provider-health.json +191 -0
- package/templates/otel-collector.yml +25 -1
package/dist/core/metrics.js
CHANGED
|
@@ -10,9 +10,12 @@ let agentToolUsage;
|
|
|
10
10
|
let guardEvaluations;
|
|
11
11
|
let guardWouldBlock;
|
|
12
12
|
let semanticDrift;
|
|
13
|
+
let timeToFirstToken;
|
|
13
14
|
let budgetExceeded;
|
|
14
15
|
let budgetBlocked;
|
|
15
16
|
let budgetDowngraded;
|
|
17
|
+
let responseEmpty;
|
|
18
|
+
let responseLatencyPerToken;
|
|
16
19
|
let initialized = false;
|
|
17
20
|
export function initMetrics() {
|
|
18
21
|
if (initialized)
|
|
@@ -50,6 +53,10 @@ export function initMetrics() {
|
|
|
50
53
|
semanticDrift = meter.createHistogram(GEN_AI_METRICS.SEMANTIC_DRIFT, {
|
|
51
54
|
description: "Semantic drift from baseline (0 = identical, 1 = completely different)",
|
|
52
55
|
});
|
|
56
|
+
timeToFirstToken = meter.createHistogram(GEN_AI_METRICS.TIME_TO_FIRST_TOKEN, {
|
|
57
|
+
description: "Time from request start to first streaming token (ms)",
|
|
58
|
+
unit: "ms",
|
|
59
|
+
});
|
|
53
60
|
budgetExceeded = meter.createCounter(GEN_AI_METRICS.BUDGET_EXCEEDED, {
|
|
54
61
|
description: "Number of times a budget limit was exceeded",
|
|
55
62
|
});
|
|
@@ -59,8 +66,19 @@ export function initMetrics() {
|
|
|
59
66
|
budgetDowngraded = meter.createCounter(GEN_AI_METRICS.BUDGET_DOWNGRADED, {
|
|
60
67
|
description: "Number of LLM calls downgraded due to budget limits",
|
|
61
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
|
+
});
|
|
62
76
|
initialized = true;
|
|
63
77
|
}
|
|
78
|
+
/** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
|
|
79
|
+
export function resetMetrics() {
|
|
80
|
+
initialized = false;
|
|
81
|
+
}
|
|
64
82
|
/** Build metric labels: provider + model + optional FinOps attributes. */
|
|
65
83
|
function baseLabels(provider, model, attrs) {
|
|
66
84
|
return {
|
|
@@ -108,6 +126,12 @@ export function recordSemanticDrift(drift, provider, model) {
|
|
|
108
126
|
[GEN_AI_ATTRS.REQUEST_MODEL]: model,
|
|
109
127
|
});
|
|
110
128
|
}
|
|
129
|
+
export function recordTimeToFirstToken(ms, provider, model) {
|
|
130
|
+
timeToFirstToken.record(ms, {
|
|
131
|
+
[GEN_AI_ATTRS.PROVIDER]: provider,
|
|
132
|
+
[GEN_AI_ATTRS.REQUEST_MODEL]: model,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
111
135
|
export function recordBudgetExceeded(budgetType) {
|
|
112
136
|
budgetExceeded.add(1, { budget_type: budgetType });
|
|
113
137
|
}
|
|
@@ -117,3 +141,9 @@ export function recordBudgetBlocked(budgetType) {
|
|
|
117
141
|
export function recordBudgetDowngraded(budgetType) {
|
|
118
142
|
budgetDowngraded.add(1, { budget_type: budgetType });
|
|
119
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
|
+
}
|
package/dist/core/pricing.d.ts
CHANGED
|
@@ -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.
|
package/dist/core/pricing.js
CHANGED
|
@@ -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.
|
|
@@ -43,6 +47,8 @@ export function getModelPricing(model) {
|
|
|
43
47
|
* Returns 0 if model pricing is not found.
|
|
44
48
|
*/
|
|
45
49
|
export function calculateCost(model, inputTokens, outputTokens) {
|
|
50
|
+
if (inputTokens < 0 || outputTokens < 0)
|
|
51
|
+
return 0;
|
|
46
52
|
const pricing = getModelPricing(model);
|
|
47
53
|
if (!pricing)
|
|
48
54
|
return 0;
|
package/dist/core/spans.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
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
|
-
|
|
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>;
|
package/dist/core/spans.js
CHANGED
|
@@ -1,26 +1,73 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
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 {
|
|
6
|
+
import { calculateCost } from "./pricing.js";
|
|
7
|
+
import { ToadBudgetExceededError } from "../budget/index.js";
|
|
7
8
|
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
9
|
+
let saltWarningEmitted = false;
|
|
10
|
+
// Track the last config ref so saltWarningEmitted resets across re-inits without circular imports
|
|
11
|
+
let lastConfigRef = null;
|
|
8
12
|
function sha256(text) {
|
|
9
|
-
const
|
|
13
|
+
const config = getConfig();
|
|
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
|
+
}
|
|
20
|
+
if (config?.hashContent && !config.salt && !saltWarningEmitted) {
|
|
21
|
+
console.warn("toad-eye: hashContent is enabled without salt — short strings may be reversible. Set salt in config for stronger privacy.");
|
|
22
|
+
saltWarningEmitted = true;
|
|
23
|
+
}
|
|
10
24
|
return createHash("sha256")
|
|
11
25
|
.update(salt + text)
|
|
12
26
|
.digest("hex");
|
|
13
27
|
}
|
|
14
|
-
|
|
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) {
|
|
15
46
|
const config = getConfig();
|
|
16
47
|
if (config?.recordContent === false)
|
|
17
48
|
return undefined;
|
|
18
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
|
|
19
60
|
if (config?.redactPatterns?.length) {
|
|
20
61
|
for (const pattern of config.redactPatterns) {
|
|
21
|
-
|
|
62
|
+
const [result, count] = applyRedaction(processed, pattern);
|
|
63
|
+
processed = result;
|
|
64
|
+
totalRedacted += count;
|
|
22
65
|
}
|
|
23
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
|
+
}
|
|
24
71
|
if (config?.hashContent) {
|
|
25
72
|
return `sha256:${sha256(processed)}`;
|
|
26
73
|
}
|
|
@@ -56,14 +103,14 @@ function recordBaseMetrics(duration, provider, model, attrs) {
|
|
|
56
103
|
recordRequest(provider, model, attrs);
|
|
57
104
|
recordRequestDuration(duration, provider, model, attrs);
|
|
58
105
|
}
|
|
59
|
-
function setSuccessAttributes(span, input, output) {
|
|
106
|
+
function setSuccessAttributes(span, input, output, resolvedCost) {
|
|
60
107
|
const completion = processContent(output.completion);
|
|
61
108
|
span.setAttributes({
|
|
62
109
|
...(completion !== undefined && { [GEN_AI_ATTRS.COMPLETION]: completion }),
|
|
63
110
|
[GEN_AI_ATTRS.RESPONSE_MODEL]: input.model,
|
|
64
111
|
[GEN_AI_ATTRS.INPUT_TOKENS]: output.inputTokens,
|
|
65
112
|
[GEN_AI_ATTRS.OUTPUT_TOKENS]: output.outputTokens,
|
|
66
|
-
[GEN_AI_ATTRS.COST]:
|
|
113
|
+
[GEN_AI_ATTRS.COST]: resolvedCost,
|
|
67
114
|
[GEN_AI_ATTRS.STATUS]: "success",
|
|
68
115
|
[GEN_AI_ATTRS.FINISH_REASONS]: ["stop"],
|
|
69
116
|
});
|
|
@@ -84,12 +131,18 @@ function setErrorAttributes(span, message) {
|
|
|
84
131
|
});
|
|
85
132
|
}
|
|
86
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
|
+
}
|
|
87
137
|
const budget = getBudgetTracker();
|
|
88
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;
|
|
89
142
|
// Budget check BEFORE the LLM call
|
|
90
143
|
if (budget) {
|
|
91
|
-
const userId = input.attributes?.[
|
|
92
|
-
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);
|
|
93
146
|
if (override) {
|
|
94
147
|
// Downgrade mode — use modified provider/model
|
|
95
148
|
effectiveInput = {
|
|
@@ -97,7 +150,7 @@ export async function traceLLMCall(input, fn) {
|
|
|
97
150
|
provider: override.provider,
|
|
98
151
|
model: override.model,
|
|
99
152
|
};
|
|
100
|
-
recordBudgetDowngraded(
|
|
153
|
+
recordBudgetDowngraded(override.budget);
|
|
101
154
|
}
|
|
102
155
|
}
|
|
103
156
|
return tracer.startActiveSpan(`gen_ai.${effectiveInput.provider}.${effectiveInput.model}`, async (span) => {
|
|
@@ -107,14 +160,23 @@ export async function traceLLMCall(input, fn) {
|
|
|
107
160
|
const output = await fn();
|
|
108
161
|
const duration = performance.now() - start;
|
|
109
162
|
const attrs = resolveAttributes(effectiveInput);
|
|
110
|
-
|
|
163
|
+
const resolvedCost = output.cost ??
|
|
164
|
+
calculateCost(effectiveInput.model, output.inputTokens, output.outputTokens);
|
|
165
|
+
setSuccessAttributes(span, effectiveInput, output, resolvedCost);
|
|
111
166
|
recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
|
|
112
|
-
recordRequestCost(
|
|
167
|
+
recordRequestCost(resolvedCost, effectiveInput.provider, effectiveInput.model, attrs);
|
|
113
168
|
recordTokens(output.inputTokens + output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
|
|
114
|
-
//
|
|
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
|
|
115
177
|
if (budget) {
|
|
116
|
-
const userId = effectiveInput.attributes?.[
|
|
117
|
-
const exceeded = budget.recordCost(
|
|
178
|
+
const userId = effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID];
|
|
179
|
+
const exceeded = budget.recordCost(resolvedCost, effectiveInput.model, userId, estimatedCost);
|
|
118
180
|
if (exceeded) {
|
|
119
181
|
recordBudgetExceeded(exceeded.budget);
|
|
120
182
|
console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
|
|
@@ -129,10 +191,13 @@ export async function traceLLMCall(input, fn) {
|
|
|
129
191
|
setErrorAttributes(span, message);
|
|
130
192
|
recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
|
|
131
193
|
recordError(effectiveInput.provider, effectiveInput.model, attrs);
|
|
132
|
-
//
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
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);
|
|
136
201
|
}
|
|
137
202
|
throw error;
|
|
138
203
|
}
|
package/dist/core/tracer.d.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
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;
|
|
7
4
|
export declare function getBudgetTracker(): BudgetTracker | null;
|
|
8
5
|
export declare function initObservability(config: ToadEyeConfig): void;
|
package/dist/core/tracer.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -45,8 +44,10 @@ function resolveTransport(config) {
|
|
|
45
44
|
return { endpoint, headers, isCloudMode };
|
|
46
45
|
}
|
|
47
46
|
export function initObservability(config) {
|
|
48
|
-
if (sdk)
|
|
47
|
+
if (sdk) {
|
|
48
|
+
console.warn("toad-eye: initObservability() already called. Call shutdown() first to reconfigure.");
|
|
49
49
|
return;
|
|
50
|
+
}
|
|
50
51
|
validateConfig(config);
|
|
51
52
|
const { endpoint, headers, isCloudMode } = resolveTransport(config);
|
|
52
53
|
const resource = resourceFromAttributes({
|
|
@@ -64,22 +65,55 @@ export function initObservability(config) {
|
|
|
64
65
|
exporter: metricExporter,
|
|
65
66
|
exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
|
|
66
67
|
});
|
|
68
|
+
// Add Vercel AI SDK SpanProcessor if 'ai' is in instrument list
|
|
69
|
+
const spanProcessors = config.instrument?.includes("ai")
|
|
70
|
+
? [new ToadEyeAISpanProcessor()]
|
|
71
|
+
: [];
|
|
72
|
+
// SDK-side head sampling (default: 1.0 = send everything to Collector)
|
|
73
|
+
const sdkRate = config.sampling?.sdkRate ?? 1.0;
|
|
74
|
+
const sampler = sdkRate < 1.0
|
|
75
|
+
? new ParentBasedSampler({
|
|
76
|
+
root: new TraceIdRatioBasedSampler(sdkRate),
|
|
77
|
+
})
|
|
78
|
+
: undefined;
|
|
67
79
|
sdk = new NodeSDK({
|
|
68
80
|
resource,
|
|
69
81
|
traceExporter,
|
|
70
82
|
metricReader,
|
|
83
|
+
spanProcessors,
|
|
84
|
+
...(sampler !== undefined && { sampler }),
|
|
71
85
|
});
|
|
72
86
|
sdk.start();
|
|
73
87
|
currentConfig = config;
|
|
74
88
|
initMetrics();
|
|
89
|
+
// Non-blocking connectivity check — warns the user if the OTel Collector is unreachable.
|
|
90
|
+
// Fire-and-forget: does not block initObservability(). Cloud mode skips this check.
|
|
91
|
+
if (!isCloudMode) {
|
|
92
|
+
void fetch(`${endpoint}/v1/traces`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: "[]",
|
|
95
|
+
signal: AbortSignal.timeout(2000),
|
|
96
|
+
}).catch(() => {
|
|
97
|
+
console.warn(`toad-eye: cannot reach OTel Collector at ${endpoint} — no telemetry will be exported. Is the stack running? Run: npx toad-eye up`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
75
100
|
if (isCloudMode) {
|
|
76
|
-
|
|
101
|
+
const masked = config.apiKey
|
|
102
|
+
? config.apiKey.slice(0, 8) + "..." + config.apiKey.slice(-4)
|
|
103
|
+
: "";
|
|
104
|
+
console.log(`toad-eye: cloud mode enabled (${masked}) → sending telemetry to ${endpoint}`);
|
|
77
105
|
}
|
|
78
106
|
if (config.budgets) {
|
|
79
107
|
budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
|
|
80
108
|
}
|
|
81
109
|
if (config.instrument?.length) {
|
|
82
|
-
|
|
110
|
+
// Filter out 'ai' — it uses SpanProcessor, not monkey-patching
|
|
111
|
+
const patchProviders = config.instrument.filter((i) => i !== "ai");
|
|
112
|
+
if (patchProviders.length > 0) {
|
|
113
|
+
// enableAll is async (lazy-loads provider modules on first call).
|
|
114
|
+
// Fire-and-forget: patching completes before any async SDK call.
|
|
115
|
+
void enableAll(patchProviders);
|
|
116
|
+
}
|
|
83
117
|
}
|
|
84
118
|
}
|
|
85
119
|
export async function shutdown() {
|
|
@@ -90,4 +124,6 @@ export async function shutdown() {
|
|
|
90
124
|
sdk = null;
|
|
91
125
|
currentConfig = null;
|
|
92
126
|
budgetTracker = null;
|
|
127
|
+
resetMetrics();
|
|
128
|
+
resetCustomPricing();
|
|
93
129
|
}
|
package/dist/drift/baseline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/drift/monitor.js
CHANGED
|
@@ -39,9 +39,13 @@ export function createDriftMonitor(config) {
|
|
|
39
39
|
return {
|
|
40
40
|
check: performCheck,
|
|
41
41
|
checkInBackground(response, providerName, model) {
|
|
42
|
-
|
|
42
|
+
let timer;
|
|
43
|
+
const timeout = new Promise((_, reject) => (timer = setTimeout(() => reject(new Error("Drift check timeout (5s)")), 5000)));
|
|
44
|
+
void Promise.race([performCheck(response, providerName, model), timeout])
|
|
45
|
+
.catch((err) => {
|
|
43
46
|
console.warn(`[toad-eye] Drift check failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
44
|
-
})
|
|
47
|
+
})
|
|
48
|
+
.finally(() => clearTimeout(timer));
|
|
45
49
|
},
|
|
46
50
|
};
|
|
47
51
|
}
|
|
@@ -20,7 +20,11 @@ export function createOpenAIEmbeddingProvider(apiKey, model = DEFAULT_MODEL) {
|
|
|
20
20
|
throw new Error(`OpenAI Embeddings API error: ${response.status} ${response.statusText}`);
|
|
21
21
|
}
|
|
22
22
|
const body = (await response.json());
|
|
23
|
-
|
|
23
|
+
const embedding = body.data?.[0]?.embedding;
|
|
24
|
+
if (!embedding) {
|
|
25
|
+
throw new Error("OpenAI Embeddings API returned no embeddings");
|
|
26
|
+
}
|
|
27
|
+
return embedding;
|
|
24
28
|
},
|
|
25
29
|
};
|
|
26
30
|
}
|
package/dist/export.js
CHANGED
|
@@ -31,7 +31,7 @@ function buildAssertions(completion) {
|
|
|
31
31
|
value: Math.ceil(completion.length * LENGTH_BUFFER_MULTIPLIER),
|
|
32
32
|
});
|
|
33
33
|
if (!completion.toLowerCase().includes(REFUSAL_MARKER)) {
|
|
34
|
-
assertions.push({ type: "not_contains", value:
|
|
34
|
+
assertions.push({ type: "not_contains", value: REFUSAL_MARKER });
|
|
35
35
|
}
|
|
36
36
|
if (isJson(completion)) {
|
|
37
37
|
assertions.push({ type: "is_json", value: true });
|
package/dist/guard.d.ts
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Shadow Guardrails integration — records toad-guard validation results
|
|
3
3
|
* as span attributes and metrics without blocking LLM responses.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* production traffic before switching to enforce mode.
|
|
5
|
+
* Results are recorded as attributes on the currently active span
|
|
6
|
+
* (typically the LLM call span from traceLLMCall). This avoids
|
|
7
|
+
* trace bloat — no extra child spans per guard rule.
|
|
9
8
|
*
|
|
10
9
|
* Usage from toad-guard:
|
|
11
10
|
* ```ts
|
|
@@ -17,10 +16,9 @@
|
|
|
17
16
|
*/
|
|
18
17
|
import type { GuardResult } from "./types/index.js";
|
|
19
18
|
/**
|
|
20
|
-
* Record a guard validation result as
|
|
19
|
+
* Record a guard validation result as attributes on the active span.
|
|
21
20
|
*
|
|
22
|
-
* Attaches guard attributes to
|
|
23
|
-
*
|
|
24
|
-
* useful for measuring how often shadow guardrails would have blocked.
|
|
21
|
+
* Attaches guard attributes directly to the current span (no child span created).
|
|
22
|
+
* Increments guard evaluation counter, and would_block counter if failed.
|
|
25
23
|
*/
|
|
26
24
|
export declare function recordGuardResult(result: GuardResult): void;
|
package/dist/guard.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Shadow Guardrails integration — records toad-guard validation results
|
|
3
3
|
* as span attributes and metrics without blocking LLM responses.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* production traffic before switching to enforce mode.
|
|
5
|
+
* Results are recorded as attributes on the currently active span
|
|
6
|
+
* (typically the LLM call span from traceLLMCall). This avoids
|
|
7
|
+
* trace bloat — no extra child spans per guard rule.
|
|
9
8
|
*
|
|
10
9
|
* Usage from toad-guard:
|
|
11
10
|
* ```ts
|
|
@@ -16,28 +15,27 @@
|
|
|
16
15
|
* ```
|
|
17
16
|
*/
|
|
18
17
|
import { trace } from "@opentelemetry/api";
|
|
19
|
-
import { GEN_AI_ATTRS
|
|
18
|
+
import { GEN_AI_ATTRS } from "./types/index.js";
|
|
20
19
|
import { recordGuardEvaluation, recordGuardWouldBlock, } from "./core/metrics.js";
|
|
21
|
-
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
22
20
|
/**
|
|
23
|
-
* Record a guard validation result as
|
|
21
|
+
* Record a guard validation result as attributes on the active span.
|
|
24
22
|
*
|
|
25
|
-
* Attaches guard attributes to
|
|
26
|
-
*
|
|
27
|
-
* useful for measuring how often shadow guardrails would have blocked.
|
|
23
|
+
* Attaches guard attributes directly to the current span (no child span created).
|
|
24
|
+
* Increments guard evaluation counter, and would_block counter if failed.
|
|
28
25
|
*/
|
|
29
26
|
export function recordGuardResult(result) {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
result.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
const activeSpan = trace.getActiveSpan();
|
|
28
|
+
if (activeSpan) {
|
|
29
|
+
activeSpan.setAttributes({
|
|
30
|
+
[GEN_AI_ATTRS.GUARD_MODE]: result.mode,
|
|
31
|
+
[GEN_AI_ATTRS.GUARD_PASSED]: result.passed,
|
|
32
|
+
[GEN_AI_ATTRS.GUARD_RULE_NAME]: result.ruleName,
|
|
33
|
+
...(!result.passed &&
|
|
34
|
+
result.failureReason !== undefined && {
|
|
35
|
+
[GEN_AI_ATTRS.GUARD_FAILURE_REASON]: result.failureReason,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
41
39
|
recordGuardEvaluation(result.ruleName);
|
|
42
40
|
if (!result.passed) {
|
|
43
41
|
recordGuardWouldBlock(result.ruleName);
|
package/dist/index.d.ts
CHANGED
|
@@ -13,5 +13,10 @@ export type { ExportTraceOptions } from "./export.js";
|
|
|
13
13
|
export { calculateCost, setCustomPricing, getModelPricing, } from "./core/pricing.js";
|
|
14
14
|
export type { ModelPricing } from "./core/pricing.js";
|
|
15
15
|
export type { LLMCallInput, LLMCallOutput } from "./core/spans.js";
|
|
16
|
-
export type { ToadEyeConfig, LLMSpanAttributes, LLMProvider, SpanStatus, MetricName, AgentStepType, AgentStepInput, GuardMode, GuardResult, } from "./types/index.js";
|
|
17
|
-
export { GEN_AI_METRICS, GEN_AI_ATTRS
|
|
16
|
+
export type { ToadEyeConfig, LLMSpanAttributes, LLMProvider, SpanStatus, MetricName, AgentStepType, AgentStepInput, AgentQueryOptions, GuardMode, GuardResult, InstrumentTarget, SamplingConfig, } from "./types/index.js";
|
|
17
|
+
export { GEN_AI_METRICS, GEN_AI_ATTRS } from "./types/index.js";
|
|
18
|
+
/** @deprecated Use GEN_AI_METRICS instead */
|
|
19
|
+
export { LLM_METRICS } from "./types/index.js";
|
|
20
|
+
/** @deprecated Use GEN_AI_ATTRS instead */
|
|
21
|
+
export { LLM_ATTRS } from "./types/index.js";
|
|
22
|
+
export { ToadEyeAISpanProcessor, withToadEye } from "./vercel.js";
|
package/dist/index.js
CHANGED
|
@@ -7,4 +7,9 @@ export { recordGuardResult } from "./guard.js";
|
|
|
7
7
|
export { createDriftMonitor, createOpenAIEmbeddingProvider, cosineSimilarity, cosineDrift, saveBaseline, loadBaseline, } from "./drift/index.js";
|
|
8
8
|
export { exportTrace, fetchTrace, traceToEvalYaml } from "./export.js";
|
|
9
9
|
export { calculateCost, setCustomPricing, getModelPricing, } from "./core/pricing.js";
|
|
10
|
-
export { GEN_AI_METRICS, GEN_AI_ATTRS
|
|
10
|
+
export { GEN_AI_METRICS, GEN_AI_ATTRS } from "./types/index.js";
|
|
11
|
+
/** @deprecated Use GEN_AI_METRICS instead */
|
|
12
|
+
export { LLM_METRICS } from "./types/index.js";
|
|
13
|
+
/** @deprecated Use GEN_AI_ATTRS instead */
|
|
14
|
+
export { LLM_ATTRS } from "./types/index.js";
|
|
15
|
+
export { ToadEyeAISpanProcessor, withToadEye } from "./vercel.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export declare const anthropicInstrumentation: import("./types.js").Instrumentation;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { register } from "./registry.js";
|
|
2
1
|
import { createInstrumentation } from "./create.js";
|
|
3
2
|
function extractMessages(messages) {
|
|
4
3
|
if (!Array.isArray(messages))
|
|
@@ -46,28 +45,21 @@ const messagesCreate = {
|
|
|
46
45
|
};
|
|
47
46
|
},
|
|
48
47
|
isStreaming: (body) => !!body?.stream,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (event.type === "message_start" && event.message?.usage) {
|
|
60
|
-
inputTokens = event.message.usage.input_tokens ?? 0;
|
|
61
|
-
}
|
|
62
|
-
if (event.type === "message_delta" && event.usage) {
|
|
63
|
-
outputTokens = event.usage.output_tokens ?? 0;
|
|
64
|
-
}
|
|
48
|
+
accumulateChunk: (acc, chunk) => {
|
|
49
|
+
const event = chunk;
|
|
50
|
+
if (event.type === "content_block_delta" && event.delta?.text) {
|
|
51
|
+
acc.completion += event.delta.text;
|
|
52
|
+
}
|
|
53
|
+
if (event.type === "message_start" && event.message?.usage) {
|
|
54
|
+
acc.inputTokens = event.message.usage.input_tokens ?? 0;
|
|
55
|
+
}
|
|
56
|
+
if (event.type === "message_delta" && event.usage) {
|
|
57
|
+
acc.outputTokens = event.usage.output_tokens ?? 0;
|
|
65
58
|
}
|
|
66
|
-
return { completion, inputTokens, outputTokens };
|
|
67
59
|
},
|
|
68
60
|
};
|
|
69
|
-
|
|
61
|
+
export const anthropicInstrumentation = createInstrumentation({
|
|
70
62
|
name: "anthropic",
|
|
71
63
|
moduleName: "@anthropic-ai/sdk",
|
|
72
64
|
patches: [messagesCreate],
|
|
73
|
-
})
|
|
65
|
+
});
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import type { LLMProvider } from "../types/index.js";
|
|
2
2
|
import type { Instrumentation, PatchTarget } from "./types.js";
|
|
3
|
-
/**
|
|
4
|
-
* Factory for creating SDK instrumentations.
|
|
5
|
-
* Eliminates boilerplate: SDK resolution, prototype patching, error handling, cleanup.
|
|
6
|
-
*/
|
|
7
3
|
export declare function createInstrumentation(config: {
|
|
8
4
|
name: LLMProvider;
|
|
9
5
|
moduleName: string;
|