toad-eye 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +290 -109
- package/dist/agent.d.ts +41 -0
- package/dist/agent.js +91 -0
- package/dist/budget/error.d.ts +10 -0
- package/dist/budget/error.js +22 -0
- package/dist/budget/index.d.ts +3 -0
- package/dist/budget/index.js +2 -0
- package/dist/budget/tracker.d.ts +35 -0
- package/dist/budget/tracker.js +121 -0
- package/dist/budget/types.d.ts +34 -0
- package/dist/budget/types.js +1 -0
- package/dist/cli.js +66 -28
- package/dist/core/metrics.d.ts +14 -0
- package/dist/core/metrics.js +119 -0
- package/dist/{spans.d.ts → core/spans.d.ts} +3 -1
- package/dist/core/spans.js +143 -0
- package/dist/core/tracer.d.ts +9 -0
- package/dist/core/tracer.js +93 -0
- package/dist/drift/baseline.d.ts +16 -0
- package/dist/drift/baseline.js +16 -0
- package/dist/drift/cosine.d.ts +13 -0
- package/dist/drift/cosine.js +39 -0
- package/dist/drift/embedding-provider.d.ts +21 -0
- package/dist/drift/embedding-provider.js +8 -0
- package/dist/drift/index.d.ts +9 -0
- package/dist/drift/index.js +8 -0
- package/dist/drift/monitor.d.ts +29 -0
- package/dist/drift/monitor.js +47 -0
- package/dist/drift/openai-embeddings.d.ts +6 -0
- package/dist/drift/openai-embeddings.js +26 -0
- package/dist/export.d.ts +46 -0
- package/dist/export.js +108 -0
- package/dist/guard.d.ts +26 -0
- package/dist/guard.js +45 -0
- package/dist/index.d.ts +14 -6
- package/dist/index.js +8 -3
- package/dist/instrumentations/anthropic.js +20 -1
- package/dist/instrumentations/create.js +130 -32
- package/dist/instrumentations/openai.js +38 -2
- package/dist/instrumentations/types.d.ts +9 -1
- package/dist/types/attributes.d.ts +12 -0
- package/dist/types/attributes.js +15 -0
- package/dist/types/config.d.ts +14 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/metrics.d.ts +8 -0
- package/dist/types/metrics.js +12 -0
- package/dist/types/spans.d.ts +21 -0
- package/package.json +28 -5
- package/templates/grafana/dashboards/finops-attribution.json +278 -0
- package/dist/metrics.d.ts +0 -6
- package/dist/metrics.js +0 -61
- package/dist/spans.js +0 -87
- package/dist/tracer.d.ts +0 -7
- package/dist/tracer.js +0 -55
- package/dist/types.d.ts +0 -104
- package/dist/types.js +0 -58
- /package/dist/{pricing.d.ts → core/pricing.d.ts} +0 -0
- /package/dist/{pricing.js → core/pricing.js} +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
2
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
3
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
4
|
+
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
5
|
+
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
6
|
+
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
7
|
+
import { initMetrics } from "./metrics.js";
|
|
8
|
+
import { enableAll, disableAll } from "../instrumentations/registry.js";
|
|
9
|
+
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";
|
|
14
|
+
const DEFAULT_ENDPOINT = "http://localhost:4318";
|
|
15
|
+
const DEFAULT_CLOUD_ENDPOINT = "https://cloud.toad-eye.dev";
|
|
16
|
+
let sdk = null;
|
|
17
|
+
let currentConfig = null;
|
|
18
|
+
let budgetTracker = null;
|
|
19
|
+
export function getConfig() {
|
|
20
|
+
return currentConfig;
|
|
21
|
+
}
|
|
22
|
+
export function getBudgetTracker() {
|
|
23
|
+
return budgetTracker;
|
|
24
|
+
}
|
|
25
|
+
function validateConfig(config) {
|
|
26
|
+
if (!config.serviceName?.trim()) {
|
|
27
|
+
throw new Error("toad-eye: serviceName is required and cannot be empty");
|
|
28
|
+
}
|
|
29
|
+
if (config.endpoint !== undefined && !config.endpoint.startsWith("http")) {
|
|
30
|
+
throw new Error(`toad-eye: endpoint must be a valid URL, got "${config.endpoint}"`);
|
|
31
|
+
}
|
|
32
|
+
if (config.apiKey !== undefined && !config.apiKey.startsWith("toad_")) {
|
|
33
|
+
throw new Error('toad-eye: apiKey must start with "toad_" prefix');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Resolve endpoint and auth headers based on config mode (self-hosted vs cloud). */
|
|
37
|
+
function resolveTransport(config) {
|
|
38
|
+
const isCloudMode = config.apiKey !== undefined;
|
|
39
|
+
const endpoint = isCloudMode
|
|
40
|
+
? (config.cloudEndpoint ?? DEFAULT_CLOUD_ENDPOINT)
|
|
41
|
+
: (config.endpoint ?? DEFAULT_ENDPOINT);
|
|
42
|
+
const headers = isCloudMode
|
|
43
|
+
? { Authorization: `Bearer ${config.apiKey}` }
|
|
44
|
+
: {};
|
|
45
|
+
return { endpoint, headers, isCloudMode };
|
|
46
|
+
}
|
|
47
|
+
export function initObservability(config) {
|
|
48
|
+
if (sdk)
|
|
49
|
+
return;
|
|
50
|
+
validateConfig(config);
|
|
51
|
+
const { endpoint, headers, isCloudMode } = resolveTransport(config);
|
|
52
|
+
const resource = resourceFromAttributes({
|
|
53
|
+
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
54
|
+
});
|
|
55
|
+
const traceExporter = new OTLPTraceExporter({
|
|
56
|
+
url: `${endpoint}/v1/traces`,
|
|
57
|
+
headers,
|
|
58
|
+
});
|
|
59
|
+
const metricExporter = new OTLPMetricExporter({
|
|
60
|
+
url: `${endpoint}/v1/metrics`,
|
|
61
|
+
headers,
|
|
62
|
+
});
|
|
63
|
+
const metricReader = new PeriodicExportingMetricReader({
|
|
64
|
+
exporter: metricExporter,
|
|
65
|
+
exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
|
|
66
|
+
});
|
|
67
|
+
sdk = new NodeSDK({
|
|
68
|
+
resource,
|
|
69
|
+
traceExporter,
|
|
70
|
+
metricReader,
|
|
71
|
+
});
|
|
72
|
+
sdk.start();
|
|
73
|
+
currentConfig = config;
|
|
74
|
+
initMetrics();
|
|
75
|
+
if (isCloudMode) {
|
|
76
|
+
console.log(`toad-eye: cloud mode enabled → sending telemetry to ${endpoint}`);
|
|
77
|
+
}
|
|
78
|
+
if (config.budgets) {
|
|
79
|
+
budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
|
|
80
|
+
}
|
|
81
|
+
if (config.instrument?.length) {
|
|
82
|
+
enableAll(config.instrument);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function shutdown() {
|
|
86
|
+
if (!sdk)
|
|
87
|
+
return;
|
|
88
|
+
disableAll();
|
|
89
|
+
await sdk.shutdown();
|
|
90
|
+
sdk = null;
|
|
91
|
+
currentConfig = null;
|
|
92
|
+
budgetTracker = null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline storage — save and load embedding baselines as JSON files.
|
|
3
|
+
*
|
|
4
|
+
* Baseline files contain embeddings of "known good" responses.
|
|
5
|
+
* Drift monitoring compares current responses against these baselines.
|
|
6
|
+
*/
|
|
7
|
+
export interface DriftBaseline {
|
|
8
|
+
readonly model: string;
|
|
9
|
+
readonly provider: string;
|
|
10
|
+
readonly embeddingModel: string;
|
|
11
|
+
readonly embeddings: readonly (readonly number[])[];
|
|
12
|
+
readonly sampleCount: number;
|
|
13
|
+
readonly createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function saveBaseline(filepath: string, baseline: DriftBaseline): void;
|
|
16
|
+
export declare function loadBaseline(filepath: string): DriftBaseline | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline storage — save and load embedding baselines as JSON files.
|
|
3
|
+
*
|
|
4
|
+
* Baseline files contain embeddings of "known good" responses.
|
|
5
|
+
* Drift monitoring compares current responses against these baselines.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
8
|
+
export function saveBaseline(filepath, baseline) {
|
|
9
|
+
writeFileSync(filepath, JSON.stringify(baseline, null, 2), "utf-8");
|
|
10
|
+
}
|
|
11
|
+
export function loadBaseline(filepath) {
|
|
12
|
+
if (!existsSync(filepath))
|
|
13
|
+
return undefined;
|
|
14
|
+
const raw = readFileSync(filepath, "utf-8");
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cosine similarity and drift calculation for embedding vectors.
|
|
3
|
+
*
|
|
4
|
+
* drift = 1 - cosine_similarity(current, baseline)
|
|
5
|
+
* - drift = 0 → identical to baseline
|
|
6
|
+
* - drift > 0.3 → significant deviation (default alert threshold)
|
|
7
|
+
*/
|
|
8
|
+
export declare function cosineSimilarity(a: readonly number[], b: readonly number[]): number;
|
|
9
|
+
/**
|
|
10
|
+
* Calculate drift as distance from the nearest baseline embedding.
|
|
11
|
+
* Returns the minimum drift value (closest match).
|
|
12
|
+
*/
|
|
13
|
+
export declare function cosineDrift(current: readonly number[], baselines: readonly (readonly number[])[]): number;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cosine similarity and drift calculation for embedding vectors.
|
|
3
|
+
*
|
|
4
|
+
* drift = 1 - cosine_similarity(current, baseline)
|
|
5
|
+
* - drift = 0 → identical to baseline
|
|
6
|
+
* - drift > 0.3 → significant deviation (default alert threshold)
|
|
7
|
+
*/
|
|
8
|
+
export function cosineSimilarity(a, b) {
|
|
9
|
+
let dot = 0;
|
|
10
|
+
let normA = 0;
|
|
11
|
+
let normB = 0;
|
|
12
|
+
for (let i = 0; i < a.length; i++) {
|
|
13
|
+
const ai = a[i];
|
|
14
|
+
const bi = b[i];
|
|
15
|
+
dot += ai * bi;
|
|
16
|
+
normA += ai * ai;
|
|
17
|
+
normB += bi * bi;
|
|
18
|
+
}
|
|
19
|
+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
|
20
|
+
if (denominator === 0)
|
|
21
|
+
return 0;
|
|
22
|
+
return dot / denominator;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Calculate drift as distance from the nearest baseline embedding.
|
|
26
|
+
* Returns the minimum drift value (closest match).
|
|
27
|
+
*/
|
|
28
|
+
export function cosineDrift(current, baselines) {
|
|
29
|
+
if (baselines.length === 0)
|
|
30
|
+
return 1;
|
|
31
|
+
let maxSimilarity = -1;
|
|
32
|
+
for (const baseline of baselines) {
|
|
33
|
+
const similarity = cosineSimilarity(current, baseline);
|
|
34
|
+
if (similarity > maxSimilarity) {
|
|
35
|
+
maxSimilarity = similarity;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return 1 - maxSimilarity;
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding provider abstraction for semantic drift monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Implementations convert text into float vectors for cosine similarity comparison.
|
|
5
|
+
* Default: OpenAI text-embedding-3-small (cheap, fast).
|
|
6
|
+
* Users can provide a custom implementation via the EmbeddingProvider interface.
|
|
7
|
+
*/
|
|
8
|
+
export interface EmbeddingProvider {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
embed(text: string): Promise<readonly number[]>;
|
|
11
|
+
}
|
|
12
|
+
export interface OpenAIEmbeddingConfig {
|
|
13
|
+
readonly provider: "openai";
|
|
14
|
+
readonly apiKey: string;
|
|
15
|
+
readonly model?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
export interface CustomEmbeddingConfig {
|
|
18
|
+
readonly provider: "custom";
|
|
19
|
+
readonly embed: (text: string) => Promise<readonly number[]>;
|
|
20
|
+
}
|
|
21
|
+
export type EmbeddingConfig = OpenAIEmbeddingConfig | CustomEmbeddingConfig;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding provider abstraction for semantic drift monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Implementations convert text into float vectors for cosine similarity comparison.
|
|
5
|
+
* Default: OpenAI text-embedding-3-small (cheap, fast).
|
|
6
|
+
* Users can provide a custom implementation via the EmbeddingProvider interface.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Drift Monitoring — detect silent LLM quality degradation
|
|
3
|
+
* by comparing current responses to a saved baseline via embeddings.
|
|
4
|
+
*/
|
|
5
|
+
export type { EmbeddingProvider, EmbeddingConfig, OpenAIEmbeddingConfig, CustomEmbeddingConfig, } from "./embedding-provider.js";
|
|
6
|
+
export { createOpenAIEmbeddingProvider } from "./openai-embeddings.js";
|
|
7
|
+
export { cosineSimilarity, cosineDrift } from "./cosine.js";
|
|
8
|
+
export { saveBaseline, loadBaseline, type DriftBaseline } from "./baseline.js";
|
|
9
|
+
export { createDriftMonitor, type DriftMonitor, type DriftMonitorConfig, } from "./monitor.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Drift Monitoring — detect silent LLM quality degradation
|
|
3
|
+
* by comparing current responses to a saved baseline via embeddings.
|
|
4
|
+
*/
|
|
5
|
+
export { createOpenAIEmbeddingProvider } from "./openai-embeddings.js";
|
|
6
|
+
export { cosineSimilarity, cosineDrift } from "./cosine.js";
|
|
7
|
+
export { saveBaseline, loadBaseline } from "./baseline.js";
|
|
8
|
+
export { createDriftMonitor, } from "./monitor.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift monitor — runtime semantic drift detection with sampling.
|
|
3
|
+
*
|
|
4
|
+
* Not every response is checked (embedding API calls cost money).
|
|
5
|
+
* By default, every 10th response is sampled. The drift value is
|
|
6
|
+
* recorded as a Prometheus histogram metric with provider/model labels.
|
|
7
|
+
*/
|
|
8
|
+
import type { EmbeddingConfig } from "./embedding-provider.js";
|
|
9
|
+
export interface DriftMonitorConfig {
|
|
10
|
+
readonly embedding: EmbeddingConfig;
|
|
11
|
+
readonly baselinePath: string;
|
|
12
|
+
readonly sampleRate?: number | undefined;
|
|
13
|
+
}
|
|
14
|
+
export interface DriftMonitor {
|
|
15
|
+
/**
|
|
16
|
+
* Check a response for semantic drift. Respects sampling rate —
|
|
17
|
+
* only every Nth call actually computes embeddings.
|
|
18
|
+
* Returns the drift value if checked, undefined if skipped.
|
|
19
|
+
*/
|
|
20
|
+
check(response: string, provider: string, model: string): Promise<number | undefined>;
|
|
21
|
+
/**
|
|
22
|
+
* Fire-and-forget drift check — does NOT block the caller.
|
|
23
|
+
* Errors are logged to console.warn, never thrown.
|
|
24
|
+
* Use this in hot paths (e.g. inside traceLLMCall) to avoid
|
|
25
|
+
* blocking the LLM response delivery.
|
|
26
|
+
*/
|
|
27
|
+
checkInBackground(response: string, provider: string, model: string): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createDriftMonitor(config: DriftMonitorConfig): DriftMonitor;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift monitor — runtime semantic drift detection with sampling.
|
|
3
|
+
*
|
|
4
|
+
* Not every response is checked (embedding API calls cost money).
|
|
5
|
+
* By default, every 10th response is sampled. The drift value is
|
|
6
|
+
* recorded as a Prometheus histogram metric with provider/model labels.
|
|
7
|
+
*/
|
|
8
|
+
import { createOpenAIEmbeddingProvider } from "./openai-embeddings.js";
|
|
9
|
+
import { cosineDrift } from "./cosine.js";
|
|
10
|
+
import { loadBaseline } from "./baseline.js";
|
|
11
|
+
import { recordSemanticDrift } from "../core/metrics.js";
|
|
12
|
+
function createProvider(config) {
|
|
13
|
+
switch (config.provider) {
|
|
14
|
+
case "openai":
|
|
15
|
+
return createOpenAIEmbeddingProvider(config.apiKey, config.model);
|
|
16
|
+
case "custom":
|
|
17
|
+
return { name: "custom", embed: config.embed };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function createDriftMonitor(config) {
|
|
21
|
+
const provider = createProvider(config.embedding);
|
|
22
|
+
const sampleRate = config.sampleRate ?? 10;
|
|
23
|
+
const baseline = loadBaseline(config.baselinePath);
|
|
24
|
+
let counter = 0;
|
|
25
|
+
if (baseline === undefined) {
|
|
26
|
+
console.warn(`[toad-eye] No baseline found at ${config.baselinePath}. Drift monitoring disabled until baseline is saved.`);
|
|
27
|
+
}
|
|
28
|
+
async function performCheck(response, providerName, model) {
|
|
29
|
+
if (baseline === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
counter++;
|
|
32
|
+
if (counter % sampleRate !== 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
const embedding = await provider.embed(response);
|
|
35
|
+
const drift = cosineDrift(embedding, baseline.embeddings);
|
|
36
|
+
recordSemanticDrift(drift, providerName, model);
|
|
37
|
+
return drift;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
check: performCheck,
|
|
41
|
+
checkInBackground(response, providerName, model) {
|
|
42
|
+
void performCheck(response, providerName, model).catch((err) => {
|
|
43
|
+
console.warn(`[toad-eye] Drift check failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI embedding provider using text-embedding-3-small.
|
|
3
|
+
* Calls the OpenAI API directly via fetch — no SDK dependency.
|
|
4
|
+
*/
|
|
5
|
+
import type { EmbeddingProvider } from "./embedding-provider.js";
|
|
6
|
+
export declare function createOpenAIEmbeddingProvider(apiKey: string, model?: string): EmbeddingProvider;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI embedding provider using text-embedding-3-small.
|
|
3
|
+
* Calls the OpenAI API directly via fetch — no SDK dependency.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_MODEL = "text-embedding-3-small";
|
|
6
|
+
const OPENAI_API_URL = "https://api.openai.com/v1/embeddings";
|
|
7
|
+
export function createOpenAIEmbeddingProvider(apiKey, model = DEFAULT_MODEL) {
|
|
8
|
+
return {
|
|
9
|
+
name: `openai/${model}`,
|
|
10
|
+
async embed(text) {
|
|
11
|
+
const response = await fetch(OPENAI_API_URL, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: `Bearer ${apiKey}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({ input: text, model }),
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new Error(`OpenAI Embeddings API error: ${response.status} ${response.statusText}`);
|
|
21
|
+
}
|
|
22
|
+
const body = (await response.json());
|
|
23
|
+
return body.data[0].embedding;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace-to-Dataset Export — converts production Jaeger traces into toad-eval test cases.
|
|
3
|
+
*
|
|
4
|
+
* Fetches a trace by ID from the Jaeger Query API, extracts LLM call data
|
|
5
|
+
* (prompt, model, provider, response), and generates a YAML file in toad-eval format
|
|
6
|
+
* with auto-generated assertions (max_length, not_contains, is_json).
|
|
7
|
+
*/
|
|
8
|
+
interface JaegerTag {
|
|
9
|
+
readonly key: string;
|
|
10
|
+
readonly type: string;
|
|
11
|
+
readonly value: string | number | boolean;
|
|
12
|
+
}
|
|
13
|
+
interface JaegerSpan {
|
|
14
|
+
readonly traceID: string;
|
|
15
|
+
readonly spanID: string;
|
|
16
|
+
readonly operationName: string;
|
|
17
|
+
readonly tags: readonly JaegerTag[];
|
|
18
|
+
readonly startTime: number;
|
|
19
|
+
readonly duration: number;
|
|
20
|
+
}
|
|
21
|
+
interface JaegerTrace {
|
|
22
|
+
readonly traceID: string;
|
|
23
|
+
readonly spans: readonly JaegerSpan[];
|
|
24
|
+
}
|
|
25
|
+
export interface ExportTraceOptions {
|
|
26
|
+
readonly jaegerUrl?: string | undefined;
|
|
27
|
+
readonly output?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Fetch a trace from Jaeger by trace ID.
|
|
31
|
+
* Requires Jaeger to be running with the Query API available.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fetchTrace(traceId: string, jaegerUrl?: string): Promise<JaegerTrace>;
|
|
34
|
+
/**
|
|
35
|
+
* Convert a Jaeger trace to a toad-eval YAML string.
|
|
36
|
+
* Extracts LLM spans with prompt data, generates assertions from completions.
|
|
37
|
+
*
|
|
38
|
+
* Returns undefined if no exportable spans found (e.g. recordContent was false).
|
|
39
|
+
*/
|
|
40
|
+
export declare function traceToEvalYaml(trace: JaegerTrace): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Full export pipeline: fetch trace from Jaeger → convert to toad-eval YAML.
|
|
43
|
+
* Returns the YAML string. Caller is responsible for writing to file.
|
|
44
|
+
*/
|
|
45
|
+
export declare function exportTrace(traceId: string, options?: ExportTraceOptions): Promise<string>;
|
|
46
|
+
export {};
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace-to-Dataset Export — converts production Jaeger traces into toad-eval test cases.
|
|
3
|
+
*
|
|
4
|
+
* Fetches a trace by ID from the Jaeger Query API, extracts LLM call data
|
|
5
|
+
* (prompt, model, provider, response), and generates a YAML file in toad-eval format
|
|
6
|
+
* with auto-generated assertions (max_length, not_contains, is_json).
|
|
7
|
+
*/
|
|
8
|
+
import { stringify } from "yaml";
|
|
9
|
+
import { GEN_AI_ATTRS } from "./types/index.js";
|
|
10
|
+
const DEFAULT_JAEGER_URL = "http://localhost:16686";
|
|
11
|
+
const LENGTH_BUFFER_MULTIPLIER = 1.5;
|
|
12
|
+
const REFUSAL_MARKER = "i cannot";
|
|
13
|
+
function getTagValue(tags, key) {
|
|
14
|
+
const tag = tags.find((t) => t.key === key);
|
|
15
|
+
return tag !== undefined ? String(tag.value) : undefined;
|
|
16
|
+
}
|
|
17
|
+
function isJson(text) {
|
|
18
|
+
try {
|
|
19
|
+
JSON.parse(text);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function buildAssertions(completion) {
|
|
27
|
+
const assertions = [];
|
|
28
|
+
if (completion !== undefined) {
|
|
29
|
+
assertions.push({
|
|
30
|
+
type: "max_length",
|
|
31
|
+
value: Math.ceil(completion.length * LENGTH_BUFFER_MULTIPLIER),
|
|
32
|
+
});
|
|
33
|
+
if (!completion.toLowerCase().includes(REFUSAL_MARKER)) {
|
|
34
|
+
assertions.push({ type: "not_contains", value: "I cannot" });
|
|
35
|
+
}
|
|
36
|
+
if (isJson(completion)) {
|
|
37
|
+
assertions.push({ type: "is_json", value: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return assertions;
|
|
41
|
+
}
|
|
42
|
+
function spanToEvalCase(span, index) {
|
|
43
|
+
const prompt = getTagValue(span.tags, GEN_AI_ATTRS.PROMPT);
|
|
44
|
+
if (prompt === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
const completion = getTagValue(span.tags, GEN_AI_ATTRS.COMPLETION);
|
|
47
|
+
return {
|
|
48
|
+
id: `production-case-${index + 1}`,
|
|
49
|
+
variables: { input: prompt },
|
|
50
|
+
assertions: buildAssertions(completion),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Fetch a trace from Jaeger by trace ID.
|
|
55
|
+
* Requires Jaeger to be running with the Query API available.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchTrace(traceId, jaegerUrl = DEFAULT_JAEGER_URL) {
|
|
58
|
+
const url = `${jaegerUrl}/api/traces/${traceId}`;
|
|
59
|
+
const response = await fetch(url);
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(`Failed to fetch trace ${traceId}: ${response.status} ${response.statusText}`);
|
|
62
|
+
}
|
|
63
|
+
const body = (await response.json());
|
|
64
|
+
if (body.data.length === 0) {
|
|
65
|
+
throw new Error(`Trace ${traceId} not found`);
|
|
66
|
+
}
|
|
67
|
+
return body.data[0];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Convert a Jaeger trace to a toad-eval YAML string.
|
|
71
|
+
* Extracts LLM spans with prompt data, generates assertions from completions.
|
|
72
|
+
*
|
|
73
|
+
* Returns undefined if no exportable spans found (e.g. recordContent was false).
|
|
74
|
+
*/
|
|
75
|
+
export function traceToEvalYaml(trace) {
|
|
76
|
+
const cases = trace.spans
|
|
77
|
+
.map((span, i) => spanToEvalCase(span, i))
|
|
78
|
+
.filter((c) => c !== undefined);
|
|
79
|
+
if (cases.length === 0)
|
|
80
|
+
return undefined;
|
|
81
|
+
// Pick model/provider from first span that has them
|
|
82
|
+
const firstSpanWithMeta = trace.spans.find((s) => getTagValue(s.tags, GEN_AI_ATTRS.REQUEST_MODEL) !== undefined);
|
|
83
|
+
const dataset = {
|
|
84
|
+
name: `exported-trace-${trace.traceID.slice(0, 8)}`,
|
|
85
|
+
source: "toad-eye-export",
|
|
86
|
+
metadata: {
|
|
87
|
+
trace_id: trace.traceID,
|
|
88
|
+
exported_at: new Date().toISOString(),
|
|
89
|
+
model: getTagValue(firstSpanWithMeta?.tags ?? [], GEN_AI_ATTRS.REQUEST_MODEL) ?? "unknown",
|
|
90
|
+
provider: getTagValue(firstSpanWithMeta?.tags ?? [], GEN_AI_ATTRS.PROVIDER) ??
|
|
91
|
+
"unknown",
|
|
92
|
+
},
|
|
93
|
+
cases,
|
|
94
|
+
};
|
|
95
|
+
return stringify(dataset);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Full export pipeline: fetch trace from Jaeger → convert to toad-eval YAML.
|
|
99
|
+
* Returns the YAML string. Caller is responsible for writing to file.
|
|
100
|
+
*/
|
|
101
|
+
export async function exportTrace(traceId, options = {}) {
|
|
102
|
+
const trace = await fetchTrace(traceId, options.jaegerUrl);
|
|
103
|
+
const yaml = traceToEvalYaml(trace);
|
|
104
|
+
if (yaml === undefined) {
|
|
105
|
+
throw new Error(`No exportable spans in trace ${traceId}. Was recordContent enabled?`);
|
|
106
|
+
}
|
|
107
|
+
return yaml;
|
|
108
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow Guardrails integration — records toad-guard validation results
|
|
3
|
+
* as span attributes and metrics without blocking LLM responses.
|
|
4
|
+
*
|
|
5
|
+
* toad-guard validates each LLM response and passes the result here.
|
|
6
|
+
* In shadow mode, validation runs but never throws — results are recorded
|
|
7
|
+
* for observability only, letting you tune guardrail thresholds on
|
|
8
|
+
* production traffic before switching to enforce mode.
|
|
9
|
+
*
|
|
10
|
+
* Usage from toad-guard:
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { recordGuardResult } from "toad-eye";
|
|
13
|
+
*
|
|
14
|
+
* const result = guard.validate(response);
|
|
15
|
+
* recordGuardResult(result); // records as span attrs + metrics
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import type { GuardResult } from "./types/index.js";
|
|
19
|
+
/**
|
|
20
|
+
* Record a guard validation result as span attributes and metrics.
|
|
21
|
+
*
|
|
22
|
+
* Attaches guard attributes to a child span and increments counters.
|
|
23
|
+
* If the guard failed, also increments the `would_block` counter —
|
|
24
|
+
* useful for measuring how often shadow guardrails would have blocked.
|
|
25
|
+
*/
|
|
26
|
+
export declare function recordGuardResult(result: GuardResult): void;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow Guardrails integration — records toad-guard validation results
|
|
3
|
+
* as span attributes and metrics without blocking LLM responses.
|
|
4
|
+
*
|
|
5
|
+
* toad-guard validates each LLM response and passes the result here.
|
|
6
|
+
* In shadow mode, validation runs but never throws — results are recorded
|
|
7
|
+
* for observability only, letting you tune guardrail thresholds on
|
|
8
|
+
* production traffic before switching to enforce mode.
|
|
9
|
+
*
|
|
10
|
+
* Usage from toad-guard:
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { recordGuardResult } from "toad-eye";
|
|
13
|
+
*
|
|
14
|
+
* const result = guard.validate(response);
|
|
15
|
+
* recordGuardResult(result); // records as span attrs + metrics
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import { trace } from "@opentelemetry/api";
|
|
19
|
+
import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "./types/index.js";
|
|
20
|
+
import { recordGuardEvaluation, recordGuardWouldBlock, } from "./core/metrics.js";
|
|
21
|
+
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
22
|
+
/**
|
|
23
|
+
* Record a guard validation result as span attributes and metrics.
|
|
24
|
+
*
|
|
25
|
+
* Attaches guard attributes to a child span and increments counters.
|
|
26
|
+
* If the guard failed, also increments the `would_block` counter —
|
|
27
|
+
* useful for measuring how often shadow guardrails would have blocked.
|
|
28
|
+
*/
|
|
29
|
+
export function recordGuardResult(result) {
|
|
30
|
+
const span = tracer.startSpan(`guard.evaluate.${result.ruleName}`);
|
|
31
|
+
span.setAttributes({
|
|
32
|
+
[GEN_AI_ATTRS.GUARD_MODE]: result.mode,
|
|
33
|
+
[GEN_AI_ATTRS.GUARD_PASSED]: result.passed,
|
|
34
|
+
[GEN_AI_ATTRS.GUARD_RULE_NAME]: result.ruleName,
|
|
35
|
+
...(!result.passed &&
|
|
36
|
+
result.failureReason !== undefined && {
|
|
37
|
+
[GEN_AI_ATTRS.GUARD_FAILURE_REASON]: result.failureReason,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
span.end();
|
|
41
|
+
recordGuardEvaluation(result.ruleName);
|
|
42
|
+
if (!result.passed) {
|
|
43
|
+
recordGuardWouldBlock(result.ruleName);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
export { initObservability, shutdown, getConfig } from "./tracer.js";
|
|
1
|
+
export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
|
|
2
|
+
export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
|
|
3
|
+
export type { BudgetConfig, BudgetExceededMode, BudgetExceededInfo, DowngradeCallback, } from "./budget/index.js";
|
|
2
4
|
export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
|
|
3
5
|
export type { AlertsConfig, AlertRule, AlertChannelConfig, FiredAlert, } from "./alerts/index.js";
|
|
4
|
-
export { traceLLMCall } from "./spans.js";
|
|
5
|
-
export {
|
|
6
|
-
export
|
|
7
|
-
export
|
|
8
|
-
export type {
|
|
6
|
+
export { traceLLMCall } from "./core/spans.js";
|
|
7
|
+
export { traceAgentStep, traceAgentQuery } from "./agent.js";
|
|
8
|
+
export { recordGuardResult } from "./guard.js";
|
|
9
|
+
export { createDriftMonitor, createOpenAIEmbeddingProvider, cosineSimilarity, cosineDrift, saveBaseline, loadBaseline, } from "./drift/index.js";
|
|
10
|
+
export type { DriftMonitor, DriftMonitorConfig, EmbeddingProvider, EmbeddingConfig, DriftBaseline, } from "./drift/index.js";
|
|
11
|
+
export { exportTrace, fetchTrace, traceToEvalYaml } from "./export.js";
|
|
12
|
+
export type { ExportTraceOptions } from "./export.js";
|
|
13
|
+
export { calculateCost, setCustomPricing, getModelPricing, } from "./core/pricing.js";
|
|
14
|
+
export type { ModelPricing } from "./core/pricing.js";
|
|
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";
|
|
9
17
|
export { GEN_AI_METRICS, GEN_AI_ATTRS, LLM_METRICS, LLM_ATTRS, } from "./types/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
export { initObservability, shutdown, getConfig } from "./tracer.js";
|
|
1
|
+
export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
|
|
2
|
+
export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
|
|
2
3
|
export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
|
|
3
|
-
export { traceLLMCall } from "./spans.js";
|
|
4
|
-
export {
|
|
4
|
+
export { traceLLMCall } from "./core/spans.js";
|
|
5
|
+
export { traceAgentStep, traceAgentQuery } from "./agent.js";
|
|
6
|
+
export { recordGuardResult } from "./guard.js";
|
|
7
|
+
export { createDriftMonitor, createOpenAIEmbeddingProvider, cosineSimilarity, cosineDrift, saveBaseline, loadBaseline, } from "./drift/index.js";
|
|
8
|
+
export { exportTrace, fetchTrace, traceToEvalYaml } from "./export.js";
|
|
9
|
+
export { calculateCost, setCustomPricing, getModelPricing, } from "./core/pricing.js";
|
|
5
10
|
export { GEN_AI_METRICS, GEN_AI_ATTRS, LLM_METRICS, LLM_ATTRS, } from "./types/index.js";
|
|
@@ -29,7 +29,6 @@ function extractCompletion(content) {
|
|
|
29
29
|
const messagesCreate = {
|
|
30
30
|
getPrototype: (sdk) => sdk?.Messages?.prototype,
|
|
31
31
|
method: "create",
|
|
32
|
-
shouldSkip: (body) => !!body?.stream,
|
|
33
32
|
extractRequest: (body) => {
|
|
34
33
|
const b = body;
|
|
35
34
|
return {
|
|
@@ -46,6 +45,26 @@ const messagesCreate = {
|
|
|
46
45
|
outputTokens: r?.usage?.output_tokens ?? 0,
|
|
47
46
|
};
|
|
48
47
|
},
|
|
48
|
+
isStreaming: (body) => !!body?.stream,
|
|
49
|
+
extractStreamResponse: (chunks) => {
|
|
50
|
+
// Anthropic stream events: message_start, content_block_delta, message_delta, message_stop
|
|
51
|
+
let completion = "";
|
|
52
|
+
let inputTokens = 0;
|
|
53
|
+
let outputTokens = 0;
|
|
54
|
+
for (const chunk of chunks) {
|
|
55
|
+
const event = chunk;
|
|
56
|
+
if (event.type === "content_block_delta" && event.delta?.text) {
|
|
57
|
+
completion += event.delta.text;
|
|
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
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { completion, inputTokens, outputTokens };
|
|
67
|
+
},
|
|
49
68
|
};
|
|
50
69
|
register(createInstrumentation({
|
|
51
70
|
name: "anthropic",
|