toad-eye 0.2.1 → 1.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/README.md +9 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +123 -0
- package/{src/index.ts → dist/index.d.ts} +1 -7
- package/dist/index.js +3 -0
- package/dist/metrics.d.ts +6 -0
- package/dist/metrics.js +46 -0
- package/dist/spans.d.ts +16 -0
- package/dist/spans.js +61 -0
- package/dist/tracer.d.ts +4 -0
- package/dist/tracer.js +46 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +32 -0
- package/package.json +17 -5
- package/templates/docker-compose.yml +40 -0
- package/templates/grafana/dashboards/dashboards.yml +8 -0
- package/templates/grafana/dashboards/llm-overview.json +173 -0
- package/templates/grafana/datasources.yml +15 -0
- package/templates/otel-collector.yml +29 -0
- package/templates/prometheus.yml +7 -0
- package/src/metrics.ts +0 -69
- package/src/spans.ts +0 -103
- package/src/tracer.ts +0 -57
- package/src/types.ts +0 -76
- package/tsconfig.json +0 -5
package/README.md
CHANGED
|
@@ -18,13 +18,19 @@ One-line instrumentation for any LLM service — get traces, metrics, and Grafan
|
|
|
18
18
|
|
|
19
19
|
LLM APIs are black boxes — you don't know what they cost, how slow they are, or why they fail. toad-eye gives you full visibility with one line of code.
|
|
20
20
|
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install toad-eye
|
|
25
|
+
```
|
|
26
|
+
|
|
21
27
|
## Architecture
|
|
22
28
|
|
|
23
29
|
```
|
|
24
30
|
Your LLM service
|
|
25
31
|
│
|
|
26
32
|
▼
|
|
27
|
-
|
|
33
|
+
toad-eye
|
|
28
34
|
│
|
|
29
35
|
▼ OTLP/HTTP
|
|
30
36
|
OTel Collector
|
|
@@ -51,7 +57,7 @@ npm run load --workspace=demo # send test traffic
|
|
|
51
57
|
## Usage
|
|
52
58
|
|
|
53
59
|
```typescript
|
|
54
|
-
import { initObservability, traceLLMCall } from "
|
|
60
|
+
import { initObservability, traceLLMCall } from "toad-eye";
|
|
55
61
|
|
|
56
62
|
// one-line init
|
|
57
63
|
initObservability({
|
|
@@ -108,7 +114,7 @@ All metrics are labeled with `provider` and `model` for filtering and grouping.
|
|
|
108
114
|
## Project structure
|
|
109
115
|
|
|
110
116
|
```
|
|
111
|
-
packages/instrumentation/ — NPM package (
|
|
117
|
+
packages/instrumentation/ — NPM package (toad-eye)
|
|
112
118
|
demo/ — mock LLM service for testing
|
|
113
119
|
infra/ — docker-compose stack (OTel + Prometheus + Jaeger + Grafana)
|
|
114
120
|
```
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const INFRA_DIR = "infra/toad-eye";
|
|
7
|
+
const SERVICES = [
|
|
8
|
+
{ name: "Grafana", url: "http://localhost:3100", login: "admin / admin" },
|
|
9
|
+
{ name: "Jaeger UI", url: "http://localhost:16686" },
|
|
10
|
+
{ name: "Prometheus", url: "http://localhost:9090" },
|
|
11
|
+
{ name: "OTel Collector", url: "http://localhost:4318" },
|
|
12
|
+
];
|
|
13
|
+
function getTemplatesDir() {
|
|
14
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
15
|
+
// dist/cli.js -> package root -> templates/
|
|
16
|
+
return join(thisFile, "..", "..", "templates");
|
|
17
|
+
}
|
|
18
|
+
function getComposeFile() {
|
|
19
|
+
return join(process.cwd(), INFRA_DIR, "docker-compose.yml");
|
|
20
|
+
}
|
|
21
|
+
function init() {
|
|
22
|
+
const dest = join(process.cwd(), INFRA_DIR);
|
|
23
|
+
if (existsSync(dest)) {
|
|
24
|
+
console.log(`\u2705 ${INFRA_DIR}/ already exists, skipping.`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const templatesDir = getTemplatesDir();
|
|
28
|
+
if (!existsSync(templatesDir)) {
|
|
29
|
+
console.error(`\u274c Templates not found at ${templatesDir}. Reinstall toad-eye.`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
mkdirSync(dest, { recursive: true });
|
|
33
|
+
cpSync(templatesDir, dest, { recursive: true });
|
|
34
|
+
console.log(`\u2705 Created ${INFRA_DIR}/ with observability stack config.`);
|
|
35
|
+
console.log();
|
|
36
|
+
console.log("Next steps:");
|
|
37
|
+
console.log(" npx toad-eye up Start the stack");
|
|
38
|
+
console.log(" npx toad-eye status Check running services");
|
|
39
|
+
}
|
|
40
|
+
function up() {
|
|
41
|
+
const composeFile = getComposeFile();
|
|
42
|
+
if (!existsSync(composeFile)) {
|
|
43
|
+
console.error(`\u274c ${INFRA_DIR}/ not found. Run \`npx toad-eye init\` first.`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
console.log("\u{1f438} Starting observability stack...");
|
|
47
|
+
execSync(`docker compose -f ${composeFile} up -d`, { stdio: "inherit" });
|
|
48
|
+
console.log();
|
|
49
|
+
status();
|
|
50
|
+
}
|
|
51
|
+
function down() {
|
|
52
|
+
const composeFile = getComposeFile();
|
|
53
|
+
if (!existsSync(composeFile)) {
|
|
54
|
+
console.error(`\u274c ${INFRA_DIR}/ not found. Nothing to stop.`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
console.log("\u{1f44b} Stopping observability stack...");
|
|
58
|
+
execSync(`docker compose -f ${composeFile} down`, { stdio: "inherit" });
|
|
59
|
+
console.log("\u2705 Stack stopped.");
|
|
60
|
+
}
|
|
61
|
+
function status() {
|
|
62
|
+
const composeFile = getComposeFile();
|
|
63
|
+
if (!existsSync(composeFile)) {
|
|
64
|
+
console.error(`\u274c ${INFRA_DIR}/ not found. Run \`npx toad-eye init\` first.`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const output = execSync(`docker compose -f ${composeFile} ps --format json`, {
|
|
69
|
+
encoding: "utf-8",
|
|
70
|
+
});
|
|
71
|
+
const lines = output.trim().split("\n").filter(Boolean);
|
|
72
|
+
const containers = lines.map((line) => JSON.parse(line));
|
|
73
|
+
console.log("\u{1f438} toad-eye stack:");
|
|
74
|
+
console.log();
|
|
75
|
+
for (const svc of SERVICES) {
|
|
76
|
+
const container = containers.find((c) => svc.name.toLowerCase().includes(c.Service.toLowerCase()));
|
|
77
|
+
const state = container?.State === "running" ? "\u{1f7e2}" : "\u{1f534}";
|
|
78
|
+
const loginInfo = "login" in svc ? ` (${svc.login})` : "";
|
|
79
|
+
console.log(` ${state} ${svc.name.padEnd(16)} ${svc.url}${loginInfo}`);
|
|
80
|
+
}
|
|
81
|
+
console.log();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
console.log("Could not check container status. Is Docker running?");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function help() {
|
|
88
|
+
console.log(`
|
|
89
|
+
\u{1f438} toad-eye CLI — observability stack for LLM services
|
|
90
|
+
|
|
91
|
+
Commands:
|
|
92
|
+
init Copy observability configs into your project
|
|
93
|
+
up Start the stack (OTel Collector + Prometheus + Jaeger + Grafana)
|
|
94
|
+
down Stop the stack
|
|
95
|
+
status Show running services and URLs
|
|
96
|
+
help Show this message
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
const command = process.argv[2];
|
|
100
|
+
switch (command) {
|
|
101
|
+
case "init":
|
|
102
|
+
init();
|
|
103
|
+
break;
|
|
104
|
+
case "up":
|
|
105
|
+
up();
|
|
106
|
+
break;
|
|
107
|
+
case "down":
|
|
108
|
+
down();
|
|
109
|
+
break;
|
|
110
|
+
case "status":
|
|
111
|
+
status();
|
|
112
|
+
break;
|
|
113
|
+
case "help":
|
|
114
|
+
case "--help":
|
|
115
|
+
case "-h":
|
|
116
|
+
case undefined:
|
|
117
|
+
help();
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
console.error(`Unknown command: ${command}`);
|
|
121
|
+
help();
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
export { initObservability, shutdown, getConfig } from "./tracer.js";
|
|
2
2
|
export { traceLLMCall } from "./spans.js";
|
|
3
3
|
export type { LLMCallInput, LLMCallOutput } from "./spans.js";
|
|
4
|
-
export type {
|
|
5
|
-
ToadEyeConfig,
|
|
6
|
-
LLMSpanAttributes,
|
|
7
|
-
LLMProvider,
|
|
8
|
-
SpanStatus,
|
|
9
|
-
MetricName,
|
|
10
|
-
} from "./types.js";
|
|
4
|
+
export type { ToadEyeConfig, LLMSpanAttributes, LLMProvider, SpanStatus, MetricName, } from "./types.js";
|
|
11
5
|
export { LLM_METRICS, LLM_ATTRS } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function initMetrics(): void;
|
|
2
|
+
export declare function recordRequestDuration(ms: number, provider: string, model: string): void;
|
|
3
|
+
export declare function recordRequestCost(usd: number, provider: string, model: string): void;
|
|
4
|
+
export declare function recordTokens(count: number, provider: string, model: string): void;
|
|
5
|
+
export declare function recordRequest(provider: string, model: string): void;
|
|
6
|
+
export declare function recordError(provider: string, model: string): void;
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { metrics } from "@opentelemetry/api";
|
|
2
|
+
import { LLM_METRICS, INSTRUMENTATION_NAME } from "./types.js";
|
|
3
|
+
let requestDuration;
|
|
4
|
+
let requestCost;
|
|
5
|
+
let tokensTotal;
|
|
6
|
+
let requestsTotal;
|
|
7
|
+
let errorsTotal;
|
|
8
|
+
let initialized = false;
|
|
9
|
+
export function initMetrics() {
|
|
10
|
+
if (initialized)
|
|
11
|
+
return;
|
|
12
|
+
const meter = metrics.getMeter(INSTRUMENTATION_NAME);
|
|
13
|
+
requestDuration = meter.createHistogram(LLM_METRICS.REQUEST_DURATION, {
|
|
14
|
+
description: "LLM request duration in milliseconds",
|
|
15
|
+
unit: "ms",
|
|
16
|
+
});
|
|
17
|
+
requestCost = meter.createHistogram(LLM_METRICS.REQUEST_COST, {
|
|
18
|
+
description: "LLM request cost in USD",
|
|
19
|
+
unit: "USD",
|
|
20
|
+
});
|
|
21
|
+
tokensTotal = meter.createCounter(LLM_METRICS.TOKENS, {
|
|
22
|
+
description: "Total tokens consumed",
|
|
23
|
+
});
|
|
24
|
+
requestsTotal = meter.createCounter(LLM_METRICS.REQUESTS, {
|
|
25
|
+
description: "Total LLM requests",
|
|
26
|
+
});
|
|
27
|
+
errorsTotal = meter.createCounter(LLM_METRICS.ERRORS, {
|
|
28
|
+
description: "Total failed LLM requests",
|
|
29
|
+
});
|
|
30
|
+
initialized = true;
|
|
31
|
+
}
|
|
32
|
+
export function recordRequestDuration(ms, provider, model) {
|
|
33
|
+
requestDuration.record(ms, { provider, model });
|
|
34
|
+
}
|
|
35
|
+
export function recordRequestCost(usd, provider, model) {
|
|
36
|
+
requestCost.record(usd, { provider, model });
|
|
37
|
+
}
|
|
38
|
+
export function recordTokens(count, provider, model) {
|
|
39
|
+
tokensTotal.add(count, { provider, model });
|
|
40
|
+
}
|
|
41
|
+
export function recordRequest(provider, model) {
|
|
42
|
+
requestsTotal.add(1, { provider, model });
|
|
43
|
+
}
|
|
44
|
+
export function recordError(provider, model) {
|
|
45
|
+
errorsTotal.add(1, { provider, model });
|
|
46
|
+
}
|
package/dist/spans.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LLMSpanAttributes } from "./types.js";
|
|
2
|
+
/** Input for traceLLMCall — what the user knows before calling the LLM */
|
|
3
|
+
export interface LLMCallInput {
|
|
4
|
+
readonly provider: LLMSpanAttributes["provider"];
|
|
5
|
+
readonly model: string;
|
|
6
|
+
readonly prompt: string;
|
|
7
|
+
readonly temperature?: number | undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Output from the LLM call — what comes back */
|
|
10
|
+
export interface LLMCallOutput {
|
|
11
|
+
readonly completion: string;
|
|
12
|
+
readonly inputTokens: number;
|
|
13
|
+
readonly outputTokens: number;
|
|
14
|
+
readonly cost: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function traceLLMCall(input: LLMCallInput, fn: () => Promise<LLMCallOutput>): Promise<LLMCallOutput>;
|
package/dist/spans.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
|
2
|
+
import { LLM_ATTRS, INSTRUMENTATION_NAME } from "./types.js";
|
|
3
|
+
import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, } from "./metrics.js";
|
|
4
|
+
import { getConfig } from "./tracer.js";
|
|
5
|
+
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
6
|
+
function shouldRecordContent() {
|
|
7
|
+
return getConfig()?.recordContent !== false;
|
|
8
|
+
}
|
|
9
|
+
function setBaseAttributes(span, input) {
|
|
10
|
+
span.setAttributes({
|
|
11
|
+
[LLM_ATTRS.PROVIDER]: input.provider,
|
|
12
|
+
[LLM_ATTRS.MODEL]: input.model,
|
|
13
|
+
[LLM_ATTRS.TEMPERATURE]: input.temperature ?? 1.0,
|
|
14
|
+
...(shouldRecordContent() && { [LLM_ATTRS.PROMPT]: input.prompt }),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export async function traceLLMCall(input, fn) {
|
|
18
|
+
return tracer.startActiveSpan(`llm.${input.provider}.${input.model}`, async (span) => {
|
|
19
|
+
const start = performance.now();
|
|
20
|
+
setBaseAttributes(span, input);
|
|
21
|
+
try {
|
|
22
|
+
const output = await fn();
|
|
23
|
+
const duration = performance.now() - start;
|
|
24
|
+
span.setAttributes({
|
|
25
|
+
...(shouldRecordContent() && {
|
|
26
|
+
[LLM_ATTRS.COMPLETION]: output.completion,
|
|
27
|
+
}),
|
|
28
|
+
[LLM_ATTRS.INPUT_TOKENS]: output.inputTokens,
|
|
29
|
+
[LLM_ATTRS.OUTPUT_TOKENS]: output.outputTokens,
|
|
30
|
+
[LLM_ATTRS.COST]: output.cost,
|
|
31
|
+
[LLM_ATTRS.STATUS]: "success",
|
|
32
|
+
});
|
|
33
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
34
|
+
recordRequest(input.provider, input.model);
|
|
35
|
+
recordRequestDuration(duration, input.provider, input.model);
|
|
36
|
+
recordRequestCost(output.cost, input.provider, input.model);
|
|
37
|
+
recordTokens(output.inputTokens + output.outputTokens, input.provider, input.model);
|
|
38
|
+
return output;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const duration = performance.now() - start;
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
span.setAttributes({
|
|
44
|
+
[LLM_ATTRS.COMPLETION]: "",
|
|
45
|
+
[LLM_ATTRS.INPUT_TOKENS]: 0,
|
|
46
|
+
[LLM_ATTRS.OUTPUT_TOKENS]: 0,
|
|
47
|
+
[LLM_ATTRS.COST]: 0,
|
|
48
|
+
[LLM_ATTRS.STATUS]: "error",
|
|
49
|
+
[LLM_ATTRS.ERROR]: message,
|
|
50
|
+
});
|
|
51
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
52
|
+
recordRequest(input.provider, input.model);
|
|
53
|
+
recordRequestDuration(duration, input.provider, input.model);
|
|
54
|
+
recordError(input.provider, input.model);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
span.end();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
package/dist/tracer.d.ts
ADDED
package/dist/tracer.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
const DEFAULT_ENDPOINT = "http://localhost:4318";
|
|
9
|
+
let sdk = null;
|
|
10
|
+
let currentConfig = null;
|
|
11
|
+
export function getConfig() {
|
|
12
|
+
return currentConfig;
|
|
13
|
+
}
|
|
14
|
+
export function initObservability(config) {
|
|
15
|
+
if (sdk)
|
|
16
|
+
return;
|
|
17
|
+
const endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
18
|
+
const resource = resourceFromAttributes({
|
|
19
|
+
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
20
|
+
});
|
|
21
|
+
const traceExporter = new OTLPTraceExporter({
|
|
22
|
+
url: `${endpoint}/v1/traces`,
|
|
23
|
+
});
|
|
24
|
+
const metricExporter = new OTLPMetricExporter({
|
|
25
|
+
url: `${endpoint}/v1/metrics`,
|
|
26
|
+
});
|
|
27
|
+
const metricReader = new PeriodicExportingMetricReader({
|
|
28
|
+
exporter: metricExporter,
|
|
29
|
+
exportIntervalMillis: 5000,
|
|
30
|
+
});
|
|
31
|
+
sdk = new NodeSDK({
|
|
32
|
+
resource,
|
|
33
|
+
traceExporter,
|
|
34
|
+
metricReader,
|
|
35
|
+
});
|
|
36
|
+
sdk.start();
|
|
37
|
+
currentConfig = config;
|
|
38
|
+
initMetrics();
|
|
39
|
+
}
|
|
40
|
+
export async function shutdown() {
|
|
41
|
+
if (!sdk)
|
|
42
|
+
return;
|
|
43
|
+
await sdk.shutdown();
|
|
44
|
+
sdk = null;
|
|
45
|
+
currentConfig = null;
|
|
46
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export declare const INSTRUMENTATION_NAME = "toad-eye";
|
|
2
|
+
/**
|
|
3
|
+
* LLM providers supported by toad-eye
|
|
4
|
+
*/
|
|
5
|
+
export type LLMProvider = "anthropic" | "gemini" | "openai";
|
|
6
|
+
/**
|
|
7
|
+
* Span status
|
|
8
|
+
*/
|
|
9
|
+
export type SpanStatus = "success" | "error";
|
|
10
|
+
/**
|
|
11
|
+
* Attributes attached to every LLM span (trace).
|
|
12
|
+
* These become searchable fields in Jaeger and filterable dimensions in Grafana.
|
|
13
|
+
*/
|
|
14
|
+
export interface LLMSpanAttributes {
|
|
15
|
+
readonly provider: LLMProvider;
|
|
16
|
+
readonly model: string;
|
|
17
|
+
readonly prompt: string;
|
|
18
|
+
readonly completion: string;
|
|
19
|
+
readonly inputTokens: number;
|
|
20
|
+
readonly outputTokens: number;
|
|
21
|
+
readonly cost: number;
|
|
22
|
+
readonly temperature: number;
|
|
23
|
+
readonly status: SpanStatus;
|
|
24
|
+
readonly error?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Metric names exported to Prometheus.
|
|
28
|
+
*
|
|
29
|
+
* Naming convention follows OpenTelemetry semantic conventions:
|
|
30
|
+
* - dots as separators: "llm.request.duration"
|
|
31
|
+
* - Prometheus auto-converts dots to underscores: llm_request_duration
|
|
32
|
+
* - Prometheus adds _total suffix to counters automatically — do NOT include it in the name
|
|
33
|
+
*/
|
|
34
|
+
export declare const LLM_METRICS: {
|
|
35
|
+
readonly REQUEST_DURATION: "llm.request.duration";
|
|
36
|
+
readonly REQUEST_COST: "llm.request.cost";
|
|
37
|
+
readonly TOKENS: "llm.tokens";
|
|
38
|
+
readonly REQUESTS: "llm.requests";
|
|
39
|
+
readonly ERRORS: "llm.errors";
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Span attribute keys attached to every LLM span.
|
|
43
|
+
* Using constants prevents typos and enables rename-refactoring.
|
|
44
|
+
*/
|
|
45
|
+
export declare const LLM_ATTRS: {
|
|
46
|
+
readonly PROVIDER: "llm.provider";
|
|
47
|
+
readonly MODEL: "llm.model";
|
|
48
|
+
readonly PROMPT: "llm.prompt";
|
|
49
|
+
readonly COMPLETION: "llm.completion";
|
|
50
|
+
readonly INPUT_TOKENS: "llm.input_tokens";
|
|
51
|
+
readonly OUTPUT_TOKENS: "llm.output_tokens";
|
|
52
|
+
readonly COST: "llm.cost";
|
|
53
|
+
readonly TEMPERATURE: "llm.temperature";
|
|
54
|
+
readonly STATUS: "llm.status";
|
|
55
|
+
readonly ERROR: "llm.error";
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Type-safe metric name — only values from LLM_METRICS are allowed
|
|
59
|
+
*/
|
|
60
|
+
export type MetricName = (typeof LLM_METRICS)[keyof typeof LLM_METRICS];
|
|
61
|
+
/**
|
|
62
|
+
* Configuration for initObservability().
|
|
63
|
+
* This is what the user passes when connecting toad-eye to their service.
|
|
64
|
+
*/
|
|
65
|
+
export interface ToadEyeConfig {
|
|
66
|
+
readonly serviceName: string;
|
|
67
|
+
readonly endpoint?: string | undefined;
|
|
68
|
+
readonly recordContent?: boolean | undefined;
|
|
69
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const INSTRUMENTATION_NAME = "toad-eye";
|
|
2
|
+
/**
|
|
3
|
+
* Metric names exported to Prometheus.
|
|
4
|
+
*
|
|
5
|
+
* Naming convention follows OpenTelemetry semantic conventions:
|
|
6
|
+
* - dots as separators: "llm.request.duration"
|
|
7
|
+
* - Prometheus auto-converts dots to underscores: llm_request_duration
|
|
8
|
+
* - Prometheus adds _total suffix to counters automatically — do NOT include it in the name
|
|
9
|
+
*/
|
|
10
|
+
export const LLM_METRICS = {
|
|
11
|
+
REQUEST_DURATION: "llm.request.duration",
|
|
12
|
+
REQUEST_COST: "llm.request.cost",
|
|
13
|
+
TOKENS: "llm.tokens",
|
|
14
|
+
REQUESTS: "llm.requests",
|
|
15
|
+
ERRORS: "llm.errors",
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Span attribute keys attached to every LLM span.
|
|
19
|
+
* Using constants prevents typos and enables rename-refactoring.
|
|
20
|
+
*/
|
|
21
|
+
export const LLM_ATTRS = {
|
|
22
|
+
PROVIDER: "llm.provider",
|
|
23
|
+
MODEL: "llm.model",
|
|
24
|
+
PROMPT: "llm.prompt",
|
|
25
|
+
COMPLETION: "llm.completion",
|
|
26
|
+
INPUT_TOKENS: "llm.input_tokens",
|
|
27
|
+
OUTPUT_TOKENS: "llm.output_tokens",
|
|
28
|
+
COST: "llm.cost",
|
|
29
|
+
TEMPERATURE: "llm.temperature",
|
|
30
|
+
STATUS: "llm.status",
|
|
31
|
+
ERROR: "llm.error",
|
|
32
|
+
};
|
package/package.json
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toad-eye",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "One-line OpenTelemetry instrumentation for LLM services",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"toad-eye": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"templates",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.build.json",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
8
20
|
"repository": {
|
|
9
21
|
"type": "git",
|
|
10
|
-
"url": "https://github.com/
|
|
22
|
+
"url": "https://github.com/vola-trebla/toad-eye"
|
|
11
23
|
},
|
|
12
|
-
"homepage": "https://github.com/
|
|
24
|
+
"homepage": "https://github.com/vola-trebla/toad-eye#readme",
|
|
13
25
|
"author": "albertalov",
|
|
14
26
|
"keywords": [
|
|
15
27
|
"opentelemetry",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
services:
|
|
2
|
+
collector:
|
|
3
|
+
image: otel/opentelemetry-collector-contrib:latest
|
|
4
|
+
volumes:
|
|
5
|
+
- ./otel-collector.yml:/etc/otelcol-contrib/config.yaml
|
|
6
|
+
ports:
|
|
7
|
+
- "4318:4318"
|
|
8
|
+
depends_on:
|
|
9
|
+
- jaeger
|
|
10
|
+
- prometheus
|
|
11
|
+
|
|
12
|
+
prometheus:
|
|
13
|
+
image: prom/prometheus:latest
|
|
14
|
+
volumes:
|
|
15
|
+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
|
16
|
+
ports:
|
|
17
|
+
- "9090:9090"
|
|
18
|
+
|
|
19
|
+
jaeger:
|
|
20
|
+
image: jaegertracing/all-in-one:latest
|
|
21
|
+
environment:
|
|
22
|
+
- COLLECTOR_OTLP_ENABLED=true
|
|
23
|
+
ports:
|
|
24
|
+
- "16686:16686"
|
|
25
|
+
- "4317:4317"
|
|
26
|
+
|
|
27
|
+
grafana:
|
|
28
|
+
image: grafana/grafana:latest
|
|
29
|
+
volumes:
|
|
30
|
+
- ./grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml
|
|
31
|
+
- ./grafana/dashboards/dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yml
|
|
32
|
+
- ./grafana/dashboards/llm-overview.json:/etc/grafana/provisioning/dashboards/llm-overview.json
|
|
33
|
+
environment:
|
|
34
|
+
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|
35
|
+
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards/llm-overview.json
|
|
36
|
+
ports:
|
|
37
|
+
- "3100:3000"
|
|
38
|
+
depends_on:
|
|
39
|
+
- prometheus
|
|
40
|
+
- jaeger
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
{
|
|
2
|
+
"annotations": { "list": [] },
|
|
3
|
+
"editable": true,
|
|
4
|
+
"fiscalYearStartMonth": 0,
|
|
5
|
+
"graphTooltip": 1,
|
|
6
|
+
"id": null,
|
|
7
|
+
"links": [],
|
|
8
|
+
"panels": [
|
|
9
|
+
{
|
|
10
|
+
"title": "Request rate (per minute)",
|
|
11
|
+
"type": "timeseries",
|
|
12
|
+
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
|
13
|
+
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
14
|
+
"targets": [
|
|
15
|
+
{
|
|
16
|
+
"expr": "sum(rate(llm_requests_total[1m])) by (provider) * 60",
|
|
17
|
+
"legendFormat": "{{ provider }}"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"fieldConfig": {
|
|
21
|
+
"defaults": {
|
|
22
|
+
"unit": "reqpm",
|
|
23
|
+
"custom": { "drawStyle": "line", "fillOpacity": 15, "lineWidth": 2 }
|
|
24
|
+
},
|
|
25
|
+
"overrides": []
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"title": "Error rate (per minute)",
|
|
30
|
+
"type": "timeseries",
|
|
31
|
+
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
|
32
|
+
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
33
|
+
"targets": [
|
|
34
|
+
{
|
|
35
|
+
"expr": "sum(rate(llm_errors_total[1m])) by (provider) * 60",
|
|
36
|
+
"legendFormat": "{{ provider }}"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"fieldConfig": {
|
|
40
|
+
"defaults": {
|
|
41
|
+
"unit": "reqpm",
|
|
42
|
+
"custom": { "drawStyle": "line", "fillOpacity": 15, "lineWidth": 2 },
|
|
43
|
+
"color": { "mode": "palette-classic" }
|
|
44
|
+
},
|
|
45
|
+
"overrides": []
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"title": "Latency p50 / p95 (ms)",
|
|
50
|
+
"type": "timeseries",
|
|
51
|
+
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
|
52
|
+
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
53
|
+
"targets": [
|
|
54
|
+
{
|
|
55
|
+
"expr": "histogram_quantile(0.50, sum(rate(llm_request_duration_milliseconds_bucket[1m])) by (le, provider))",
|
|
56
|
+
"legendFormat": "p50 {{ provider }}"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"expr": "histogram_quantile(0.95, sum(rate(llm_request_duration_milliseconds_bucket[1m])) by (le, provider))",
|
|
60
|
+
"legendFormat": "p95 {{ provider }}"
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"fieldConfig": {
|
|
64
|
+
"defaults": {
|
|
65
|
+
"unit": "ms",
|
|
66
|
+
"custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2 }
|
|
67
|
+
},
|
|
68
|
+
"overrides": []
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"title": "Cost per minute (USD)",
|
|
73
|
+
"type": "timeseries",
|
|
74
|
+
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
|
75
|
+
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
76
|
+
"targets": [
|
|
77
|
+
{
|
|
78
|
+
"expr": "sum(rate(llm_request_cost_USD_sum[1m])) by (provider) * 60",
|
|
79
|
+
"legendFormat": "{{ provider }}"
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"fieldConfig": {
|
|
83
|
+
"defaults": {
|
|
84
|
+
"unit": "currencyUSD",
|
|
85
|
+
"custom": { "drawStyle": "bars", "fillOpacity": 50, "lineWidth": 1 }
|
|
86
|
+
},
|
|
87
|
+
"overrides": []
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"title": "Total tokens consumed",
|
|
92
|
+
"type": "stat",
|
|
93
|
+
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 16 },
|
|
94
|
+
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
95
|
+
"targets": [
|
|
96
|
+
{
|
|
97
|
+
"expr": "sum(llm_tokens_total)"
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
"fieldConfig": {
|
|
101
|
+
"defaults": {
|
|
102
|
+
"unit": "short",
|
|
103
|
+
"thresholds": {
|
|
104
|
+
"steps": [
|
|
105
|
+
{ "color": "green", "value": null },
|
|
106
|
+
{ "color": "yellow", "value": 100000 },
|
|
107
|
+
{ "color": "red", "value": 1000000 }
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"overrides": []
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"title": "Total requests",
|
|
116
|
+
"type": "stat",
|
|
117
|
+
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 16 },
|
|
118
|
+
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
119
|
+
"targets": [
|
|
120
|
+
{
|
|
121
|
+
"expr": "sum(llm_requests_total)"
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"fieldConfig": {
|
|
125
|
+
"defaults": {
|
|
126
|
+
"unit": "short",
|
|
127
|
+
"thresholds": {
|
|
128
|
+
"steps": [
|
|
129
|
+
{ "color": "green", "value": null },
|
|
130
|
+
{ "color": "yellow", "value": 1000 },
|
|
131
|
+
{ "color": "red", "value": 10000 }
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"overrides": []
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"title": "Total errors",
|
|
140
|
+
"type": "stat",
|
|
141
|
+
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 16 },
|
|
142
|
+
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
143
|
+
"targets": [
|
|
144
|
+
{
|
|
145
|
+
"expr": "sum(llm_errors_total)"
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
"fieldConfig": {
|
|
149
|
+
"defaults": {
|
|
150
|
+
"unit": "short",
|
|
151
|
+
"thresholds": {
|
|
152
|
+
"steps": [
|
|
153
|
+
{ "color": "green", "value": null },
|
|
154
|
+
{ "color": "orange", "value": 10 },
|
|
155
|
+
{ "color": "red", "value": 100 }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
"overrides": []
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
"refresh": "5s",
|
|
164
|
+
"schemaVersion": 39,
|
|
165
|
+
"tags": ["llm", "observability", "toad-eye"],
|
|
166
|
+
"templating": { "list": [] },
|
|
167
|
+
"time": { "from": "now-15m", "to": "now" },
|
|
168
|
+
"timepicker": {},
|
|
169
|
+
"timezone": "browser",
|
|
170
|
+
"title": "toad-eye: LLM observability",
|
|
171
|
+
"uid": "toad-eye-llm-overview",
|
|
172
|
+
"version": 2
|
|
173
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
receivers:
|
|
2
|
+
otlp:
|
|
3
|
+
protocols:
|
|
4
|
+
http:
|
|
5
|
+
endpoint: 0.0.0.0:4318
|
|
6
|
+
|
|
7
|
+
processors:
|
|
8
|
+
batch:
|
|
9
|
+
timeout: 1s
|
|
10
|
+
|
|
11
|
+
exporters:
|
|
12
|
+
otlphttp/jaeger:
|
|
13
|
+
endpoint: http://jaeger:4318
|
|
14
|
+
tls:
|
|
15
|
+
insecure: true
|
|
16
|
+
|
|
17
|
+
prometheus:
|
|
18
|
+
endpoint: 0.0.0.0:8889
|
|
19
|
+
|
|
20
|
+
service:
|
|
21
|
+
pipelines:
|
|
22
|
+
traces:
|
|
23
|
+
receivers: [otlp]
|
|
24
|
+
processors: [batch]
|
|
25
|
+
exporters: [otlphttp/jaeger]
|
|
26
|
+
metrics:
|
|
27
|
+
receivers: [otlp]
|
|
28
|
+
processors: [batch]
|
|
29
|
+
exporters: [prometheus]
|
package/src/metrics.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { metrics } from "@opentelemetry/api";
|
|
2
|
-
import type { Counter, Histogram } from "@opentelemetry/api";
|
|
3
|
-
import { LLM_METRICS, INSTRUMENTATION_NAME } from "./types.js";
|
|
4
|
-
|
|
5
|
-
let requestDuration: Histogram;
|
|
6
|
-
let requestCost: Histogram;
|
|
7
|
-
let tokensTotal: Counter;
|
|
8
|
-
let requestsTotal: Counter;
|
|
9
|
-
let errorsTotal: Counter;
|
|
10
|
-
|
|
11
|
-
let initialized = false;
|
|
12
|
-
|
|
13
|
-
export function initMetrics() {
|
|
14
|
-
if (initialized) return;
|
|
15
|
-
|
|
16
|
-
const meter = metrics.getMeter(INSTRUMENTATION_NAME);
|
|
17
|
-
|
|
18
|
-
requestDuration = meter.createHistogram(LLM_METRICS.REQUEST_DURATION, {
|
|
19
|
-
description: "LLM request duration in milliseconds",
|
|
20
|
-
unit: "ms",
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
requestCost = meter.createHistogram(LLM_METRICS.REQUEST_COST, {
|
|
24
|
-
description: "LLM request cost in USD",
|
|
25
|
-
unit: "USD",
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
tokensTotal = meter.createCounter(LLM_METRICS.TOKENS, {
|
|
29
|
-
description: "Total tokens consumed",
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
requestsTotal = meter.createCounter(LLM_METRICS.REQUESTS, {
|
|
33
|
-
description: "Total LLM requests",
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
errorsTotal = meter.createCounter(LLM_METRICS.ERRORS, {
|
|
37
|
-
description: "Total failed LLM requests",
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
initialized = true;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function recordRequestDuration(
|
|
44
|
-
ms: number,
|
|
45
|
-
provider: string,
|
|
46
|
-
model: string,
|
|
47
|
-
) {
|
|
48
|
-
requestDuration.record(ms, { provider, model });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function recordRequestCost(
|
|
52
|
-
usd: number,
|
|
53
|
-
provider: string,
|
|
54
|
-
model: string,
|
|
55
|
-
) {
|
|
56
|
-
requestCost.record(usd, { provider, model });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function recordTokens(count: number, provider: string, model: string) {
|
|
60
|
-
tokensTotal.add(count, { provider, model });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function recordRequest(provider: string, model: string) {
|
|
64
|
-
requestsTotal.add(1, { provider, model });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function recordError(provider: string, model: string) {
|
|
68
|
-
errorsTotal.add(1, { provider, model });
|
|
69
|
-
}
|
package/src/spans.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { trace, type Span, SpanStatusCode } from "@opentelemetry/api";
|
|
2
|
-
import type { LLMSpanAttributes } from "./types.js";
|
|
3
|
-
import { LLM_ATTRS, INSTRUMENTATION_NAME } from "./types.js";
|
|
4
|
-
import {
|
|
5
|
-
recordRequestDuration,
|
|
6
|
-
recordRequestCost,
|
|
7
|
-
recordTokens,
|
|
8
|
-
recordRequest,
|
|
9
|
-
recordError,
|
|
10
|
-
} from "./metrics.js";
|
|
11
|
-
import { getConfig } from "./tracer.js";
|
|
12
|
-
|
|
13
|
-
/** Input for traceLLMCall — what the user knows before calling the LLM */
|
|
14
|
-
export interface LLMCallInput {
|
|
15
|
-
readonly provider: LLMSpanAttributes["provider"];
|
|
16
|
-
readonly model: string;
|
|
17
|
-
readonly prompt: string;
|
|
18
|
-
readonly temperature?: number | undefined;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Output from the LLM call — what comes back */
|
|
22
|
-
export interface LLMCallOutput {
|
|
23
|
-
readonly completion: string;
|
|
24
|
-
readonly inputTokens: number;
|
|
25
|
-
readonly outputTokens: number;
|
|
26
|
-
readonly cost: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
30
|
-
|
|
31
|
-
function shouldRecordContent() {
|
|
32
|
-
return getConfig()?.recordContent !== false;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function setBaseAttributes(span: Span, input: LLMCallInput) {
|
|
36
|
-
span.setAttributes({
|
|
37
|
-
[LLM_ATTRS.PROVIDER]: input.provider,
|
|
38
|
-
[LLM_ATTRS.MODEL]: input.model,
|
|
39
|
-
[LLM_ATTRS.TEMPERATURE]: input.temperature ?? 1.0,
|
|
40
|
-
...(shouldRecordContent() && { [LLM_ATTRS.PROMPT]: input.prompt }),
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function traceLLMCall(
|
|
45
|
-
input: LLMCallInput,
|
|
46
|
-
fn: () => Promise<LLMCallOutput>,
|
|
47
|
-
): Promise<LLMCallOutput> {
|
|
48
|
-
return tracer.startActiveSpan(
|
|
49
|
-
`llm.${input.provider}.${input.model}`,
|
|
50
|
-
async (span) => {
|
|
51
|
-
const start = performance.now();
|
|
52
|
-
setBaseAttributes(span, input);
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
const output = await fn();
|
|
56
|
-
const duration = performance.now() - start;
|
|
57
|
-
|
|
58
|
-
span.setAttributes({
|
|
59
|
-
...(shouldRecordContent() && {
|
|
60
|
-
[LLM_ATTRS.COMPLETION]: output.completion,
|
|
61
|
-
}),
|
|
62
|
-
[LLM_ATTRS.INPUT_TOKENS]: output.inputTokens,
|
|
63
|
-
[LLM_ATTRS.OUTPUT_TOKENS]: output.outputTokens,
|
|
64
|
-
[LLM_ATTRS.COST]: output.cost,
|
|
65
|
-
[LLM_ATTRS.STATUS]: "success",
|
|
66
|
-
});
|
|
67
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
68
|
-
|
|
69
|
-
recordRequest(input.provider, input.model);
|
|
70
|
-
recordRequestDuration(duration, input.provider, input.model);
|
|
71
|
-
recordRequestCost(output.cost, input.provider, input.model);
|
|
72
|
-
recordTokens(
|
|
73
|
-
output.inputTokens + output.outputTokens,
|
|
74
|
-
input.provider,
|
|
75
|
-
input.model,
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
return output;
|
|
79
|
-
} catch (error) {
|
|
80
|
-
const duration = performance.now() - start;
|
|
81
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
-
|
|
83
|
-
span.setAttributes({
|
|
84
|
-
[LLM_ATTRS.COMPLETION]: "",
|
|
85
|
-
[LLM_ATTRS.INPUT_TOKENS]: 0,
|
|
86
|
-
[LLM_ATTRS.OUTPUT_TOKENS]: 0,
|
|
87
|
-
[LLM_ATTRS.COST]: 0,
|
|
88
|
-
[LLM_ATTRS.STATUS]: "error",
|
|
89
|
-
[LLM_ATTRS.ERROR]: message,
|
|
90
|
-
});
|
|
91
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
92
|
-
|
|
93
|
-
recordRequest(input.provider, input.model);
|
|
94
|
-
recordRequestDuration(duration, input.provider, input.model);
|
|
95
|
-
recordError(input.provider, input.model);
|
|
96
|
-
|
|
97
|
-
throw error;
|
|
98
|
-
} finally {
|
|
99
|
-
span.end();
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
);
|
|
103
|
-
}
|
package/src/tracer.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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 type { ToadEyeConfig } from "./types.js";
|
|
8
|
-
import { initMetrics } from "./metrics.js";
|
|
9
|
-
|
|
10
|
-
const DEFAULT_ENDPOINT = "http://localhost:4318";
|
|
11
|
-
|
|
12
|
-
let sdk: NodeSDK | null = null;
|
|
13
|
-
let currentConfig: ToadEyeConfig | null = null;
|
|
14
|
-
|
|
15
|
-
export function getConfig() {
|
|
16
|
-
return currentConfig;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function initObservability(config: ToadEyeConfig) {
|
|
20
|
-
if (sdk) return;
|
|
21
|
-
|
|
22
|
-
const endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
23
|
-
|
|
24
|
-
const resource = resourceFromAttributes({
|
|
25
|
-
[ATTR_SERVICE_NAME]: config.serviceName,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const traceExporter = new OTLPTraceExporter({
|
|
29
|
-
url: `${endpoint}/v1/traces`,
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
const metricExporter = new OTLPMetricExporter({
|
|
33
|
-
url: `${endpoint}/v1/metrics`,
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const metricReader = new PeriodicExportingMetricReader({
|
|
37
|
-
exporter: metricExporter,
|
|
38
|
-
exportIntervalMillis: 5000,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
sdk = new NodeSDK({
|
|
42
|
-
resource,
|
|
43
|
-
traceExporter,
|
|
44
|
-
metricReader,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
sdk.start();
|
|
48
|
-
currentConfig = config;
|
|
49
|
-
initMetrics();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function shutdown() {
|
|
53
|
-
if (!sdk) return;
|
|
54
|
-
await sdk.shutdown();
|
|
55
|
-
sdk = null;
|
|
56
|
-
currentConfig = null;
|
|
57
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
export const INSTRUMENTATION_NAME = "toad-eye";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* LLM providers supported by toad-eye
|
|
5
|
-
*/
|
|
6
|
-
export type LLMProvider = "anthropic" | "gemini" | "openai";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Span status
|
|
10
|
-
*/
|
|
11
|
-
export type SpanStatus = "success" | "error";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Attributes attached to every LLM span (trace).
|
|
15
|
-
* These become searchable fields in Jaeger and filterable dimensions in Grafana.
|
|
16
|
-
*/
|
|
17
|
-
export interface LLMSpanAttributes {
|
|
18
|
-
readonly provider: LLMProvider;
|
|
19
|
-
readonly model: string;
|
|
20
|
-
readonly prompt: string;
|
|
21
|
-
readonly completion: string;
|
|
22
|
-
readonly inputTokens: number;
|
|
23
|
-
readonly outputTokens: number;
|
|
24
|
-
readonly cost: number;
|
|
25
|
-
readonly temperature: number;
|
|
26
|
-
readonly status: SpanStatus;
|
|
27
|
-
readonly error?: string | undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Metric names exported to Prometheus.
|
|
32
|
-
*
|
|
33
|
-
* Naming convention follows OpenTelemetry semantic conventions:
|
|
34
|
-
* - dots as separators: "llm.request.duration"
|
|
35
|
-
* - Prometheus auto-converts dots to underscores: llm_request_duration
|
|
36
|
-
* - Prometheus adds _total suffix to counters automatically — do NOT include it in the name
|
|
37
|
-
*/
|
|
38
|
-
export const LLM_METRICS = {
|
|
39
|
-
REQUEST_DURATION: "llm.request.duration",
|
|
40
|
-
REQUEST_COST: "llm.request.cost",
|
|
41
|
-
TOKENS: "llm.tokens",
|
|
42
|
-
REQUESTS: "llm.requests",
|
|
43
|
-
ERRORS: "llm.errors",
|
|
44
|
-
} as const;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Span attribute keys attached to every LLM span.
|
|
48
|
-
* Using constants prevents typos and enables rename-refactoring.
|
|
49
|
-
*/
|
|
50
|
-
export const LLM_ATTRS = {
|
|
51
|
-
PROVIDER: "llm.provider",
|
|
52
|
-
MODEL: "llm.model",
|
|
53
|
-
PROMPT: "llm.prompt",
|
|
54
|
-
COMPLETION: "llm.completion",
|
|
55
|
-
INPUT_TOKENS: "llm.input_tokens",
|
|
56
|
-
OUTPUT_TOKENS: "llm.output_tokens",
|
|
57
|
-
COST: "llm.cost",
|
|
58
|
-
TEMPERATURE: "llm.temperature",
|
|
59
|
-
STATUS: "llm.status",
|
|
60
|
-
ERROR: "llm.error",
|
|
61
|
-
} as const;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Type-safe metric name — only values from LLM_METRICS are allowed
|
|
65
|
-
*/
|
|
66
|
-
export type MetricName = (typeof LLM_METRICS)[keyof typeof LLM_METRICS];
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Configuration for initObservability().
|
|
70
|
-
* This is what the user passes when connecting toad-eye to their service.
|
|
71
|
-
*/
|
|
72
|
-
export interface ToadEyeConfig {
|
|
73
|
-
readonly serviceName: string;
|
|
74
|
-
readonly endpoint?: string | undefined;
|
|
75
|
-
readonly recordContent?: boolean | undefined;
|
|
76
|
-
}
|