toad-eye 2.0.0 → 2.1.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/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
package/dist/agent.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AgentStepInput } from "./types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Record a single agent step as a child span (ReAct pattern: think → act → observe → answer).
|
|
4
|
+
*
|
|
5
|
+
* Creates a short-lived span under the current active context. Call this inside
|
|
6
|
+
* {@link traceAgentQuery} (preferred) or any other active span (e.g. traceLLMCall).
|
|
7
|
+
*
|
|
8
|
+
* - Respects `recordContent` config — content is omitted when recording is disabled.
|
|
9
|
+
* - For `act` steps with a `toolName`, automatically increments the `agent.tool_usage` metric.
|
|
10
|
+
*/
|
|
11
|
+
declare function traceAgentStep(input: AgentStepInput): void;
|
|
12
|
+
/**
|
|
13
|
+
* Wrap an entire agent query in a parent span, producing structured ReAct traces.
|
|
14
|
+
*
|
|
15
|
+
* The callback receives a `step` function to record individual steps.
|
|
16
|
+
* When the callback completes, the wrapper automatically records the
|
|
17
|
+
* `agent.steps_per_query` histogram metric with the total step count.
|
|
18
|
+
*
|
|
19
|
+
* Span hierarchy in Jaeger:
|
|
20
|
+
* ```
|
|
21
|
+
* [parent] agent.query "Is anything dangerous near Earth?"
|
|
22
|
+
* ├── agent.step.think "I need to check asteroids"
|
|
23
|
+
* ├── agent.step.act tool=near-earth-asteroids
|
|
24
|
+
* ├── agent.step.observe "7 asteroids found"
|
|
25
|
+
* └── agent.step.answer "7 asteroids passing safely..."
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* const result = await traceAgentQuery("Is anything dangerous near Earth?", async (step) => {
|
|
31
|
+
* step({ type: "think", stepNumber: 1, content: "I need to check asteroids" });
|
|
32
|
+
* const data = await callTool("near-earth-asteroids");
|
|
33
|
+
* step({ type: "act", stepNumber: 2, toolName: "near-earth-asteroids" });
|
|
34
|
+
* step({ type: "observe", stepNumber: 3, content: `${data.length} asteroids found` });
|
|
35
|
+
* step({ type: "answer", stepNumber: 4, content: "7 asteroids passing safely..." });
|
|
36
|
+
* return { answer: "7 asteroids passing safely..." };
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function traceAgentQuery<T>(query: string, fn: (step: (input: AgentStepInput) => void) => Promise<T>): Promise<T>;
|
|
41
|
+
export { traceAgentStep };
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
|
2
|
+
import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "./types/index.js";
|
|
3
|
+
import { recordAgentSteps, recordAgentToolUsage } from "./core/metrics.js";
|
|
4
|
+
import { getConfig } from "./core/tracer.js";
|
|
5
|
+
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
6
|
+
/**
|
|
7
|
+
* Record a single agent step as a child span (ReAct pattern: think → act → observe → answer).
|
|
8
|
+
*
|
|
9
|
+
* Creates a short-lived span under the current active context. Call this inside
|
|
10
|
+
* {@link traceAgentQuery} (preferred) or any other active span (e.g. traceLLMCall).
|
|
11
|
+
*
|
|
12
|
+
* - Respects `recordContent` config — content is omitted when recording is disabled.
|
|
13
|
+
* - For `act` steps with a `toolName`, automatically increments the `agent.tool_usage` metric.
|
|
14
|
+
*/
|
|
15
|
+
function traceAgentStep(input) {
|
|
16
|
+
const span = tracer.startSpan(`agent.step.${input.type}`);
|
|
17
|
+
const config = getConfig();
|
|
18
|
+
const recordContent = config?.recordContent !== false;
|
|
19
|
+
span.setAttributes({
|
|
20
|
+
[GEN_AI_ATTRS.AGENT_STEP_TYPE]: input.type,
|
|
21
|
+
[GEN_AI_ATTRS.AGENT_STEP_NUMBER]: input.stepNumber,
|
|
22
|
+
...(input.toolName !== undefined && {
|
|
23
|
+
[GEN_AI_ATTRS.AGENT_TOOL_NAME]: input.toolName,
|
|
24
|
+
}),
|
|
25
|
+
...(recordContent &&
|
|
26
|
+
input.content !== undefined && {
|
|
27
|
+
[GEN_AI_ATTRS.AGENT_STEP_CONTENT]: input.content,
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
if (input.type === "act" && input.toolName !== undefined) {
|
|
31
|
+
recordAgentToolUsage(input.toolName);
|
|
32
|
+
}
|
|
33
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
34
|
+
span.end();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap an entire agent query in a parent span, producing structured ReAct traces.
|
|
38
|
+
*
|
|
39
|
+
* The callback receives a `step` function to record individual steps.
|
|
40
|
+
* When the callback completes, the wrapper automatically records the
|
|
41
|
+
* `agent.steps_per_query` histogram metric with the total step count.
|
|
42
|
+
*
|
|
43
|
+
* Span hierarchy in Jaeger:
|
|
44
|
+
* ```
|
|
45
|
+
* [parent] agent.query "Is anything dangerous near Earth?"
|
|
46
|
+
* ├── agent.step.think "I need to check asteroids"
|
|
47
|
+
* ├── agent.step.act tool=near-earth-asteroids
|
|
48
|
+
* ├── agent.step.observe "7 asteroids found"
|
|
49
|
+
* └── agent.step.answer "7 asteroids passing safely..."
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const result = await traceAgentQuery("Is anything dangerous near Earth?", async (step) => {
|
|
55
|
+
* step({ type: "think", stepNumber: 1, content: "I need to check asteroids" });
|
|
56
|
+
* const data = await callTool("near-earth-asteroids");
|
|
57
|
+
* step({ type: "act", stepNumber: 2, toolName: "near-earth-asteroids" });
|
|
58
|
+
* step({ type: "observe", stepNumber: 3, content: `${data.length} asteroids found` });
|
|
59
|
+
* step({ type: "answer", stepNumber: 4, content: "7 asteroids passing safely..." });
|
|
60
|
+
* return { answer: "7 asteroids passing safely..." };
|
|
61
|
+
* });
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export async function traceAgentQuery(query, fn) {
|
|
65
|
+
return tracer.startActiveSpan(`agent.query`, async (span) => {
|
|
66
|
+
const config = getConfig();
|
|
67
|
+
const recordContent = config?.recordContent !== false;
|
|
68
|
+
if (recordContent) {
|
|
69
|
+
span.setAttribute(GEN_AI_ATTRS.AGENT_STEP_CONTENT, query);
|
|
70
|
+
}
|
|
71
|
+
let stepCount = 0;
|
|
72
|
+
try {
|
|
73
|
+
const result = await fn((input) => {
|
|
74
|
+
stepCount++;
|
|
75
|
+
traceAgentStep(input);
|
|
76
|
+
});
|
|
77
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
recordAgentSteps(stepCount);
|
|
87
|
+
span.end();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export { traceAgentStep };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BudgetExceededInfo } from "./types.js";
|
|
2
|
+
/** Thrown when onBudgetExceeded is 'block' and a budget limit is hit. */
|
|
3
|
+
export declare class ToadBudgetExceededError extends Error {
|
|
4
|
+
readonly budget: BudgetExceededInfo["budget"];
|
|
5
|
+
readonly limit: number;
|
|
6
|
+
readonly current: number;
|
|
7
|
+
readonly model?: string | undefined;
|
|
8
|
+
readonly userId?: string | undefined;
|
|
9
|
+
constructor(info: BudgetExceededInfo);
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Thrown when onBudgetExceeded is 'block' and a budget limit is hit. */
|
|
2
|
+
export class ToadBudgetExceededError extends Error {
|
|
3
|
+
budget;
|
|
4
|
+
limit;
|
|
5
|
+
current;
|
|
6
|
+
model;
|
|
7
|
+
userId;
|
|
8
|
+
constructor(info) {
|
|
9
|
+
const target = info.budget === "perModel"
|
|
10
|
+
? ` for model ${info.model}`
|
|
11
|
+
: info.budget === "perUser"
|
|
12
|
+
? ` for user ${info.userId}`
|
|
13
|
+
: "";
|
|
14
|
+
super(`toad-eye: ${info.budget} budget exceeded${target} — limit $${info.limit}, current $${info.current.toFixed(2)}`);
|
|
15
|
+
this.name = "ToadBudgetExceededError";
|
|
16
|
+
this.budget = info.budget;
|
|
17
|
+
this.limit = info.limit;
|
|
18
|
+
this.current = info.current;
|
|
19
|
+
this.model = info.model;
|
|
20
|
+
this.userId = info.userId;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BudgetConfig, BudgetExceededMode, BudgetState, BudgetExceededInfo, DowngradeCallback } from "./types.js";
|
|
2
|
+
export declare class BudgetTracker {
|
|
3
|
+
private readonly config;
|
|
4
|
+
private readonly mode;
|
|
5
|
+
private readonly downgrade?;
|
|
6
|
+
private state;
|
|
7
|
+
constructor(config: BudgetConfig, mode: BudgetExceededMode, downgrade?: DowngradeCallback);
|
|
8
|
+
/** Reset counters if the day has changed. */
|
|
9
|
+
private resetIfNewDay;
|
|
10
|
+
/**
|
|
11
|
+
* Check budget BEFORE making an LLM call.
|
|
12
|
+
* Returns modified provider/model if downgrade mode triggers,
|
|
13
|
+
* throws ToadBudgetExceededError if block mode triggers,
|
|
14
|
+
* returns null if no action needed.
|
|
15
|
+
*/
|
|
16
|
+
checkBefore(provider: string, model: string, userId?: string): {
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
} | null;
|
|
20
|
+
/** Record cost AFTER a successful LLM call. Returns exceeded info if budget was just crossed. */
|
|
21
|
+
recordCost(cost: number, model: string, userId?: string): BudgetExceededInfo | null;
|
|
22
|
+
/** Find which budget (if any) is exceeded. */
|
|
23
|
+
private findExceeded;
|
|
24
|
+
/** Get current budget usage as percentage (0-100+). */
|
|
25
|
+
getUsagePercent(): number;
|
|
26
|
+
/** Get current state snapshot (for persistence or debugging). */
|
|
27
|
+
getState(): Readonly<BudgetState>;
|
|
28
|
+
/** Restore state from persistence (e.g., on startup). */
|
|
29
|
+
restoreState(saved: {
|
|
30
|
+
date: string;
|
|
31
|
+
totalCost: number;
|
|
32
|
+
perUser: Record<string, number>;
|
|
33
|
+
perModel: Record<string, number>;
|
|
34
|
+
}): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ToadBudgetExceededError } from "./error.js";
|
|
2
|
+
function todayUTC() {
|
|
3
|
+
return new Date().toISOString().slice(0, 10);
|
|
4
|
+
}
|
|
5
|
+
export class BudgetTracker {
|
|
6
|
+
config;
|
|
7
|
+
mode;
|
|
8
|
+
downgrade;
|
|
9
|
+
state;
|
|
10
|
+
constructor(config, mode, downgrade) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
this.mode = mode;
|
|
13
|
+
this.downgrade = downgrade;
|
|
14
|
+
this.state = {
|
|
15
|
+
date: todayUTC(),
|
|
16
|
+
totalCost: 0,
|
|
17
|
+
perUser: new Map(),
|
|
18
|
+
perModel: new Map(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Reset counters if the day has changed. */
|
|
22
|
+
resetIfNewDay() {
|
|
23
|
+
const today = todayUTC();
|
|
24
|
+
if (this.state.date !== today) {
|
|
25
|
+
this.state = {
|
|
26
|
+
date: today,
|
|
27
|
+
totalCost: 0,
|
|
28
|
+
perUser: new Map(),
|
|
29
|
+
perModel: new Map(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check budget BEFORE making an LLM call.
|
|
35
|
+
* Returns modified provider/model if downgrade mode triggers,
|
|
36
|
+
* throws ToadBudgetExceededError if block mode triggers,
|
|
37
|
+
* returns null if no action needed.
|
|
38
|
+
*/
|
|
39
|
+
checkBefore(provider, model, userId) {
|
|
40
|
+
this.resetIfNewDay();
|
|
41
|
+
const exceeded = this.findExceeded(model, userId);
|
|
42
|
+
if (!exceeded)
|
|
43
|
+
return null;
|
|
44
|
+
if (this.mode === "block") {
|
|
45
|
+
throw new ToadBudgetExceededError(exceeded);
|
|
46
|
+
}
|
|
47
|
+
if (this.mode === "downgrade" && this.downgrade) {
|
|
48
|
+
return this.downgrade({ provider, model });
|
|
49
|
+
}
|
|
50
|
+
// 'warn' mode — continue, warning emitted after call
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** Record cost AFTER a successful LLM call. Returns exceeded info if budget was just crossed. */
|
|
54
|
+
recordCost(cost, model, userId) {
|
|
55
|
+
this.resetIfNewDay();
|
|
56
|
+
this.state.totalCost += cost;
|
|
57
|
+
this.state.perModel.set(model, (this.state.perModel.get(model) ?? 0) + cost);
|
|
58
|
+
if (userId) {
|
|
59
|
+
this.state.perUser.set(userId, (this.state.perUser.get(userId) ?? 0) + cost);
|
|
60
|
+
}
|
|
61
|
+
return this.findExceeded(model, userId);
|
|
62
|
+
}
|
|
63
|
+
/** Find which budget (if any) is exceeded. */
|
|
64
|
+
findExceeded(model, userId) {
|
|
65
|
+
if (this.config.daily !== undefined &&
|
|
66
|
+
this.state.totalCost >= this.config.daily) {
|
|
67
|
+
return {
|
|
68
|
+
budget: "daily",
|
|
69
|
+
limit: this.config.daily,
|
|
70
|
+
current: this.state.totalCost,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const modelLimit = this.config.perModel?.[model];
|
|
74
|
+
if (modelLimit !== undefined) {
|
|
75
|
+
const modelCost = this.state.perModel.get(model) ?? 0;
|
|
76
|
+
if (modelCost >= modelLimit) {
|
|
77
|
+
return {
|
|
78
|
+
budget: "perModel",
|
|
79
|
+
limit: modelLimit,
|
|
80
|
+
current: modelCost,
|
|
81
|
+
model,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (this.config.perUser !== undefined && userId) {
|
|
86
|
+
const userCost = this.state.perUser.get(userId) ?? 0;
|
|
87
|
+
if (userCost >= this.config.perUser) {
|
|
88
|
+
return {
|
|
89
|
+
budget: "perUser",
|
|
90
|
+
limit: this.config.perUser,
|
|
91
|
+
current: userCost,
|
|
92
|
+
userId,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
/** Get current budget usage as percentage (0-100+). */
|
|
99
|
+
getUsagePercent() {
|
|
100
|
+
if (this.config.daily === undefined || this.config.daily === 0)
|
|
101
|
+
return 0;
|
|
102
|
+
this.resetIfNewDay();
|
|
103
|
+
return (this.state.totalCost / this.config.daily) * 100;
|
|
104
|
+
}
|
|
105
|
+
/** Get current state snapshot (for persistence or debugging). */
|
|
106
|
+
getState() {
|
|
107
|
+
this.resetIfNewDay();
|
|
108
|
+
return this.state;
|
|
109
|
+
}
|
|
110
|
+
/** Restore state from persistence (e.g., on startup). */
|
|
111
|
+
restoreState(saved) {
|
|
112
|
+
if (saved.date !== todayUTC())
|
|
113
|
+
return; // stale state, ignore
|
|
114
|
+
this.state = {
|
|
115
|
+
date: saved.date,
|
|
116
|
+
totalCost: saved.totalCost,
|
|
117
|
+
perUser: new Map(Object.entries(saved.perUser)),
|
|
118
|
+
perModel: new Map(Object.entries(saved.perModel)),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Budget configuration for initObservability(). */
|
|
2
|
+
export interface BudgetConfig {
|
|
3
|
+
/** Max daily spend in USD for the entire application. */
|
|
4
|
+
readonly daily?: number | undefined;
|
|
5
|
+
/** Max daily spend in USD per user. Requires userId in traceLLMCall attributes. */
|
|
6
|
+
readonly perUser?: number | undefined;
|
|
7
|
+
/** Max daily spend in USD per model. */
|
|
8
|
+
readonly perModel?: Readonly<Record<string, number>> | undefined;
|
|
9
|
+
}
|
|
10
|
+
/** What happens when a budget is exceeded. */
|
|
11
|
+
export type BudgetExceededMode = "warn" | "block" | "downgrade";
|
|
12
|
+
/** Callback for 'downgrade' mode — receives original input, returns modified input. */
|
|
13
|
+
export type DowngradeCallback = (original: {
|
|
14
|
+
readonly provider: string;
|
|
15
|
+
readonly model: string;
|
|
16
|
+
}) => {
|
|
17
|
+
readonly provider: string;
|
|
18
|
+
readonly model: string;
|
|
19
|
+
};
|
|
20
|
+
/** Internal state tracking daily spend. */
|
|
21
|
+
export interface BudgetState {
|
|
22
|
+
date: string;
|
|
23
|
+
totalCost: number;
|
|
24
|
+
perUser: Map<string, number>;
|
|
25
|
+
perModel: Map<string, number>;
|
|
26
|
+
}
|
|
27
|
+
/** Info about which budget was exceeded. */
|
|
28
|
+
export interface BudgetExceededInfo {
|
|
29
|
+
readonly budget: "daily" | "perUser" | "perModel";
|
|
30
|
+
readonly limit: number;
|
|
31
|
+
readonly current: number;
|
|
32
|
+
readonly model?: string | undefined;
|
|
33
|
+
readonly userId?: string | undefined;
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync, spawn } from "node:child_process";
|
|
3
|
-
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { initObservability, traceLLMCall, shutdown, calculateCost, } from "./index.js";
|
|
7
7
|
const INFRA_DIR = "infra/toad-eye";
|
|
8
|
+
const DEMO_DELAY_MIN_MS = 200;
|
|
9
|
+
const DEMO_DELAY_MAX_MS = 2000;
|
|
10
|
+
const DEMO_ERROR_RATE = 0.1;
|
|
11
|
+
const DEMO_TICK_INTERVAL_MS = 2000;
|
|
12
|
+
const DEMO_TOKEN_INPUT_RANGE = [50, 500];
|
|
13
|
+
const DEMO_TOKEN_OUTPUT_RANGE = [20, 300];
|
|
8
14
|
const SERVICES = [
|
|
9
15
|
{ name: "Grafana", url: "http://localhost:3100", login: "admin / admin" },
|
|
10
16
|
{ name: "Jaeger UI", url: "http://localhost:16686" },
|
|
@@ -19,6 +25,14 @@ function getTemplatesDir() {
|
|
|
19
25
|
function getComposeFile() {
|
|
20
26
|
return join(process.cwd(), INFRA_DIR, "docker-compose.yml");
|
|
21
27
|
}
|
|
28
|
+
function requireInfra(message = `Run \`npx toad-eye init\` first.`) {
|
|
29
|
+
const composeFile = getComposeFile();
|
|
30
|
+
if (!existsSync(composeFile)) {
|
|
31
|
+
console.error(`\u274c ${INFRA_DIR}/ not found. ${message}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
return composeFile;
|
|
35
|
+
}
|
|
22
36
|
function init() {
|
|
23
37
|
const dest = join(process.cwd(), INFRA_DIR);
|
|
24
38
|
if (existsSync(dest)) {
|
|
@@ -39,32 +53,20 @@ function init() {
|
|
|
39
53
|
console.log(" npx toad-eye status Check running services");
|
|
40
54
|
}
|
|
41
55
|
function up() {
|
|
42
|
-
const composeFile =
|
|
43
|
-
if (!existsSync(composeFile)) {
|
|
44
|
-
console.error(`\u274c ${INFRA_DIR}/ not found. Run \`npx toad-eye init\` first.`);
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
56
|
+
const composeFile = requireInfra();
|
|
47
57
|
console.log("\u{1f438} Starting observability stack...");
|
|
48
58
|
execSync(`docker compose -f ${composeFile} up -d`, { stdio: "inherit" });
|
|
49
59
|
console.log();
|
|
50
60
|
status();
|
|
51
61
|
}
|
|
52
62
|
function down() {
|
|
53
|
-
const composeFile =
|
|
54
|
-
if (!existsSync(composeFile)) {
|
|
55
|
-
console.error(`\u274c ${INFRA_DIR}/ not found. Nothing to stop.`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
63
|
+
const composeFile = requireInfra("Nothing to stop.");
|
|
58
64
|
console.log("\u{1f44b} Stopping observability stack...");
|
|
59
65
|
execSync(`docker compose -f ${composeFile} down`, { stdio: "inherit" });
|
|
60
66
|
console.log("\u2705 Stack stopped.");
|
|
61
67
|
}
|
|
62
68
|
function status() {
|
|
63
|
-
const composeFile =
|
|
64
|
-
if (!existsSync(composeFile)) {
|
|
65
|
-
console.error(`\u274c ${INFRA_DIR}/ not found. Run \`npx toad-eye init\` first.`);
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
69
|
+
const composeFile = requireInfra();
|
|
68
70
|
try {
|
|
69
71
|
const output = execSync(`docker compose -f ${composeFile} ps --format json`, {
|
|
70
72
|
encoding: "utf-8",
|
|
@@ -104,12 +106,12 @@ function pickRandom(arr) {
|
|
|
104
106
|
return arr[randomBetween(0, arr.length - 1)];
|
|
105
107
|
}
|
|
106
108
|
async function simulateLLMCall(provider, model, prompt) {
|
|
107
|
-
await new Promise((r) => setTimeout(r, randomBetween(
|
|
108
|
-
if (Math.random() <
|
|
109
|
+
await new Promise((r) => setTimeout(r, randomBetween(DEMO_DELAY_MIN_MS, DEMO_DELAY_MAX_MS)));
|
|
110
|
+
if (Math.random() < DEMO_ERROR_RATE) {
|
|
109
111
|
throw new Error(`${provider} API error: rate limit exceeded`);
|
|
110
112
|
}
|
|
111
|
-
const inputTokens = randomBetween(
|
|
112
|
-
const outputTokens = randomBetween(
|
|
113
|
+
const inputTokens = randomBetween(...DEMO_TOKEN_INPUT_RANGE);
|
|
114
|
+
const outputTokens = randomBetween(...DEMO_TOKEN_OUTPUT_RANGE);
|
|
113
115
|
return {
|
|
114
116
|
completion: `Mock response for: "${prompt}"`,
|
|
115
117
|
inputTokens,
|
|
@@ -118,11 +120,7 @@ async function simulateLLMCall(provider, model, prompt) {
|
|
|
118
120
|
};
|
|
119
121
|
}
|
|
120
122
|
async function demo() {
|
|
121
|
-
|
|
122
|
-
if (!existsSync(composeFile)) {
|
|
123
|
-
console.error(`\u274c ${INFRA_DIR}/ not found. Run \`npx toad-eye init\` first.`);
|
|
124
|
-
process.exit(1);
|
|
125
|
-
}
|
|
123
|
+
requireInfra();
|
|
126
124
|
initObservability({
|
|
127
125
|
serviceName: "toad-eye-demo",
|
|
128
126
|
endpoint: "http://localhost:4318",
|
|
@@ -147,10 +145,46 @@ async function demo() {
|
|
|
147
145
|
}
|
|
148
146
|
};
|
|
149
147
|
const run = () => {
|
|
150
|
-
tick().then(() => setTimeout(run,
|
|
148
|
+
tick().then(() => setTimeout(run, DEMO_TICK_INTERVAL_MS));
|
|
151
149
|
};
|
|
152
150
|
run();
|
|
153
151
|
}
|
|
152
|
+
async function exportTraceCommand() {
|
|
153
|
+
const traceId = process.argv[3];
|
|
154
|
+
if (!traceId) {
|
|
155
|
+
console.error("✗ Usage: npx toad-eye export-trace <trace_id> [--output dir] [--jaeger-url url]");
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const args = process.argv.slice(4);
|
|
159
|
+
let outputDir = process.cwd();
|
|
160
|
+
let jaegerUrl;
|
|
161
|
+
for (let i = 0; i < args.length; i++) {
|
|
162
|
+
if (args[i] === "--output" && args[i + 1]) {
|
|
163
|
+
outputDir = resolve(args[i + 1]);
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
else if (args[i] === "--jaeger-url" && args[i + 1]) {
|
|
167
|
+
jaegerUrl = args[i + 1];
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const { exportTrace } = await import("./export.js");
|
|
172
|
+
try {
|
|
173
|
+
const yaml = await exportTrace(traceId, { jaegerUrl });
|
|
174
|
+
const filename = `trace-${traceId.slice(0, 8)}.eval.yaml`;
|
|
175
|
+
const filepath = join(outputDir, filename);
|
|
176
|
+
if (!existsSync(outputDir)) {
|
|
177
|
+
mkdirSync(outputDir, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
writeFileSync(filepath, yaml, "utf-8");
|
|
180
|
+
console.log(`✅ Exported trace ${traceId} → ${filepath}`);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
184
|
+
console.error(`✗ ${msg}`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
154
188
|
function help() {
|
|
155
189
|
console.log(`
|
|
156
190
|
\u{1f438} toad-eye CLI — observability stack for LLM services
|
|
@@ -160,8 +194,9 @@ Commands:
|
|
|
160
194
|
up Start the stack (OTel Collector + Prometheus + Jaeger + Grafana)
|
|
161
195
|
down Stop the stack
|
|
162
196
|
status Show running services and URLs
|
|
163
|
-
demo
|
|
164
|
-
|
|
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
|
|
165
200
|
`);
|
|
166
201
|
}
|
|
167
202
|
const command = process.argv[2];
|
|
@@ -181,6 +216,9 @@ switch (command) {
|
|
|
181
216
|
case "demo":
|
|
182
217
|
demo();
|
|
183
218
|
break;
|
|
219
|
+
case "export-trace":
|
|
220
|
+
exportTraceCommand();
|
|
221
|
+
break;
|
|
184
222
|
case "help":
|
|
185
223
|
case "--help":
|
|
186
224
|
case "-h":
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function initMetrics(): void;
|
|
2
|
+
export declare function recordRequestDuration(ms: number, provider: string, model: string, attrs?: Record<string, string>): void;
|
|
3
|
+
export declare function recordRequestCost(usd: number, provider: string, model: string, attrs?: Record<string, string>): void;
|
|
4
|
+
export declare function recordTokens(count: number, provider: string, model: string, attrs?: Record<string, string>): void;
|
|
5
|
+
export declare function recordRequest(provider: string, model: string, attrs?: Record<string, string>): void;
|
|
6
|
+
export declare function recordError(provider: string, model: string, attrs?: Record<string, string>): void;
|
|
7
|
+
export declare function recordAgentSteps(stepCount: number): void;
|
|
8
|
+
export declare function recordAgentToolUsage(toolName: string): void;
|
|
9
|
+
export declare function recordGuardEvaluation(ruleName: string): void;
|
|
10
|
+
export declare function recordGuardWouldBlock(ruleName: string): void;
|
|
11
|
+
export declare function recordSemanticDrift(drift: number, provider: string, model: string): void;
|
|
12
|
+
export declare function recordBudgetExceeded(budgetType: string): void;
|
|
13
|
+
export declare function recordBudgetBlocked(budgetType: string): void;
|
|
14
|
+
export declare function recordBudgetDowngraded(budgetType: string): void;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { metrics } from "@opentelemetry/api";
|
|
2
|
+
import { GEN_AI_ATTRS, GEN_AI_METRICS, INSTRUMENTATION_NAME, } from "../types/index.js";
|
|
3
|
+
let requestDuration;
|
|
4
|
+
let requestCost;
|
|
5
|
+
let tokenUsage;
|
|
6
|
+
let requestsTotal;
|
|
7
|
+
let errorsTotal;
|
|
8
|
+
let agentStepsPerQuery;
|
|
9
|
+
let agentToolUsage;
|
|
10
|
+
let guardEvaluations;
|
|
11
|
+
let guardWouldBlock;
|
|
12
|
+
let semanticDrift;
|
|
13
|
+
let budgetExceeded;
|
|
14
|
+
let budgetBlocked;
|
|
15
|
+
let budgetDowngraded;
|
|
16
|
+
let initialized = false;
|
|
17
|
+
export function initMetrics() {
|
|
18
|
+
if (initialized)
|
|
19
|
+
return;
|
|
20
|
+
const meter = metrics.getMeter(INSTRUMENTATION_NAME);
|
|
21
|
+
requestDuration = meter.createHistogram(GEN_AI_METRICS.REQUEST_DURATION, {
|
|
22
|
+
description: "GenAI operation duration in milliseconds",
|
|
23
|
+
unit: "ms",
|
|
24
|
+
});
|
|
25
|
+
requestCost = meter.createHistogram(GEN_AI_METRICS.REQUEST_COST, {
|
|
26
|
+
description: "GenAI request cost in USD",
|
|
27
|
+
unit: "USD",
|
|
28
|
+
});
|
|
29
|
+
tokenUsage = meter.createCounter(GEN_AI_METRICS.TOKEN_USAGE, {
|
|
30
|
+
description: "Total tokens consumed",
|
|
31
|
+
});
|
|
32
|
+
requestsTotal = meter.createCounter(GEN_AI_METRICS.REQUESTS, {
|
|
33
|
+
description: "Total GenAI requests",
|
|
34
|
+
});
|
|
35
|
+
errorsTotal = meter.createCounter(GEN_AI_METRICS.ERRORS, {
|
|
36
|
+
description: "Total failed GenAI requests",
|
|
37
|
+
});
|
|
38
|
+
agentStepsPerQuery = meter.createHistogram(GEN_AI_METRICS.AGENT_STEPS_PER_QUERY, {
|
|
39
|
+
description: "Number of agent steps per query",
|
|
40
|
+
});
|
|
41
|
+
agentToolUsage = meter.createCounter(GEN_AI_METRICS.AGENT_TOOL_USAGE, {
|
|
42
|
+
description: "Agent tool invocations by tool name",
|
|
43
|
+
});
|
|
44
|
+
guardEvaluations = meter.createCounter(GEN_AI_METRICS.GUARD_EVALUATIONS, {
|
|
45
|
+
description: "Total guard evaluations (shadow + enforce)",
|
|
46
|
+
});
|
|
47
|
+
guardWouldBlock = meter.createCounter(GEN_AI_METRICS.GUARD_WOULD_BLOCK, {
|
|
48
|
+
description: "Guard evaluations that would have blocked the response",
|
|
49
|
+
});
|
|
50
|
+
semanticDrift = meter.createHistogram(GEN_AI_METRICS.SEMANTIC_DRIFT, {
|
|
51
|
+
description: "Semantic drift from baseline (0 = identical, 1 = completely different)",
|
|
52
|
+
});
|
|
53
|
+
budgetExceeded = meter.createCounter(GEN_AI_METRICS.BUDGET_EXCEEDED, {
|
|
54
|
+
description: "Number of times a budget limit was exceeded",
|
|
55
|
+
});
|
|
56
|
+
budgetBlocked = meter.createCounter(GEN_AI_METRICS.BUDGET_BLOCKED, {
|
|
57
|
+
description: "Number of LLM calls blocked due to budget limits",
|
|
58
|
+
});
|
|
59
|
+
budgetDowngraded = meter.createCounter(GEN_AI_METRICS.BUDGET_DOWNGRADED, {
|
|
60
|
+
description: "Number of LLM calls downgraded due to budget limits",
|
|
61
|
+
});
|
|
62
|
+
initialized = true;
|
|
63
|
+
}
|
|
64
|
+
/** Build metric labels: provider + model + optional FinOps attributes. */
|
|
65
|
+
function baseLabels(provider, model, attrs) {
|
|
66
|
+
return {
|
|
67
|
+
[GEN_AI_ATTRS.PROVIDER]: provider,
|
|
68
|
+
[GEN_AI_ATTRS.REQUEST_MODEL]: model,
|
|
69
|
+
...attrs,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function recordRequestDuration(ms, provider, model, attrs) {
|
|
73
|
+
requestDuration.record(ms, baseLabels(provider, model, attrs));
|
|
74
|
+
}
|
|
75
|
+
export function recordRequestCost(usd, provider, model, attrs) {
|
|
76
|
+
requestCost.record(usd, baseLabels(provider, model, attrs));
|
|
77
|
+
}
|
|
78
|
+
export function recordTokens(count, provider, model, attrs) {
|
|
79
|
+
tokenUsage.add(count, baseLabels(provider, model, attrs));
|
|
80
|
+
}
|
|
81
|
+
export function recordRequest(provider, model, attrs) {
|
|
82
|
+
requestsTotal.add(1, baseLabels(provider, model, attrs));
|
|
83
|
+
}
|
|
84
|
+
export function recordError(provider, model, attrs) {
|
|
85
|
+
errorsTotal.add(1, baseLabels(provider, model, attrs));
|
|
86
|
+
}
|
|
87
|
+
export function recordAgentSteps(stepCount) {
|
|
88
|
+
agentStepsPerQuery.record(stepCount);
|
|
89
|
+
}
|
|
90
|
+
export function recordAgentToolUsage(toolName) {
|
|
91
|
+
agentToolUsage.add(1, {
|
|
92
|
+
[GEN_AI_ATTRS.AGENT_TOOL_NAME]: toolName,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export function recordGuardEvaluation(ruleName) {
|
|
96
|
+
guardEvaluations.add(1, {
|
|
97
|
+
[GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export function recordGuardWouldBlock(ruleName) {
|
|
101
|
+
guardWouldBlock.add(1, {
|
|
102
|
+
[GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export function recordSemanticDrift(drift, provider, model) {
|
|
106
|
+
semanticDrift.record(drift, {
|
|
107
|
+
[GEN_AI_ATTRS.PROVIDER]: provider,
|
|
108
|
+
[GEN_AI_ATTRS.REQUEST_MODEL]: model,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export function recordBudgetExceeded(budgetType) {
|
|
112
|
+
budgetExceeded.add(1, { budget_type: budgetType });
|
|
113
|
+
}
|
|
114
|
+
export function recordBudgetBlocked(budgetType) {
|
|
115
|
+
budgetBlocked.add(1, { budget_type: budgetType });
|
|
116
|
+
}
|
|
117
|
+
export function recordBudgetDowngraded(budgetType) {
|
|
118
|
+
budgetDowngraded.add(1, { budget_type: budgetType });
|
|
119
|
+
}
|