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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ This file is the canonical source for unreleased changes and published release n

## [Unreleased]

### 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. 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

- `@codeworksh/plugin`: Fixed tools whose parameters are `Schema.Struct({})` being rejected by OpenAI and Anthropic. Their JSON Schema is now `{ "type": "object", "properties": {} }` instead of `{ "not": { "type": "null" } }`.
Expand All @@ -20,6 +25,7 @@ This file is the canonical source for unreleased changes and published release n

### Internal

- Instruction-file discovery probes candidates with `exists` and only stats the files that exist, so a missing `AGENTS.md` or `CLAUDE.md` no longer records a failed span in debug traces.
- The `codework tui` E2E artifact now records the CLI version as `<version>`, so it no longer goes stale on every version bump.
- Tests load provider and sandbox credentials from the root `.env.local` instead of one file per package.
- Added a live E2E test that runs `codework run` on a memory, Daytona and Vercel sandbox and checks the sandbox is stopped on exit.
Expand Down
1 change: 1 addition & 0 deletions packages/codework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@effect/platform-node": "4.0.1",
"effect": "4.0.1",
"jsonc-parser": "3.3.1",
"uuidv7": "^1.2.1",
"zod": "4.6.5"
},
"devDependencies": {
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
4 changes: 2 additions & 2 deletions packages/codework/src/cli/cmd/handlers/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const awaitMessage = Effect.fn("CLI.awaitMessage")(function* (ended: Queue.Queue
}
});

const selectSandbox = Effect.fn("CLI.selectSandbox")(function* (input: Sandbox.Selection) {
const selectSandbox = Effect.fn("CLI.selectSandbox", { level: "Info" })(function* (input: Sandbox.Selection) {
const selection = yield* Sandbox.resolve(input);
if (selection.created && selection.info !== undefined) {
const id = selection.info.id;
Expand All @@ -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
97 changes: 97 additions & 0 deletions packages/codework/src/framework/otel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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";

// Configured by the standard `OTEL_*` variables alone. A configuration that fails
// to build runs the command without export rather than failing it.

// The OTel spec defaults `OTEL_{TRACES,METRICS}_EXPORTER` to `otlp`; Effect reads an
// unset list as none, so an endpoint alone would export nothing.
const exporterDefaults = ConfigProvider.layerAdd(
ConfigProvider.fromUnknown({ OTEL_TRACES_EXPORTER: ["otlp"], OTEL_METRICS_EXPORTER: ["otlp"] }),
);

// Effect's fiber metrics hook every fiber start and end, so they follow metrics export.
const metricsEnabled = Config.all({
disabled: Config.Boolean("OTEL_SDK_DISABLED").pipe(Config.withDefault(false)),
endpoint: Config.String("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT").pipe(
Config.orElse(() => Config.String("OTEL_EXPORTER_OTLP_ENDPOINT")),
Config.option,
),
exporters: Config.Array(Schema.String, "OTEL_METRICS_EXPORTER"),
}).pipe(
Config.map(
({ disabled, endpoint, exporters }) =>
!disabled && Option.isSome(endpoint) && exporters.some((exporter) => exporter.trim().toLowerCase() === "otlp"),
),
);

const fiberMetrics = Layer.unwrap(
Effect.gen(function* () {
return (yield* metricsEnabled) ? Metric.enableRuntimeMetricsLayer : Layer.empty;
}),
);

// Only spans marked `level: "Info"` are exported; the rest, Effect's own `sql.*`
// among them, default to `Debug`. Effect drops a span's children with it, so every
// span on the path from a command to a model or tool 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;
readonly attributes: Record<string, string>;
}

// Effect has no gRPC exporter: `grpc` exports nothing rather than sending HTTP to a gRPC port.
const exporters = (resource: OtlpResource) =>
Layer.unwrap(
Effect.gen(function* () {
const protocol = yield* Config.String("OTEL_EXPORTER_OTLP_PROTOCOL").pipe(Config.withDefault("http/protobuf"));
const format = {
"http/json": OtlpSerialization.layerJson,
"http/protobuf": OtlpSerialization.layerProtobuf,
}[protocol.trim().toLowerCase()];
if (format === undefined) return Layer.empty;
return Layer.mergeAll(
OtlpTracer.layerFromConfig({ resource }),
OtlpMetrics.layerFromConfig({ resource }),
).pipe(Layer.provide(format));
}),
);

const channel = (version: string) =>
version.includes("-canary.") ? "canary" : version.includes("-dev.") ? "dev" : "latest";

const instanceId = uuidv7();

export const layer = (options: { readonly version: string; readonly command: string }) => {
const resource: OtlpResource = {
serviceName: "codework",
serviceVersion: options.version,
attributes: {
"service.instance.id": instanceId,
"deployment.environment.name": channel(options.version),
"codework.command": options.command,
},
};
return Layer.mergeAll(exporters(resource), fiberMetrics, traceLevels).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(exporterDefaults),
Layer.catchCause(() => Layer.empty),
);
};

export * as Otel from "./otel.ts";
21 changes: 17 additions & 4 deletions packages/codework/src/framework/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Effect } from "effect";
import { Command } from "effect/cli";
import type { CommandError } from "../cli/error.ts";
import { Otel } from "./otel.ts";
import { Spec } from "./spec.ts";

/**
Expand Down Expand Up @@ -72,21 +73,33 @@ export function handlers<const Root extends Spec.Any>(
}

export function run(root: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(root, handlers), options);
return Command.run(provide(root, handlers, { version: options.version, path: [] }), options);
}

function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
function provide(
node: Spec.Any,
handlers: ReadonlyArray<LazyHandler>,
context: { readonly version: string; readonly path: ReadonlyArray<string> },
): ProvidedCommand {
const found = handlers.find((entry) => entry.spec === node.spec);
const command = context.path.length === 0 ? node.name : context.path.join(" ");
const spec = found
? node.spec.pipe(
Command.withHandler((input) =>
Effect.promise(found.load).pipe(Effect.flatMap((module) => module.default(input))),
Effect.promise(found.load).pipe(
Effect.flatMap((module) => module.default(input)),
Effect.provide(Otel.layer({ version: context.version, command })),
),
),
)
: node.spec;
const children = Object.values(node.commands);
if (children.length === 0) return spec as ProvidedCommand;
return spec.pipe(Command.withSubcommands(children.map((child) => provide(child, handlers)))) as ProvidedCommand;
return spec.pipe(
Command.withSubcommands(
children.map((child) => provide(child, handlers, { ...context, path: [...context.path, child.name] })),
),
) as ProvidedCommand;
}

export * as Runtime from "./runtime.ts";
13 changes: 10 additions & 3 deletions packages/codework/src/server/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ const sessionInfo = Effect.fn("Server.sessionInfo")(function* (sessionId: Sessio
return toSessionInfo(yield* found.value.info);
});

// RpcServer's request span has no level, so it is Debug and drops every Info span under it.
// Provisioning a sandbox is worth exporting, so it starts a trace of its own.
const exported = <A, E, R>(name: string, self: Effect.Effect<A, E, R>) =>
self.pipe(Effect.withSpan(name, { level: "Info", root: true }));

const releaseOnFailure = (selection: Effect.Success<ReturnType<typeof Sandbox.resolve>> | undefined) =>
selection?.created && selection.info !== undefined
? Sandbox.stop(selection.info.id).pipe(Effect.catchCause(Effect.logError))
Expand All @@ -61,7 +66,8 @@ export const layer = Contract.Api.toLayer(
"auth.refresh": ({ provider }) => oauth.refresh(provider),
"auth.logout": ({ provider }) => oauth.logout(provider),
"session.create": Effect.fnUntraced(function* ({ title, directory, hostDir, sandbox, runtime }) {
const selection = sandbox === undefined ? undefined : yield* Sandbox.resolve(sandbox);
const selection =
sandbox === undefined ? undefined : yield* exported("Server.session.create", Sandbox.resolve(sandbox));
const selected = selection?.info;
const handle = yield* Session.create({
...(title === undefined ? {} : { title }),
Expand Down Expand Up @@ -93,7 +99,8 @@ export const layer = Contract.Api.toLayer(
return toSessionInfo(yield* handle.info);
}),
"session.relink": Effect.fnUntraced(function* ({ sessionId, sandbox, directory }) {
const selection = sandbox === undefined ? undefined : yield* Sandbox.resolve(sandbox);
const selection =
sandbox === undefined ? undefined : yield* exported("Server.session.relink", Sandbox.resolve(sandbox));
const selected = selection?.info;
const handle = yield* Session.relink({
sessionId,
Expand Down Expand Up @@ -132,7 +139,7 @@ export const layer = Contract.Api.toLayer(
return (yield* Sandbox.list()).map(toSandboxInfo);
}),
"sandbox.create": Effect.fnUntraced(function* ({ driver }) {
return toSandboxInfo(yield* Sandbox.create({ driver }));
return toSandboxInfo(yield* exported("Server.sandbox.create", Sandbox.create({ driver })));
}),
"sandbox.register": Effect.fnUntraced(function* ({ driver, providerResourceId }) {
return toSandboxInfo(yield* Sandbox.register({ driver, providerResourceId }));
Expand Down
Loading
Loading