Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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" })) {
// …
Expand All @@ -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`) |

Expand Down
34 changes: 33 additions & 1 deletion src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,6 +40,8 @@ export interface TelemetryOptions {
resourceAttributes?: Record<string, string | number | boolean>;
/** 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.
Expand All @@ -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";
Expand All @@ -70,6 +91,9 @@ const _slots = {

export async function initTelemetry(opts: TelemetryOptions): Promise<void> {
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.
Expand Down Expand Up @@ -158,6 +182,7 @@ export async function initTelemetry(opts: TelemetryOptions): Promise<void> {
// 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)}`,
Expand All @@ -177,6 +202,7 @@ export async function shutdownTelemetry(): Promise<void> {
} finally {
_initialized = false;
_sdk = null;
_captureToolContent = false;
}
}

Expand Down Expand Up @@ -357,8 +383,14 @@ export function wrapToolWithOtel<T extends AgentTool<any>>(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 {
Expand Down
56 changes: 55 additions & 1 deletion test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ function freshExporter(): {

async function withTelemetry(
fn: (exporter: InMemorySpanExporter) => Promise<void> | void,
options: { captureToolContent?: boolean } = {},
): Promise<void> {
const { exporter, provider } = freshExporter();
await initTelemetry({ serviceName: "gitagent-test", _testProvider: provider });
await initTelemetry({ serviceName: "gitagent-test", _testProvider: provider, ...options });
try {
await fn(exporter);
} finally {
Expand Down Expand Up @@ -75,6 +76,59 @@ 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 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);
}, { captureToolContent: true });
});

test("wrapToolWithOtel error path sets status=error and records error message", async () => {
await withTelemetry(async (exporter) => {
const tool: any = {
Expand Down