From f1b9b77250b9a5a8c37c77dcfee809505ac53691 Mon Sep 17 00:00:00 2001 From: ahmadalguydi Date: Tue, 28 Jul 2026 16:17:41 +0300 Subject: [PATCH 1/3] feat(telemetry): optionally capture tool trace details --- README.md | 5 +++-- src/telemetry.ts | 34 +++++++++++++++++++++++++++++++++- test/telemetry.test.ts | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7d0257f..da27910 100644 --- a/README.md +++ b/README.md @@ -756,6 +756,7 @@ Telemetry is enabled automatically when the endpoint is set and disabled when it | `OTEL_SERVICE_VERSION` | Resource `service.version` | (unset) | | `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated key=value pairs, no quotes (e.g. `Authorization=Bearer xyz,x-tenant=abc`) | (unset) | | `OTEL_TRACES_EXPORTER` | Set to `console` to print spans to stdout — no collector needed | (unset) | +| `GITAGENT_OTEL_CAPTURE_TOOL_CONTENT` | Set to `true` to include bounded tool arguments/results on tool spans (may contain sensitive data) | `false` | ### SDK usage @@ -764,7 +765,7 @@ For programmatic embedders, call `initTelemetry` explicitly — you control when ```ts import { initTelemetry, shutdownTelemetry, query } from "gitagent"; -await initTelemetry({ serviceName: "my-app" }); +await initTelemetry({ serviceName: "my-app", captureToolContent: true }); for await (const msg of query({ prompt: "hello", model: "anthropic:claude-4-6-sonnet-latest" })) { // … @@ -780,7 +781,7 @@ await shutdownTelemetry(); | Name | Kind | Key attributes | |------|------|----------------| | `gitagent.agent.session` | INTERNAL | `gitagent.entry` (`sdk` / `cli`), `gitagent.cost_usd`, `gitagent.session.duration_ms` | -| `gitagent.tool.execute` | INTERNAL | `tool.name`, `tool.call_id`, `tool.status`, `tool.error_message` | +| `gitagent.tool.execute` | INTERNAL | `tool.name`, `tool.call_id`, `tool.status`, `tool.error_message`; optional bounded `tool.input` / `tool.output` when content capture is enabled | | `gen_ai.chat` | CLIENT | `gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.response.finish_reasons`, `gitagent.cost_usd` | | `HTTP …` | CLIENT | URL, status code, duration (auto from `instrumentation-undici`) | diff --git a/src/telemetry.ts b/src/telemetry.ts index 10cf562..58fb433 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -6,7 +6,7 @@ // so the module is side-effect-free until telemetry is explicitly enabled. // - Every public function wraps its body in try/catch — telemetry must never // crash the agent. -// - Spans never carry prompt or completion content; only metadata. +// - Tool arguments/results are opt-in because they can contain sensitive data. import { trace, @@ -40,6 +40,8 @@ export interface TelemetryOptions { resourceAttributes?: Record; /** Set to `false` to skip metric exporter setup. */ enableMetrics?: boolean; + /** Capture bounded tool arguments/results on tool spans. Disabled by default. */ + captureToolContent?: boolean; /** * Test escape hatch — register the given TracerProvider directly and * skip all dynamic SDK imports. Used by unit tests. @@ -51,6 +53,25 @@ export interface TelemetryOptions { let _initialized = false; let _sdk: any = null; +let _captureToolContent = false; + +const MAX_TOOL_CONTENT_LENGTH = 8_192; + +function formatToolContent(value: unknown): string { + let text: string; + if (typeof value === "string") { + text = value; + } else { + try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + } + return text.length > MAX_TOOL_CONTENT_LENGTH + ? `${text.slice(0, MAX_TOOL_CONTENT_LENGTH)}…` + : text; +} const TRACER_NAME = "gitagent"; const METER_NAME = "gitagent"; @@ -70,6 +91,9 @@ const _slots = { export async function initTelemetry(opts: TelemetryOptions): Promise { if (_initialized) return; + _captureToolContent = + opts.captureToolContent === true || + process.env.GITAGENT_OTEL_CAPTURE_TOOL_CONTENT?.toLowerCase() === "true"; try { // Test path — register a caller-supplied TracerProvider directly. @@ -158,6 +182,7 @@ export async function initTelemetry(opts: TelemetryOptions): Promise { // so misconfiguration is visible without breaking the agent. _sdk = null; _initialized = false; + _captureToolContent = false; try { console.error( `[telemetry] init failed: ${err instanceof Error ? err.message : String(err)}`, @@ -177,6 +202,7 @@ export async function shutdownTelemetry(): Promise { } finally { _initialized = false; _sdk = null; + _captureToolContent = false; } } @@ -357,8 +383,14 @@ export function wrapToolWithOtel>(tool: T): T { }, async (span) => { try { + if (_captureToolContent) { + span.setAttribute("tool.input", formatToolContent(args)); + } const result = await original.apply(this, [args, ...rest]); try { + if (_captureToolContent) { + span.setAttribute("tool.output", formatToolContent(result)); + } span.setAttribute("tool.status", "ok"); span.setStatus({ code: SpanStatusCode.OK }); } catch { diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 6b1dd5a..654dfbd 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -37,9 +37,10 @@ function freshExporter(): { async function withTelemetry( fn: (exporter: InMemorySpanExporter) => Promise | void, + options: { captureToolContent?: boolean } = {}, ): Promise { const { exporter, provider } = freshExporter(); - await initTelemetry({ serviceName: "gitagent-test", _testProvider: provider }); + await initTelemetry({ serviceName: "gitagent-test", _testProvider: provider, ...options }); try { await fn(exporter); } finally { @@ -75,6 +76,39 @@ test("wrapToolWithOtel happy path produces gitagent.tool.execute span with statu }); }); +test("wrapToolWithOtel can capture bounded tool input and output when opted in", async () => { + await withTelemetry(async (exporter) => { + const tool: any = { + name: "describe", + description: "describe", + parameters: {} as any, + execute: async (args: any) => ({ ok: true, value: args.value }), + }; + await (wrapToolWithOtel(tool) as any).execute({ value: "hello" }); + + const span = exporter.getFinishedSpans().find((s) => s.name === "gitagent.tool.execute"); + assert.ok(span); + assert.equal(span!.attributes["tool.input"], '{"value":"hello"}'); + assert.equal(span!.attributes["tool.output"], '{"ok":true,"value":"hello"}'); + }, { captureToolContent: true }); +}); + +test("wrapToolWithOtel omits tool content by default", async () => { + await withTelemetry(async (exporter) => { + const tool: any = { + name: "private", + description: "private", + parameters: {} as any, + execute: async () => "secret", + }; + await (wrapToolWithOtel(tool) as any).execute({ secret: "value" }); + const span = exporter.getFinishedSpans().find((s) => s.name === "gitagent.tool.execute"); + assert.ok(span); + assert.equal(span!.attributes["tool.input"], undefined); + assert.equal(span!.attributes["tool.output"], undefined); + }); +}); + test("wrapToolWithOtel error path sets status=error and records error message", async () => { await withTelemetry(async (exporter) => { const tool: any = { From 36ab152fcbe5ec4e10ea60252018d251bf10ca66 Mon Sep 17 00:00:00 2001 From: ahmadalguydi Date: Tue, 28 Jul 2026 16:18:43 +0300 Subject: [PATCH 2/3] test(telemetry): cover bounded tool content capture --- test/telemetry.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 654dfbd..25e137f 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -109,6 +109,26 @@ test("wrapToolWithOtel omits tool content by default", async () => { }); }); +test("wrapToolWithOtel bounds large content and tolerates circular values", async () => { + await withTelemetry(async (exporter) => { + const circular: any = {}; + circular.self = circular; + const tool: any = { + name: "bounded", + description: "bounded", + parameters: {} as any, + execute: async () => "x".repeat(9_000), + }; + await (wrapToolWithOtel(tool) as any).execute(circular); + const span = exporter.getFinishedSpans().find((s) => s.name === "gitagent.tool.execute"); + assert.ok(span); + assert.equal(String(span!.attributes["tool.input"]), "[object Object]"); + const output = String(span!.attributes["tool.output"]); + assert.equal(output.length, 8_193); + assert.equal(output.endsWith("…"), true); + }); +}); + test("wrapToolWithOtel error path sets status=error and records error message", async () => { await withTelemetry(async (exporter) => { const tool: any = { From 27f27bf37bab9983acce65f2939b39666b7c809f Mon Sep 17 00:00:00 2001 From: ahmadalguydi Date: Tue, 28 Jul 2026 16:18:55 +0300 Subject: [PATCH 3/3] test(telemetry): enable capture for bounded-value case --- test/telemetry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 25e137f..211b565 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -126,7 +126,7 @@ test("wrapToolWithOtel bounds large content and tolerates circular values", asyn const output = String(span!.attributes["tool.output"]); assert.equal(output.length, 8_193); assert.equal(output.endsWith("…"), true); - }); + }, { captureToolContent: true }); }); test("wrapToolWithOtel error path sets status=error and records error message", async () => {