feat(sdk): configure embedded logging (#41809)

This commit is contained in:
James Long
2026-08-11 14:03:55 -04:00
committed by GitHub
parent 136bad9f17
commit eeb870b0cc
4 changed files with 111 additions and 11 deletions
+7 -2
View File
@@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
@@ -44,7 +44,12 @@ const layer = Layer.effect(
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
inherit,
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
+14 -1
View File
@@ -13,6 +13,19 @@ const session = yield * opencode.sessions.get({ sessionID })
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level:
```ts
const opencode =
yield *
OpenCode.create({
log: {
level: "warn",
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
},
})
```
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
The same constructor is available as a service Layer:
@@ -26,4 +39,4 @@ const program = Effect.gen(function* () {
yield * program.pipe(Effect.provide(OpenCode.layer))
```
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.
`OpenCode.layer` adapts the silent default `OpenCode.create()` for dependency injection; use `OpenCode.layerWith(options)` to configure the host.
+71
View File
@@ -0,0 +1,71 @@
import { Context, Formatter, Layer, Logger, References } from "effect"
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
export type LogEntry = {
readonly level: LogLevel
readonly message: string
readonly attributes?: Readonly<Record<string, unknown>>
readonly cause?: unknown
}
export type LogWriter = (entry: LogEntry) => void
export type LogOptions = {
readonly level?: LogLevel
readonly emit: LogWriter
}
const levels: Record<LogLevel, Logger.Options<unknown>["logLevel"]> = {
trace: "Trace",
debug: "Debug",
info: "Info",
warn: "Warn",
error: "Error",
fatal: "Fatal",
}
function normalizeLevel(level: Logger.Options<unknown>["logLevel"]): LogLevel | undefined {
const output = Object.fromEntries(Object.entries(levels).map(([name, effect]) => [effect, name]))
return output[level] as LogLevel
}
export function layer(log?: LogOptions) {
const logger = Logger.make((options) => {
if (!log) return
const level = normalizeLevel(options.logLevel)
if (!level) return
const entry = Logger.formatStructured.log(options)
const values = Array.isArray(entry.message) ? entry.message : [entry.message]
const [message, ...data] = values
const details =
data.length === 1 && !Array.isArray(data[0]) ? (data[0] as Readonly<Record<string, unknown>>) : undefined
const { cause: detailCause, ...detailAttributes } = details ?? {}
const attributes = {
...entry.annotations,
...detailAttributes,
...(Object.keys(entry.spans).length > 0 ? { spans: entry.spans } : {}),
...(!details && data.length > 0 ? { data: data.length === 1 ? data[0] : data } : {}),
}
try {
log.emit({
level,
message: typeof message === "string" ? message : Formatter.format(message),
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
...(entry.cause === undefined && detailCause === undefined ? {} : { cause: entry.cause ?? detailCause }),
})
} catch {
// A host logger must not break OpenCode operations.
}
})
return Layer.merge(
Logger.layer([logger], { mergeWithExisting: false }),
Layer.succeed(References.MinimumLogLevel, levels[log?.level ?? "info"]),
)
}
export function context(source: Context.Context<never>) {
return Context.make(Logger.CurrentLoggers, Context.get(source, Logger.CurrentLoggers)).pipe(
Context.add(References.MinimumLogLevel, Context.get(source, References.MinimumLogLevel)),
)
}
+19 -8
View File
@@ -2,18 +2,27 @@ import { OpenCode } from "@opencode-ai/client/effect"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import * as Logging from "./logging"
export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) {
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
import type { LogOptions } from "./logging"
export type CreateOptions = ServerOptions & {
readonly log?: LogOptions
}
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
const { log, ...server } = options
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes({
...options,
app: { ...options.app, name: options.app?.name ?? "sdk" },
database: { path: ":memory:", ...options.database },
}).pipe(Layer.provide(HttpServer.layerServices)),
...server,
app: { ...server.app, name: server.app?.name ?? "sdk" },
database: { path: ":memory:", ...server.database },
}).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
),
),
(runtime) => runtime.disposeEffect,
@@ -21,7 +30,9 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
const context = yield* runtime.contextEffect
const plugins = Context.get(context, SdkPlugins.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
Logging.context(context),
)(router.asHttpEffect())
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
preconnect: () => undefined,
}) satisfies typeof globalThis.fetch