@taicho-ai/telemetry 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/README.md +4 -0
- package/package.json +35 -0
- package/src/index.ts +1 -0
- package/src/otel.ts +252 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@taicho-ai/telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": "./src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
8
|
+
"test": "bun test src"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@opentelemetry/api": "^1.9.1",
|
|
12
|
+
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
13
|
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
|
|
14
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
|
|
15
|
+
"@opentelemetry/resources": "^2.9.0",
|
|
16
|
+
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
17
|
+
"@opentelemetry/sdk-trace-node": "^2.9.0"
|
|
18
|
+
},
|
|
19
|
+
"description": "OpenTelemetry setup and GenAI signal helpers for taicho — standard OTLP export, off by default.",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"homepage": "https://taicho.ai",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/taicho-ai/taicho.git",
|
|
25
|
+
"directory": "packages/telemetry"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"!src/**/*.test.ts",
|
|
30
|
+
"!src/**/*.test.tsx"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./otel";
|
package/src/otel.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/** Plan 16: OpenTelemetry instrumentation. The engine already produces rich internal evidence
|
|
2
|
+
* (transcript.jsonl, RunTrace) — but it is taicho-only. This module exports the SAME
|
|
3
|
+
* model/tool/delegation activity as STANDARD OpenTelemetry: `gen_ai.*` semantic-convention spans —
|
|
4
|
+
* taicho-native "chat <model> · iter N" spans opened in loop.ts (NOT the AI SDK's
|
|
5
|
+
* experimental_telemetry, which is unused) — nested under a taicho run span, plus a small metrics
|
|
6
|
+
* pipeline. Everything ships over OTLP to any collector (Jaeger, Grafana Tempo, Honeycomb, LangSmith…).
|
|
7
|
+
*
|
|
8
|
+
* OFF BY DEFAULT: with no OTLP endpoint configured, initTelemetry returns undefined and the engine
|
|
9
|
+
* does zero extra work — no provider, no spans, no network. Turning it on is one env var
|
|
10
|
+
* (OTEL_EXPORTER_OTLP_ENDPOINT), read by the standard OTel SDK exactly as every other OTel app reads it.
|
|
11
|
+
*
|
|
12
|
+
* Since Plan 17 this export is the ONLY trace-visualization path — the internal /trace waterfall is
|
|
13
|
+
* retired; users point the standard OTEL_* env vars at their own backend (docs/observability.md). */
|
|
14
|
+
import { metrics, context as otelContextApi, type Tracer, type Histogram, type Counter, type UpDownCounter } from "@opentelemetry/api";
|
|
15
|
+
import {
|
|
16
|
+
NodeTracerProvider,
|
|
17
|
+
BatchSpanProcessor,
|
|
18
|
+
SimpleSpanProcessor,
|
|
19
|
+
type SpanExporter,
|
|
20
|
+
} from "@opentelemetry/sdk-trace-node";
|
|
21
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
22
|
+
import {
|
|
23
|
+
MeterProvider,
|
|
24
|
+
PeriodicExportingMetricReader,
|
|
25
|
+
type MetricReader,
|
|
26
|
+
} from "@opentelemetry/sdk-metrics";
|
|
27
|
+
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
28
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
29
|
+
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
|
|
30
|
+
export interface TelemetryLogger {
|
|
31
|
+
info(message: string, data?: unknown): void;
|
|
32
|
+
warn(message: string, error?: unknown): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const silentLogger: TelemetryLogger = { info() {}, warn() {} };
|
|
36
|
+
|
|
37
|
+
/** One model call's telemetry, recorded as metrics (the span attributes come from the AI SDK). */
|
|
38
|
+
export interface ModelCallMetric {
|
|
39
|
+
provider: string;
|
|
40
|
+
model: string;
|
|
41
|
+
inputTokens: number;
|
|
42
|
+
outputTokens: number;
|
|
43
|
+
costUsd: number | null; // null on a subscription/unpriced run — never a fabricated 0
|
|
44
|
+
durationMs: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The live telemetry handle threaded through RunDeps (like the squad ledger). Undefined ⇒ disabled. */
|
|
48
|
+
export interface Telemetry {
|
|
49
|
+
/** Base tracer (service = the aggregate service name) for any span not tied to a specific agent. */
|
|
50
|
+
tracer: Tracer;
|
|
51
|
+
/** A tracer whose spans belong to a per-AGENT OTel service (`service.name = <agent>`, namespaced
|
|
52
|
+
* under the base service). So a backend that groups/colors by service — Jaeger's waterfall + its
|
|
53
|
+
* System Architecture graph — shows each agent as its own service and draws the delegation edges,
|
|
54
|
+
* instead of one undifferentiated "taicho". Providers are cached per agent. */
|
|
55
|
+
tracerFor(agent: string): Tracer;
|
|
56
|
+
/** Whether prompt/completion text may leave the process. ON by default; opt OUT with
|
|
57
|
+
* OTEL_TAICHO_CAPTURE_CONTENT=0|false|no|off (unrecognized values leave it on). */
|
|
58
|
+
captureContent: boolean;
|
|
59
|
+
/** Per model call → gen_ai token-usage + operation-duration histograms + the taicho cost counter. */
|
|
60
|
+
recordModelCall(m: ModelCallMetric): void;
|
|
61
|
+
/** A run began → active-runs gauge +1. */
|
|
62
|
+
runStarted(agent: string): void;
|
|
63
|
+
/** A run finished (any outcome) → active-runs gauge −1 + the run-duration histogram. */
|
|
64
|
+
runFinished(a: { agent: string; outcome: string; durationMs: number }): void;
|
|
65
|
+
/** Flush + close exporters. MUST be awaited before process exit or buffered spans are lost. */
|
|
66
|
+
shutdown(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface InitTelemetryOpts {
|
|
70
|
+
serviceName?: string;
|
|
71
|
+
serviceVersion?: string;
|
|
72
|
+
/** Test seam: export spans here instead of OTLP (an InMemorySpanExporter in unit tests). When set,
|
|
73
|
+
* telemetry is forced ON regardless of env, and a SimpleSpanProcessor is used (synchronous flush). */
|
|
74
|
+
spanExporter?: SpanExporter;
|
|
75
|
+
/** Test seam: use this metric reader instead of the OTLP periodic reader. */
|
|
76
|
+
metricReader?: MetricReader;
|
|
77
|
+
env?: Record<string, string | undefined>;
|
|
78
|
+
/** Optional host diagnostics. Telemetry never imports a framework-specific logger. */
|
|
79
|
+
logger?: TelemetryLogger;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const falsey = (v: string | undefined): boolean => v === "0" || v === "false" || v === "no" || v === "off";
|
|
83
|
+
|
|
84
|
+
/** An OPT-OUT switch: on unless the operator explicitly turns it off.
|
|
85
|
+
*
|
|
86
|
+
* The reasoning for content capture specifically. Telemetry is ALREADY off unless you set
|
|
87
|
+
* OTEL_EXPORTER_OTLP_ENDPOINT — so by the time this flag matters, you have deliberately pointed taicho
|
|
88
|
+
* at a backend you chose. Handing that person span skeletons with no prompts, no completions, and no
|
|
89
|
+
* tool I/O makes the trace unreadable and the feature nearly pointless: you cannot answer "what did the
|
|
90
|
+
* user say, what did the agent say" from structure alone. The privacy default that mattered was
|
|
91
|
+
* "export nothing anywhere", and that one is intact.
|
|
92
|
+
*
|
|
93
|
+
* Anything unrecognized (a typo like `OTEL_TAICHO_CAPTURE_CONTENT=maybe`) leaves it ON. An opt-out
|
|
94
|
+
* switch must fail toward the useful behaviour, or a typo silently guts your observability. */
|
|
95
|
+
const optOut = (v: string | undefined): boolean => !falsey(v);
|
|
96
|
+
|
|
97
|
+
/** Build the telemetry pipeline, or return undefined when disabled.
|
|
98
|
+
*
|
|
99
|
+
* Enabled when EITHER an OTLP endpoint is configured (OTEL_EXPORTER_OTLP_ENDPOINT /
|
|
100
|
+
* OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) OR a test spanExporter is injected. The OTLP exporters read
|
|
101
|
+
* their endpoint/headers/protocol from the standard OTEL_* env vars themselves — nothing bespoke. */
|
|
102
|
+
export function initTelemetry(opts: InitTelemetryOpts = {}): Telemetry | undefined {
|
|
103
|
+
const logger = opts.logger ?? silentLogger;
|
|
104
|
+
const env = opts.env ?? process.env;
|
|
105
|
+
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT ?? env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
|
|
106
|
+
const testMode = opts.spanExporter != null || opts.metricReader != null;
|
|
107
|
+
if (!endpoint && !testMode) return undefined; // the on-switch is the endpoint; absent it, a no-op
|
|
108
|
+
|
|
109
|
+
const baseService = opts.serviceName ?? env.OTEL_SERVICE_NAME ?? "taicho";
|
|
110
|
+
const version = opts.serviceVersion ?? "0.0.1";
|
|
111
|
+
const useTest = opts.spanExporter != null;
|
|
112
|
+
|
|
113
|
+
// ONE trace exporter, shared by every per-agent provider (so all agents' spans go to the same
|
|
114
|
+
// OTLP endpoint / in-memory buffer). Each agent gets its OWN provider whose resource carries
|
|
115
|
+
// `service.name = <agent>` — that is what a backend groups + colors by.
|
|
116
|
+
const providers = new Map<string, NodeTracerProvider>();
|
|
117
|
+
let traceExporter: SpanExporter;
|
|
118
|
+
let meterProvider: MeterProvider | undefined;
|
|
119
|
+
const providerFor = (svc: string): NodeTracerProvider => {
|
|
120
|
+
let p = providers.get(svc);
|
|
121
|
+
if (!p) {
|
|
122
|
+
p = new NodeTracerProvider({
|
|
123
|
+
resource: resourceFromAttributes({ "service.name": svc, "service.namespace": baseService, "service.version": version }),
|
|
124
|
+
spanProcessors: [useTest ? new SimpleSpanProcessor(traceExporter) : new BatchSpanProcessor(traceExporter)],
|
|
125
|
+
});
|
|
126
|
+
providers.set(svc, p);
|
|
127
|
+
}
|
|
128
|
+
return p;
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
traceExporter = opts.spanExporter ?? new OTLPTraceExporter();
|
|
132
|
+
// AsyncLocalStorage context propagation, set ONCE globally (independent of any single provider) so
|
|
133
|
+
// a delegated child agent's spans — created via a DIFFERENT per-agent provider — still nest under
|
|
134
|
+
// the parent's span in ONE trace across await boundaries. Bun implements AsyncLocalStorage.
|
|
135
|
+
otelContextApi.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
|
|
136
|
+
providerFor(baseService); // warm the base provider
|
|
137
|
+
|
|
138
|
+
const reader = opts.metricReader
|
|
139
|
+
?? new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter(), exportIntervalMillis: 15_000 });
|
|
140
|
+
// Metrics stay under the single aggregate service (they're squad-wide, not per-agent).
|
|
141
|
+
meterProvider = new MeterProvider({ resource: resourceFromAttributes({ "service.name": baseService, "service.version": version }), readers: [reader] });
|
|
142
|
+
metrics.setGlobalMeterProvider(meterProvider);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// Never let a telemetry misconfiguration take down the app — log and run without it.
|
|
145
|
+
logger.warn("opentelemetry init failed — continuing without telemetry", e);
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const tracer = providerFor(baseService).getTracer("taicho", version);
|
|
150
|
+
const meter = (meterProvider ?? metrics.getMeterProvider()).getMeter("taicho");
|
|
151
|
+
|
|
152
|
+
// GenAI semantic-convention instruments (portable names every backend understands) + a taicho cost
|
|
153
|
+
// counter and active-run gauge for the things the spec doesn't cover.
|
|
154
|
+
const tokenUsage: Histogram = meter.createHistogram("gen_ai.client.token.usage", { unit: "{token}", description: "Tokens used per model call" });
|
|
155
|
+
const opDuration: Histogram = meter.createHistogram("gen_ai.client.operation.duration", { unit: "s", description: "Model call latency" });
|
|
156
|
+
const costCounter: Counter = meter.createCounter("taicho.cost.usd", { unit: "USD", description: "Advisory model spend" });
|
|
157
|
+
const activeRuns: UpDownCounter = meter.createUpDownCounter("taicho.run.active", { description: "Runs currently in flight" });
|
|
158
|
+
const runDuration: Histogram = meter.createHistogram("taicho.run.duration", { unit: "s", description: "Agent run wall-clock" });
|
|
159
|
+
|
|
160
|
+
// Plan 18 follow-up: opt-OUT (was opt-in). Set OTEL_TAICHO_CAPTURE_CONTENT=0|false|no|off to strip
|
|
161
|
+
// prompt/completion/tool content from spans; anything else (including unset) keeps it.
|
|
162
|
+
const captureContent = optOut(env.OTEL_TAICHO_CAPTURE_CONTENT);
|
|
163
|
+
logger.info("opentelemetry enabled", { service: baseService, perAgentServices: true, endpoint: endpoint ?? "(test exporter)", captureContent });
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
tracer,
|
|
167
|
+
tracerFor(agent: string) {
|
|
168
|
+
return providerFor(agent || baseService).getTracer("taicho", version);
|
|
169
|
+
},
|
|
170
|
+
captureContent,
|
|
171
|
+
recordModelCall(m) {
|
|
172
|
+
const attrs = { "gen_ai.system": m.provider, "gen_ai.request.model": m.model };
|
|
173
|
+
tokenUsage.record(m.inputTokens, { ...attrs, "gen_ai.token.type": "input" });
|
|
174
|
+
tokenUsage.record(m.outputTokens, { ...attrs, "gen_ai.token.type": "output" });
|
|
175
|
+
opDuration.record(m.durationMs / 1000, attrs);
|
|
176
|
+
if (m.costUsd != null && m.costUsd > 0) costCounter.add(m.costUsd, { "gen_ai.request.model": m.model });
|
|
177
|
+
},
|
|
178
|
+
runStarted(agent) {
|
|
179
|
+
activeRuns.add(1, { "taicho.agent": agent });
|
|
180
|
+
},
|
|
181
|
+
runFinished(a) {
|
|
182
|
+
activeRuns.add(-1, { "taicho.agent": a.agent });
|
|
183
|
+
runDuration.record(a.durationMs / 1000, { "taicho.agent": a.agent, "taicho.run.outcome": a.outcome });
|
|
184
|
+
},
|
|
185
|
+
async shutdown() {
|
|
186
|
+
// shutdown() force-flushes then closes. Swallow errors: an unreachable collector on exit must
|
|
187
|
+
// never crash the process or block the REPL quit path. Every per-agent provider is flushed.
|
|
188
|
+
for (const p of providers.values()) {
|
|
189
|
+
try { await p.shutdown(); } catch (e) { logger.warn("otel tracer shutdown failed", e); }
|
|
190
|
+
}
|
|
191
|
+
try { await meterProvider?.shutdown(); } catch (e) { logger.warn("otel meter shutdown failed", e); }
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Span input/output attributes, in the keys trace backends actually read. Without these, a viewer
|
|
197
|
+
* shows "No inputs" — the AI SDK writes prompt/response under its own `ai.*` keys, which the generic
|
|
198
|
+
* OTLP path does NOT map. We set the widely-read ones: OpenInference (`input.value`/`output.value`),
|
|
199
|
+
* the generic GenAI (`gen_ai.prompt`/`gen_ai.completion`), and LangSmith's explicit reader
|
|
200
|
+
* (`langsmith.span.inputs`/`outputs`). Gated by content capture at every call site. Capped so a huge
|
|
201
|
+
* brief/answer can't bloat an attribute. */
|
|
202
|
+
export function ioAttrs(kind: "input" | "output", text: string): Record<string, string> {
|
|
203
|
+
const v = text.length > 12_000 ? text.slice(0, 12_000) + "…[truncated]" : text;
|
|
204
|
+
return kind === "input"
|
|
205
|
+
? { "input.value": v, "gen_ai.prompt": v, "langsmith.span.inputs": JSON.stringify({ input: v }) }
|
|
206
|
+
: { "output.value": v, "gen_ai.completion": v, "langsmith.span.outputs": JSON.stringify({ output: v }) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Flatten an AI SDK message's content (string | parts[]) to a readable line, so a chat message shows
|
|
210
|
+
* as text — not a nested JSON blob. Tool calls/results render compactly (`→ tool(args)` / `← tool: …`). */
|
|
211
|
+
function contentToText(content: unknown): string {
|
|
212
|
+
if (typeof content === "string") return content;
|
|
213
|
+
if (Array.isArray(content)) {
|
|
214
|
+
return content
|
|
215
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
216
|
+
.map((p: any) => {
|
|
217
|
+
if (typeof p === "string") return p;
|
|
218
|
+
if (p?.type === "text") return p.text ?? "";
|
|
219
|
+
if (p?.type === "reasoning") return p.text ?? "";
|
|
220
|
+
if (p?.type === "tool-call") return `→ ${p.toolName}(${JSON.stringify(p.input ?? p.args ?? {})})`;
|
|
221
|
+
if (p?.type === "tool-result") {
|
|
222
|
+
const r = p.output ?? p.result;
|
|
223
|
+
return `← ${p.toolName}: ${typeof r === "string" ? r : JSON.stringify(r ?? {})}`;
|
|
224
|
+
}
|
|
225
|
+
return "";
|
|
226
|
+
})
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join("\n");
|
|
229
|
+
}
|
|
230
|
+
return content == null ? "" : String(content);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** GenAI-convention chat-message attributes (the indexed OpenLLMetry form: `<prefix>.<i>.role` /
|
|
234
|
+
* `<prefix>.<i>.content`) that LangSmith and other backends render as a MESSAGE LIST instead of a raw
|
|
235
|
+
* JSON dump. `prefix` is "gen_ai.prompt" (input) or "gen_ai.completion" (output). Each message's
|
|
236
|
+
* content is flattened to readable text and capped. This is the proper OpenTelemetry way to carry a
|
|
237
|
+
* conversation on a span — used for the `chat` (LLM) spans; single-string I/O uses ioAttrs instead. */
|
|
238
|
+
export function chatMessageAttrs(prefix: string, messages: { role: string; content: unknown }[]): Record<string, string> {
|
|
239
|
+
const out: Record<string, string> = {};
|
|
240
|
+
let i = 0;
|
|
241
|
+
for (const m of messages) {
|
|
242
|
+
const text = contentToText(m.content);
|
|
243
|
+
if (!text) continue;
|
|
244
|
+
out[`${prefix}.${i}.role`] = m.role;
|
|
245
|
+
out[`${prefix}.${i}.content`] = text.length > 8_000 ? text.slice(0, 8_000) + "…[truncated]" : text;
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Re-export the api surface run.ts/loop.ts/tools.ts need to open + nest spans, from one place. */
|
|
252
|
+
export { trace, context, SpanStatusCode, type Span } from "@opentelemetry/api";
|