toad-eye 0.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/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "toad-eye",
3
+ "version": "0.1.0",
4
+ "description": "One-line OpenTelemetry instrumentation for LLM services",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "scripts": {},
9
+ "keywords": [
10
+ "opentelemetry",
11
+ "llm",
12
+ "instrumentation"
13
+ ],
14
+ "license": "ISC",
15
+ "dependencies": {
16
+ "@opentelemetry/api": "^1.9.0",
17
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
18
+ "@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
19
+ "@opentelemetry/resources": "^2.6.0",
20
+ "@opentelemetry/sdk-metrics": "^2.6.0",
21
+ "@opentelemetry/sdk-node": "^0.213.0",
22
+ "@opentelemetry/semantic-conventions": "^1.40.0"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { initObservability, shutdown, getConfig } from "./tracer.js";
2
+ export { traceLLMCall } from "./spans.js";
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";
11
+ export { LLM_METRICS, LLM_ATTRS } from "./types.js";
package/src/metrics.ts ADDED
@@ -0,0 +1,69 @@
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 ADDED
@@ -0,0 +1,103 @@
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 ADDED
@@ -0,0 +1,57 @@
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 ADDED
@@ -0,0 +1,76 @@
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 ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src"],
4
+ "exclude": ["node_modules", "dist"]
5
+ }