Skip to content
Merged
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
13 changes: 13 additions & 0 deletions packages/omp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ export default telemetryDevExtension({
Initialization is one-shot; the first extension factory initialized in a process supplies the SDK
options.

## Errors

The integration does not write to the terminal, because omp draws its TUI there. It sends SDK and
extension errors to the omp log through `pi.logger`, with the message `telemetry.dev error`. The
default location of the omp log is `~/.omp/logs`.

A series of failed exports, for example during an ingest outage, is one entry. The next failure
after an export reaches the ingest is a new entry.

To receive the errors in your code, pass `onError` to `telemetryDevExtension()`. The callback
replaces the omp log. To also write SDK diagnostics to the console, pass `logLevel`, for example
`"warn"`.

## Trace shape

A typical prompt produces this hierarchy:
Expand Down
18 changes: 16 additions & 2 deletions packages/omp/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { inspect } from "node:util";

import {
flush,
log,
startSpan,
type LogLevel,
type SdkLogLevel,
type SpanHandle,
type TokenUsage,
} from "@telemetry-dev/sdk";
Expand All @@ -13,6 +16,10 @@ import { ensureInit, type ClientOverrides, type TelemetryDevOmpOptions } from ".
export interface TelemetryDevExtensionOptions extends TelemetryDevOmpOptions {
/** Value for `gen_ai.agent.name` on spans and logs. Defaults to `"omp"`. */
agentName?: string;
/** SDK console output. Defaults to `"silent"`, because omp draws its TUI on the terminal. */
logLevel?: SdkLogLevel;
/** Receives SDK and extension errors. Defaults to omp's log file, through `pi.logger`. */
onError?: (cause: Error) => void;
}

/**
Expand All @@ -38,6 +45,8 @@ export interface TelemetryDevExtensionContext {
*/
export interface TelemetryDevExtensionHost {
on(event: never, handler: never): void;
/** omp's file logger. */
logger?: { error(message: string, context?: Record<string, unknown>): void };
}

/** Structural stand-in for the host `ExtensionFactory` type. */
Expand Down Expand Up @@ -180,11 +189,16 @@ export function telemetryDevExtension(
overrides?: ClientOverrides,
): TelemetryDevExtension {
const { agentName = "omp", ...sdkOptions } = options;
const onError = sdkOptions.onError;

return (pi) => {
// omp draws its TUI on the terminal, so errors go to omp's log file and not to the console.
const onError =
sdkOptions.onError ??
((error: Error) => pi.logger?.error("telemetry.dev error", { error: inspect(error) }));
const logLevel = sdkOptions.logLevel ?? "silent";

try {
ensureInit(sdkOptions, overrides);
ensureInit({ ...sdkOptions, logLevel, onError }, overrides);
} catch (error) {
reportError(onError, error);
}
Expand Down
42 changes: 41 additions & 1 deletion packages/omp/tests/omp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { InMemoryLogRecordExporter } from "@opentelemetry/sdk-logs";
import { InMemorySpanExporter, type ReadableSpan } from "@opentelemetry/sdk-trace-base";
import { flush, type ClientOverrides } from "@telemetry-dev/sdk";
import type { ExtensionContext, ExtensionFactory } from "@oh-my-pi/pi-coding-agent";
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";

import { resetForTesting } from "../src/config.ts";
import { telemetryDevExtension, type TelemetryDevExtensionOptions } from "../src/extension.ts";
Expand Down Expand Up @@ -543,6 +543,46 @@ test("malformed events never throw and report through onError", async () => {
expect(harness.exporters.spans.getFinishedSpans().length).toBeGreaterThan(0);
});

test("errors go to omp's file logger, not to the console", async () => {
const consoleSpies = (["error", "warn", "info", "debug", "log"] as const).map((method) =>
vi.spyOn(console, method).mockImplementation(() => {}),
);
const logged: [string, Record<string, unknown> | undefined][] = [];
const handlers = new Map<string, (event: object, ctx: ExtensionContext) => unknown>();

try {
telemetryDevExtension({
apiKey: "td_live_test",
exportMode: "immediate",
fetch: async () => new Response(null, { status: 400 }),
})({
on: (type: string, handler: never) => handlers.set(type, handler),
logger: { error: (message, context) => logged.push([message, context]) },
});

await handlers.get("session_start")?.({ type: "session_start" }, makeContext());
await flush();
await handlers.get("message_end")?.(
{
get message(): never {
throw new Error("poisoned event");
},
},
makeContext(),
);

expect(logged.map(([message]) => message)).toEqual([
"telemetry.dev error",
"telemetry.dev error",
]);
expect(String(logged[0]?.[1]?.error)).toContain("telemetry.dev log ingest failed: 400");
expect(String(logged[1]?.[1]?.error)).toContain("poisoned event");
expect(consoleSpies.flatMap((spy) => spy.mock.calls)).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});

test("each event reads the current session id", async () => {
const harness = makeHarness();
await harness.emit({ type: "session_start" }, makeContext("session-2"));
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ Use one route, not both. If the package is in `opencode.json` and also loaded fr

All telemetry SDK options can also be passed to `telemetryDevPlugin`. The `agentName` integration option controls `gen_ai.agent.name` and defaults to `opencode`.

## Errors

The integration does not write to the console. opencode 1.x runs plugins in the process that draws its TUI, and console output from a plugin shows over the TUI. The integration sends SDK and plugin errors to the opencode log through `client.app.log`, with the service name `telemetry-dev`. The default location of the opencode log is `~/.local/share/opencode/log`.

A series of failed exports, for example during an ingest outage, is one entry. The next failure after an export reaches the ingest is a new entry.

To receive the errors in your code, pass `onError` to `telemetryDevPlugin()` in a plugin file. The callback replaces the opencode log. To also write SDK diagnostics to the console, set `logLevel`, for example `"warn"`.

## Trace shape

Each opencode turn produces this hierarchy, correlated by `gen_ai.conversation.id`:
Expand Down
30 changes: 27 additions & 3 deletions packages/opencode/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { inspect } from "node:util";

import {
captureEnabled,
flush,
log,
startSpan,
type LogLevel,
type SdkLogLevel,
type SpanHandle,
type TokenUsage,
} from "@telemetry-dev/sdk";
Expand All @@ -15,6 +18,10 @@ import { asJsonObject, type JsonObject, type JsonValue } from "./type-guards.ts"
export interface TelemetryDevPluginOptions extends TelemetryDevOpencodeOptions {
/** Value for `gen_ai.agent.name` on spans and logs. Defaults to `"opencode"`. */
agentName?: string;
/** SDK console output. Defaults to `"silent"`, because opencode can draw its TUI on the plugin's terminal. */
logLevel?: SdkLogLevel;
/** Receives SDK and plugin errors. Defaults to opencode's log, through `client.app.log`. */
onError?: (cause: Error) => void;
}

/**
Expand Down Expand Up @@ -73,6 +80,20 @@ function numberField(record: JsonObject | undefined, key: string): number | unde
return parsed === value && Number.isFinite(parsed) ? parsed : undefined;
}

type AppLog = (options: { body: object }) => Promise<unknown>;

/** Writes errors to opencode's log file through the plugin client, when the client has one. */
function opencodeLog(input: unknown): ((error: Error) => void) | undefined {
const app = (input as { client?: { app?: { log?: AppLog } } } | undefined)?.client?.app;

if (typeof app?.log !== "function") return undefined;

return (error) => {
const body = { service: "telemetry-dev", level: "error", message: inspect(error) };
void app.log?.({ body }).catch(() => undefined);
};
}

function reportError(onError: ((cause: Error) => void) | undefined, cause: unknown): void {
try {
onError?.(cause instanceof Error ? cause : new Error(String(cause)));
Expand Down Expand Up @@ -172,7 +193,7 @@ export function telemetryDevPlugin(
options: TelemetryDevPluginOptions = {},
overrides?: ClientOverrides,
): TelemetryDevPlugin {
return async <Input, Options extends object>(_input: Input, configuredOptions?: Options) => {
return async <Input, Options extends object>(input: Input, configuredOptions?: Options) => {
const mergedOptions = {
...options,
...configuredOptions,
Expand All @@ -185,10 +206,13 @@ export function telemetryDevPlugin(
? configuredAgentName
: "opencode";

const onError = sdkOptions.onError;
// opencode 1.x runs plugins in the process that draws its TUI, so errors go to opencode's
// log and not to the console.
const onError = sdkOptions.onError ?? opencodeLog(input);
const logLevel = sdkOptions.logLevel ?? "silent";

try {
ensureInit(sdkOptions, overrides);
ensureInit({ ...sdkOptions, logLevel, onError }, overrides);
} catch (error) {
reportError(onError, error);
}
Expand Down
44 changes: 44 additions & 0 deletions packages/opencode/tests/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SpanStatusCode, trace } from "@opentelemetry/api";
import { InMemoryLogRecordExporter } from "@opentelemetry/sdk-logs";
import { InMemorySpanExporter, type ReadableSpan } from "@opentelemetry/sdk-trace-base";
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin";
import { createOpencodeClient } from "@opencode-ai/sdk/client";
import { flush, type ClientOverrides } from "@telemetry-dev/sdk";
import { afterEach, describe, expect, test, vi } from "vitest";

Expand Down Expand Up @@ -420,6 +421,49 @@ test("poisoned events report through onError without throwing", async () => {
expect(spansByName(harness.exporters, "invoke_agent")).toHaveLength(1);
});

test("errors go to opencode's log through the plugin client, not to the console", async () => {
const consoleSpies = (["error", "warn", "info", "debug", "log"] as const).map((method) =>
vi.spyOn(console, method).mockImplementation(() => {}),
);
const logged: { method: string; path: string; body: Record<string, string> }[] = [];

const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
fetch: async (request: Request) => {
const { method, url } = request;
const body = (await request.json()) as Record<string, string>;
logged.push({ method, path: new URL(url).pathname, body });

return Response.json(true);
},
});

const hooks = await telemetryDevPlugin({
apiKey: "td_live_test",
exportMode: "immediate",
fetch: async () => new Response(null, { status: 400 }),
})({ ...pluginInput, client });

await event(hooks, "session.created", { info: { id: "session-1" } });
await flush();
await hooks.event?.({
event: {
get type(): never {
throw new Error("poisoned event");
},
},
});

await vi.waitFor(() => expect(logged).toHaveLength(2));
expect(logged.map(({ method, path, body }) => [method, path, body.service, body.level])).toEqual([
["POST", "/log", "telemetry-dev", "error"],
["POST", "/log", "telemetry-dev", "error"],
]);
expect(logged[0]?.body.message).toContain("telemetry.dev log ingest failed: 400");
expect(logged[1]?.body.message).toContain("poisoned event");
expect(consoleSpies.flatMap((spy) => spy.mock.calls)).toEqual([]);
});

test("tool spans nest under the session's open chat span", async () => {
const harness = await makeHarness();
await chatMessage(harness.hooks);
Expand Down
2 changes: 1 addition & 1 deletion packages/otel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ When the SDK and the Claude API send the spans of a request to the same telemetr
| `serviceName` | `OTEL_SERVICE_NAME` | `unknown_service`; used on the metrics resource. |
| `environment` | `TELEMETRY_DEV_ENVIRONMENT` | `production`; used as `deployment.environment.name` on the metrics resource. |
| `fetch` | — | `globalThis.fetch`; override for tests or custom runtimes. |
| `onError` | — | Receives export and processor errors; errors are also reported through diagnostics. |
| `onError` | — | Receives export and processor errors, which diagnostics also get. A series of failed exports is one report. |
| `spanExporter` | — | Advanced/test seam replacing the built-in OTLP trace exporter. |

## Missing API key behavior
Expand Down
2 changes: 2 additions & 0 deletions packages/otel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ export {
} from "./session.ts";

export {
createExportFailures,
createLogExporter,
createMetricExporter,
createTraceExporter,
type ExportFailures,
maybeGzip,
otlpHeaders,
type OtlpTarget,
Expand Down
2 changes: 2 additions & 0 deletions packages/otel/src/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./metrics.ts";
import { StampingSpanProcessor } from "./processor.ts";
import {
createExportFailures,
createMetricExporter,
createTraceExporter,
otlpHeaders,
Expand Down Expand Up @@ -74,6 +75,7 @@ export class TelemetrySpanProcessor implements SpanProcessor {
fetchImpl: options.fetch ?? globalThis.fetch,
onError: options.onError,
exportTimeoutMillis: options.batch?.exportTimeoutMillis,
failures: createExportFailures(options.onError),
};

const exporter =
Expand Down
Loading
Loading