Skip to content
Prev Previous commit
Next Next commit
feat: export only high-level spans, CODEWORK_TRACE_LEVEL for the rest
A single run produced ~500 spans, a third of them Effect's own sql.execute.
Spans now default to Debug and export starts at Info. The command, agent
loop, model and tool calls, and sandbox create/wake are marked Info, so a
run exports about a dozen spans. CODEWORK_TRACE_LEVEL=debug exports every
span again.

Effect drops a span's children with it, so every span on the path from a
command to a model or tool call is Info, Loop.settleTools included. The
E2E test pins the exported span tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Oct 13, 2026
commit 40e75ffb541c0574345eaf3eef55a725b14b0d9d
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This file is the canonical source for unreleased changes and published release n

### Added

- `@codeworksh/cli`: Added OpenTelemetry export. Set `OTEL_EXPORTER_OTLP_ENDPOINT` and every command sends its traces and metrics over OTLP/HTTP, as protobuf by default or JSON with `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`. The standard `OTEL_*` variables configure it (headers, per-signal endpoints, `OTEL_SDK_DISABLED`, `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`); there is no settings key. Without an endpoint nothing is exported, and a bad configuration never stops a command.
- `@codeworksh/cli`: Added OpenTelemetry export. Set `OTEL_EXPORTER_OTLP_ENDPOINT` and every command sends its traces and metrics over OTLP/HTTP, as protobuf by default or JSON with `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`. The standard `OTEL_*` variables configure it (headers, per-signal endpoints, `OTEL_SDK_DISABLED`, `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`); there is no settings key. Without an endpoint nothing is exported, and a bad configuration never stops a command. Only the high-level spans are exported (command, agent loop, model and tool calls, sandbox create and wake); set `CODEWORK_TRACE_LEVEL=debug` to export every span, including database queries.
- `@codeworksh/harness`: Spans for model requests, tool calls and agent runs carry the OpenTelemetry GenAI attributes (provider, model, session, response id, finish reason, token counts, time to first token), and a failed model request ends its span with an error. Metrics: `codework.llm.duration`, `codework.llm.ttft`, `codework.llm.tokens` (by `type`: input, output, cache_read, cache_write, reasoning), `codework.llm.cost`, `codework.agent.duration`, `codework.tool.duration` (by `tool`), `codework.sandbox.provision.duration`, `codework.sandbox.exec.duration` and `codework.sandbox.active` (by `driver`), plus Effect's fiber runtime metrics (`child_fibers_*`). Prompts, file contents and model output are never exported.

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion packages/codework/src/cli/cmd/handlers/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Cmd } from "../cmd.ts";

export default Runtime.handler(
Cmd.commands.acp,
Effect.fn("CLI.acp")(function* () {
Effect.fn("CLI.acp", { level: "Info" })(function* () {
const shared = yield* Cmd.spec;
return yield* Server.serve(harnessOptions(shared)).pipe(Effect.catch(reportFailure));
}),
Expand Down
2 changes: 1 addition & 1 deletion packages/codework/src/cli/cmd/handlers/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const selectSandbox = Effect.fn("CLI.selectSandbox")(function* (input: Sandbox.S

export default Runtime.handler(
Cmd.commands.run,
Effect.fn("CLI.run")(function* ({
Effect.fn("CLI.run", { level: "Info" })(function* ({
prompt,
session,
cwd,
Expand Down
2 changes: 1 addition & 1 deletion packages/codework/src/cli/cmd/handlers/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const report = (error: unknown) => writeError(renderError(error)).pipe(Effect.an

export default Runtime.handler(
Cmd.commands.serve,
Effect.fn("CLI.serve")(function* ({ host, port, service }) {
Effect.fn("CLI.serve", { level: "Info" })(function* ({ host, port, service }) {
const shared = yield* Cmd.spec;
const harness = { ...harnessOptions(shared), watchModels: true };
const serve = Layer.launch(Server.layer({ host, port, harness }));
Expand Down
2 changes: 1 addition & 1 deletion packages/codework/src/cli/cmd/handlers/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const run = (binary: string, args: ReadonlyArray<string>, env: Record<string, st

export default Runtime.handler(
Cmd.commands.tui,
Effect.fn("CLI.tui")(function* ({ server, host, port, args }) {
Effect.fn("CLI.tui", { level: "Info" })(function* ({ server, host, port, args }) {
const shared = yield* Cmd.spec;

const program = Effect.gen(function* () {
Expand Down
24 changes: 22 additions & 2 deletions packages/codework/src/framework/otel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Config, ConfigProvider, Effect, Layer, Metric, Option, Schema } from "effect";
import { Config, ConfigProvider, Effect, Layer, Metric, Option, References, Schema } from "effect";
import { FetchHttpClient } from "effect/http";
import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/observability";
import { uuidv7 } from "uuidv7";
Expand Down Expand Up @@ -36,6 +36,26 @@ const fiberMetrics = Layer.unwrap(
}),
);

/**
* Export only the spans marked `level: "Info"`: the command, the agent loop, model
* and tool calls, and sandbox provisioning. Every other span, Effect's own `sql.*`
* among them, defaults to `Debug`; `CODEWORK_TRACE_LEVEL=debug` exports those too.
* Effect drops a span's children with it, so every span on the path from a command
* to a model call must be `Info`.
*/
const levels = { all: "All", trace: "Trace", debug: "Debug", info: "Info", warn: "Warn", error: "Error" } as const;

const traceLevels = Layer.unwrap(
Effect.gen(function* () {
const name = yield* Config.String("CODEWORK_TRACE_LEVEL").pipe(Config.withDefault("info"));
const minimum = levels[name.trim().toLowerCase() as keyof typeof levels] ?? "Info";
return Layer.mergeAll(
Layer.succeed(References.CurrentTraceLevel, "Debug"),
Layer.succeed(References.MinimumTraceLevel, minimum),
);
}),
);

interface OtlpResource {
readonly serviceName: string;
readonly serviceVersion: string;
Expand Down Expand Up @@ -79,7 +99,7 @@ export const layer = (options: { readonly version: string; readonly command: str
"codework.command": options.command,
},
};
return Layer.mergeAll(exporters(resource), fiberMetrics).pipe(
return Layer.mergeAll(exporters(resource), fiberMetrics, traceLevels).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(exporterDefaults),
Layer.catchCause(() => Layer.empty),
Expand Down
9 changes: 9 additions & 0 deletions packages/codework/test/__artifacts__/telemetry.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
"service.name": "codework",
"service.version": "<volatile>"
},
"exported": [
"(root) > CLI.run",
"CLI.run > Loop.run",
"Loop.run > Loop.runTurn",
"Loop.runTurn > Loop.runTurnAttempt",
"Loop.runTurnAttempt > LLM.run",
"Loop.runTurnAttempt > Loop.settleTools",
"Loop.settleTools > Loop.handleTool"
],
"spans": [
{
"name": "Loop.run",
Expand Down
16 changes: 16 additions & 0 deletions packages/codework/test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,20 @@ const metrics = (exports: ReadonlyArray<Export>) => {
);
};

/**
* Every exported span name, with its parent's. By default only the spans marked
* `Info` leave the process: Effect's own `sql.*` and the harness internals stay out.
*/
const exportedSpans = (exports: ReadonlyArray<Export>) => {
const spans = exports.flatMap((entry) =>
(entry.body.resourceSpans ?? []).flatMap((resource) => resource.scopeSpans.flatMap((scope) => scope.spans)),
);
const byId = new Map(spans.map((span) => [span.spanId, span]));
return [
...new Set(spans.map((span) => `${byId.get(span.parentSpanId ?? "")?.name ?? "(root)"} > ${span.name}`)),
].sort();
};

/** Every exported resource, traces and metrics alike, must describe the same process. */
const resources = (exports: ReadonlyArray<Export>) =>
exports.flatMap((entry) => [...(entry.body.resourceSpans ?? []), ...(entry.body.resourceMetrics ?? [])]);
Expand Down Expand Up @@ -341,6 +355,7 @@ describe("OTLP export", () => {
for (const key of Object.keys(env)) {
if (key.startsWith("OTEL_") && !configured.has(key)) delete env[key];
}
delete env.CODEWORK_TRACE_LEVEL;

const result = await runCli(
["--home", home, "--database", ":memory:", "--user-config-dir", settings, "run", "What does note.txt say?"],
Expand Down Expand Up @@ -371,6 +386,7 @@ describe("OTLP export", () => {
`${JSON.stringify(
{
resource: attributes(resources(collector.exports)[0]?.resource?.attributes),
exported: exportedSpans(collector.exports),
spans: genAiSpans(collector.exports),
metrics: metrics(collector.exports),
},
Expand Down
4 changes: 2 additions & 2 deletions packages/harness/src/effect/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const runtimeOverrides = (driver: SandboxDriver.Name, value: unknown) => {
return Effect.succeed(value as Readonly<Record<string, unknown>>);
};

export const create = Effect.fn("Sandbox.create")(function* (input: CreateInput) {
export const create = Effect.fn("Sandbox.create", { level: "Info" })(function* (input: CreateInput) {
const controller = yield* SandboxController.Controller;
const registry = yield* SandboxDriverRegistry.Registry;
const driverName = name(input.driver);
Expand Down Expand Up @@ -133,7 +133,7 @@ export const refresh = Effect.fn("Sandbox.refresh")(function* (id: SandboxInstan
return yield* controller.refresh(id);
});

export const wake = Effect.fn("Sandbox.wake")(function* (id: SandboxInstance.ID) {
export const wake = Effect.fn("Sandbox.wake", { level: "Info" })(function* (id: SandboxInstance.ID) {
const controller = yield* SandboxController.Controller;
return yield* controller.wake(id);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ class FailedTerminal extends Data.TaggedError("LLM.FailedTerminal")<{
* aikit event streams; production uses `open` above.
*/
export const make = (openStream: Open): Request => {
const request = Effect.fn("LLM.run", { kind: "client" })(function* (input: RequestInput) {
const request = Effect.fn("LLM.run", { kind: "client", level: "Info" })(function* (input: RequestInput) {
yield* Effect.annotateCurrentSpan({
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": input.provider,
Expand Down
15 changes: 10 additions & 5 deletions packages/harness/src/runner/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export const layer = (options: Options = {}) =>
yield* publishFailure(draft, { _tag: "interrupted" });
});

const settleTools = Effect.fn("Loop.settleTools")(function* (
const settleTools = Effect.fn("Loop.settleTools", { level: "Info" })(function* (
snapshot: State.Snapshot,
message: Message.AssistantMessage,
reason: "stop" | "length" | "toolUse",
Expand Down Expand Up @@ -187,7 +187,9 @@ export const layer = (options: Options = {}) =>
}

const settled = new Set<string>();
const handle = Effect.fn("Loop.handleTool")(function* (call: Message.ToolCallPendingPart) {
const handle = Effect.fn("Loop.handleTool", { level: "Info" })(function* (
call: Message.ToolCallPendingPart,
) {
yield* Effect.annotateCurrentSpan({
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": call.name,
Expand Down Expand Up @@ -262,7 +264,7 @@ export const layer = (options: Options = {}) =>
return interruptedCause;
});

const runTurnAttempt = Effect.fn("Loop.runTurnAttempt")(function* (
const runTurnAttempt = Effect.fn("Loop.runTurnAttempt", { level: "Info" })(function* (
promotion: Promotion,
snapshot: State.Snapshot,
) {
Expand Down Expand Up @@ -411,11 +413,14 @@ export const layer = (options: Options = {}) =>
);
});

const runTurn = Effect.fn("Loop.runTurn")(function* (promotion: Promotion, snapshot: State.Snapshot) {
const runTurn = Effect.fn("Loop.runTurn", { level: "Info" })(function* (
promotion: Promotion,
snapshot: State.Snapshot,
) {
return yield* runTurnAttempt(promotion, snapshot);
});

const run = Effect.fn("Loop.run")(function* (input: {
const run = Effect.fn("Loop.run", { level: "Info" })(function* (input: {
readonly sessionId: SessionSchema.ID;
readonly force: boolean;
}) {
Expand Down
4 changes: 2 additions & 2 deletions packages/harness/src/sandbox/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ export const make = Effect.fn("Sandbox.Controller.make")(function* (options: Opt
);
});

const create: Interface["create"] = Effect.fn("Sandbox.Controller.create")(function* (input) {
const create: Interface["create"] = Effect.fn("Sandbox.Controller.create", { level: "Info" })(function* (input) {
const driver = yield* registry.get(input.driver.name);
const config = yield* registry.decodeCreateConfig(input.driver.name, input.config);
const id = input.instanceId ?? SandboxInstance.ID.create();
Expand Down Expand Up @@ -756,7 +756,7 @@ export const make = Effect.fn("Sandbox.Controller.make")(function* (options: Opt
return yield* reload(id);
});

const wake: Interface["wake"] = Effect.fn("Sandbox.Controller.wake")(function* (id) {
const wake: Interface["wake"] = Effect.fn("Sandbox.Controller.wake", { level: "Info" })(function* (id) {
if (id === SandboxInstance.ID.local) return hostInfo();
const row = yield* requireRow(id);
if (row.status === "removed") {
Expand Down