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 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
- @toad-eye/instrumentation
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 "@toad-eye/instrumentation";
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 (@toad-eye/instrumentation)
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,3 @@
1
+ export { initObservability, shutdown, getConfig } from "./tracer.js";
2
+ export { traceLLMCall } from "./spans.js";
3
+ export { LLM_METRICS, LLM_ATTRS } from "./types.js";
@@ -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;
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToadEyeConfig } from "./types.js";
2
+ export declare function getConfig(): ToadEyeConfig | null;
3
+ export declare function initObservability(config: ToadEyeConfig): void;
4
+ export declare function shutdown(): Promise<void>;
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
+ }
@@ -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": "0.2.1",
3
+ "version": "1.1.0",
4
4
  "description": "One-line OpenTelemetry instrumentation for LLM services",
5
5
  "type": "module",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
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/albertalov/toad-eye"
22
+ "url": "https://github.com/vola-trebla/toad-eye"
11
23
  },
12
- "homepage": "https://github.com/albertalov/toad-eye#readme",
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,8 @@
1
+ apiVersion: 1
2
+
3
+ providers:
4
+ - name: "toad-eye"
5
+ type: file
6
+ options:
7
+ path: /etc/grafana/provisioning/dashboards
8
+ foldersFromFilesStructure: false
@@ -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,15 @@
1
+ apiVersion: 1
2
+
3
+ datasources:
4
+ - name: Prometheus
5
+ type: prometheus
6
+ uid: prometheus
7
+ access: proxy
8
+ url: http://prometheus:9090
9
+ isDefault: true
10
+
11
+ - name: Jaeger
12
+ type: jaeger
13
+ uid: jaeger
14
+ access: proxy
15
+ url: http://jaeger:16686
@@ -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]
@@ -0,0 +1,7 @@
1
+ global:
2
+ scrape_interval: 5s
3
+
4
+ scrape_configs:
5
+ - job_name: "otel-collector"
6
+ static_configs:
7
+ - targets: ["collector:8889"]
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
- }
package/tsconfig.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src"],
4
- "exclude": ["node_modules", "dist"]
5
- }