refactor(core): replace legacy logger with Effect logging (#31310)

This commit is contained in:
Dax 2026-06-08 15:41:56 -04:00 committed by GitHub
commit c06ad7c881
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 697 additions and 2242 deletions

View file

@ -1,73 +0,0 @@
import { Cause, Effect, Logger, References } from "effect"
import * as Log from "../util/log"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
// oxlint-disable-next-line no-base-to-string
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})

View file

@ -1,107 +0,0 @@
import { Effect, Layer, Logger } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { ensureProcessMetadata } from "../util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...value] = x.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
const processMetadata = ensureProcessMetadata("main")
const attributes: Record<string, string> = (() => {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
})()
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...attributes,
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
}
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
// @effect/opentelemetry creates a NodeTracerProvider but never calls
// register(), so the global @opentelemetry/api context manager stays
// as the no-op default. Non-Effect code (like the AI SDK) that calls
// tracer.startActiveSpan() relies on context.active() to find the
// parent span - without a real context manager every span starts a
// new trace. Registering AsyncLocalStorageContextManager fixes this.
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const mgr = new AsyncLocalStorageContextManager()
mgr.enable()
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
headers,
}),
),
}))
}
export const layer = !base
? EffectLogger.layer
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs())
}),
)
export const Observability = { enabled, layer }

View file

@ -1,6 +1,6 @@
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
import { memoMap } from "./memo-map"
import { Observability } from "./observability"
import { Observability } from "../observability"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined

View file

@ -410,9 +410,7 @@ export const layerWith = (options?: LayerOptions) =>
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) =>
Effect.logError("Event observer failed").pipe(
Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }),
),
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
),
)

View file

@ -10,11 +10,8 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { Global } from "../global"
import { NonNegativeInt } from "../schema"
import * as Log from "../util/log"
import { sanitizedProcessEnv } from "../util/opencode-process"
import { which } from "../util/which"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
@ -146,7 +143,9 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ri
export const use = serviceUse(Service)
function env() {
const env = sanitizedProcessEnv()
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH
return env
}
@ -307,7 +306,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
log.info("downloading ripgrep", { url })
yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
@ -418,7 +417,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
})
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
log.info("tree", input)
yield* Effect.logInfo("tree", input)
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {

View file

@ -4,13 +4,11 @@ import type { PlatformError } from "effect/PlatformError"
import { FSUtil } from "../fs-util"
import { Glob } from "../util/glob"
import { Global } from "../global"
import * as Log from "../util/log"
import { serviceUse } from "../effect/service-use"
import { makeRuntime } from "../effect/runtime"
import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep"
const log = Log.create({ service: "file.search" })
const root = path.join(Global.Path.cache, "fff")
export type Item = Ripgrep.Item
@ -220,12 +218,14 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (!scanned.ok || !scanned.value) {
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
state.pick.delete(dir)
log.warn("fff scan not ready", { dir })
yield* Effect.logWarning("fff scan not ready", { dir })
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
}
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error })
if (!git.ok) {
yield* Effect.logWarning("fff git refresh failed", { dir, error: git.error })
}
})
// Create (or return) the picker for a directory. Creation is synchronous
@ -244,10 +244,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (pending) return yield* Deferred.await(pending)
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => {
log.warn("fff availability check failed", { error })
return Effect.succeed(false)
}),
Effect.catch((error) => Effect.logWarning("fff availability check failed", { error }).pipe(Effect.as(false))),
)
if (!available) return undefined
@ -272,7 +269,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
}),
)
if (!made.ok) {
log.warn("fff init failed", { dir, error: made.error })
yield* Effect.logWarning("fff init failed", { dir, error: made.error })
const err = new Error(made.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
@ -355,14 +352,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
pageSize: limit,
}),
).pipe(
Effect.catch((error) => {
log.warn(`fff ${kind} search failed`, { dir, query, error })
return Effect.succeed<Fff.Result<string[]> | undefined>(undefined)
}),
Effect.catch((error) =>
Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
Effect.as<Fff.Result<string[]> | undefined>(undefined),
),
),
)
if (!fffResult) return undefined
if (!fffResult.ok) {
log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return undefined
}
@ -393,14 +391,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
timeBudgetMs: 1_500,
}),
).pipe(
Effect.catch((error) => {
log.warn("fff grep failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Grep> | undefined>(undefined)
}),
Effect.catch((error) =>
Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error }).pipe(
Effect.as<Fff.Result<Fff.Grep> | undefined>(undefined),
),
),
)
if (!fffGrep) return yield* rip(input)
if (!fffGrep.ok) {
log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
yield* Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
return yield* rip(input)
}
@ -432,10 +431,11 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
pageSize: limit,
}),
).pipe(
Effect.catch((error) => {
log.warn("fff glob failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Search> | undefined>(undefined)
}),
Effect.catch((error) =>
Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error }).pipe(
Effect.as<Fff.Result<Fff.Search> | undefined>(undefined),
),
),
)
if (fffGlob?.ok) {
@ -453,7 +453,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
truncated: fffGlob.value.totalMatched > rows.length,
}
} else if (fffGlob) {
log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
yield* Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
// fall through to the fallback
}
}
@ -500,13 +500,16 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (!entry) return
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
Effect.catch((error) => {
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error })
return Effect.succeed<Fff.Result<boolean> | undefined>(undefined)
}),
Effect.catch((error) =>
Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error }).pipe(
Effect.as<Fff.Result<boolean> | undefined>(undefined),
),
),
)
if (!out) return
if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
if (!out.ok) {
yield* Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
}
})
return Service.of({ files, tree, search, file, glob, open, warm, release })

View file

@ -12,13 +12,11 @@ import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy"
import * as Log from "../util/log"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
@ -38,8 +36,7 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
} catch {
return
}
})
@ -71,14 +68,17 @@ export const layer = Layer.effect(
const backend = getBackend()
const location = yield* Location.Service
if (!backend) {
log.error("watcher backend not supported", { directory: location.directory, platform: process.platform })
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
}
const w = watcher()
if (!w) return Service.of({})
log.info("watcher backend", { directory: location.directory, platform: process.platform, backend })
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
@ -103,9 +103,8 @@ export const layer = Layer.effect(
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.void
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
}),
)
}
@ -133,8 +132,9 @@ export const layer = Layer.effect(
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
)
}),
),
)

View file

@ -228,7 +228,7 @@ export const layer = Layer.effect(
}),
).pipe(
Effect.tapCause((cause) =>
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)),
Effect.logError("Failed to fetch models.dev", { cause: cause }),
),
Effect.ignore,
)

View file

@ -0,0 +1,21 @@
export * as Observability from "./observability"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
export const layer = Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}),
)

View file

@ -0,0 +1,66 @@
import { Formatter, Logger, type LogLevel } from "effect"
import path from "path"
import { Global } from "../global"
import { runID } from "./shared"
function formatter(id: string = runID) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
["timestamp", output.timestamp],
["level", output.level],
["run", id],
...messages.flatMap((value) => (plain(value) ? flatten(value) : [["message", value] as const])),
...(output.cause === undefined ? [] : [["cause", output.cause] as const]),
...flatten(output.spans),
...flatten(output.annotations),
]
.map(([key, value]) => `${key}=${format(value)}`)
.join(" ")
})
}
function flatten(input: Record<string, unknown>, prefix = "", seen = new WeakSet<object>()): Array<readonly [string, unknown]> {
if (seen.has(input)) return [[prefix, "[Circular]"]]
seen.add(input)
const entries = Object.entries(input)
if (entries.length === 0 && prefix) return [[prefix, input]]
return entries.flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key
return plain(value) ? flatten(value, path, seen) : [[path, value] as const]
})
}
function plain(input: unknown): input is Record<string, unknown> {
if (input === null || typeof input !== "object" || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}
function format(input: unknown) {
const value = typeof input === "string" ? input : Formatter.format(input)
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
}
export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) {
return Logger.toFile(formatter(id), file, { flag: "a", batchWindow: 0 })
}
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
export function minimumLogLevel() {
const value = process.env.OPENCODE_LOG_LEVEL?.toUpperCase()
const levels = {
DEBUG: "Debug",
INFO: "Info",
WARN: "Warn",
ERROR: "Error",
} as const satisfies Record<string, LogLevel.LogLevel>
return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO
}
export function loggers() {
return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()]
}
export * as Logging from "./logging"

View file

@ -0,0 +1,79 @@
import { Layer } from "effect"
import { OtlpLogger } from "effect/unstable/observability"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { runID } from "./shared"
const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, entry) => {
const [key, ...value] = entry.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
function resourceAttributes() {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
}
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...resourceAttributes(),
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.run": runID,
"service.instance.id": runID,
},
}
}
export function loggers() {
if (!endpoint) return []
return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })]
}
export async function tracingLayer() {
if (!endpoint) return Layer.empty
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
const manager = new AsyncLocalStorageContextManager()
manager.enable()
context.setGlobalContextManager(manager)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers,
}),
),
}))
}
export * as Otlp from "./otlp"

View file

@ -0,0 +1 @@
export const runID = crypto.randomUUID().slice(0, 8)

View file

@ -103,9 +103,7 @@ export const layer = Layer.effect(
(materializer) =>
materializer.run.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize project reference").pipe(
Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }),
),
Effect.logWarning("failed to materialize project reference", { name: materializer.name, repository: materializer.repository, cause }),
),
),
{ concurrency: 4, discard: true },

View file

@ -7,9 +7,7 @@ import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { lazy } from "./util/lazy"
import * as Log from "./util/log"
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
@ -158,7 +156,7 @@ export const layer = Layer.effect(
const session = sessions.get(id)
if (!session) return false
sessions.delete(id)
log.info("removing session", { id })
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
return true
@ -179,7 +177,7 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const id = PtyID.ascending()
log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(input.command, input.args, {
@ -231,7 +229,7 @@ export const layer = Layer.effect(
if (session.info.status === "exited") return
runFork(
Effect.gen(function* () {
log.info("session exited", { id, exitCode })
yield* Effect.logInfo("session exited", { id, exitCode })
session.info.status = "exited"
yield* events.publish(Event.Exited, { id, exitCode })
yield* removeSession(id)
@ -263,7 +261,7 @@ export const layer = Layer.effect(
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
log.info("client connected to session", { id, directory: location.directory })
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
@ -299,7 +297,6 @@ export const layer = Layer.effect(
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}

View file

@ -5,4 +5,4 @@ export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID))
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))

View file

@ -6,7 +6,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { AbsolutePath } from "../schema"
import * as Log from "../util/log"
const skillConcurrency = 4
const fileConcurrency = 8
@ -71,7 +70,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const log = Log.create({ service: "skill-discovery" })
const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
@ -87,7 +85,7 @@ export const layer = Layer.effect(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.catch((error) => Effect.sync(() => log.error("failed to download skill file", { url, error }))),
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
)
})
@ -100,10 +98,9 @@ export const layer = Layer.effect(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((error) => {
log.error("failed to fetch skill index", { url: index, error })
return Effect.succeed(undefined)
}),
Effect.catch((error) =>
Effect.logError("failed to fetch skill index", { url: index, error }).pipe(Effect.as(undefined)),
),
)
if (!data) return []
@ -111,17 +108,14 @@ export const layer = Layer.effect(
return yield* Effect.forEach(
data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) {
log.warn("skill entry has unsafe name", { url: index, skill: skill.name })
return []
}
if (!skill.files.includes("SKILL.md") && !skill.files.includes(`${skill.name}.md`)) {
log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name })
return []
}
const root = path.resolve(sourceRoot, skill.name)
if (!FSUtil.contains(sourceRoot, root) || root === sourceRoot) {
log.warn("skill entry escapes cache root", { url: index, skill: skill.name })
return []
}
@ -144,7 +138,6 @@ export const layer = Layer.effect(
}
})
if (files.some((file) => file === undefined)) {
log.warn("skill entry has unsafe file", { url: index, skill: skill.name })
return []
}
return [{ skill, root, files: files as { url: string; destination: string }[] }]

View file

@ -1,197 +0,0 @@
export * as Log from "./log"
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import * as Global from "../global"
import { Schema } from "effect"
import { Glob } from "./glob"
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
const keep = 10
const initializedRunID = "OPENCODE_LOG_INITIALIZED_RUN_ID"
let level: Level = "INFO"
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[level]
}
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void
info(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
tag(key: string, value: string): Logger
clone(): Logger
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void
[Symbol.dispose](): void
}
}
const loggers = new Map<string, Logger>()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
dev?: boolean
level?: Level
}
let logpath = ""
export function file() {
return logpath
}
export function getLevel(): Level {
return level
}
let write = (msg: any) => {
process.stderr.write(msg)
return msg.length
}
export async function init(options: Options) {
if (options.level) level = options.level
void cleanup(Global.Path.log)
if (options.print) return
logpath = path.join(
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const runID = process.env.OPENCODE_RUN_ID
const shouldTruncate = !options.dev || !runID || process.env[initializedRunID] !== runID
if (shouldTruncate) await fs.truncate(logpath).catch(() => {})
if (options.dev && runID) process.env[initializedRunID] = runID
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}
async function cleanup(dir: string) {
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const service = tags["service"]
if (service && typeof service === "string") {
const cached = loggers.get(service)
if (cached) {
return cached
}
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const prefix = `${key}=`
if (value instanceof Error) return prefix + formatError(value)
if (typeof value === "object") return prefix + JSON.stringify(value)
return prefix + value
})
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
if (shouldLog("INFO")) {
write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
if (shouldLog("ERROR")) {
write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
if (shouldLog("WARN")) {
write("WARN " + build(message, extra))
}
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
if (service && typeof service === "string") {
loggers.set(service, result)
}
return result
}

View file

@ -1,24 +0,0 @@
export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
export function ensureRunID() {
return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
}
export function ensureProcessRole(fallback: "main" | "worker") {
return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
}
export function ensureProcessMetadata(fallback: "main" | "worker") {
return {
runID: ensureRunID(),
processRole: ensureProcessRole(fallback),
}
}
export function sanitizedProcessEnv(overrides?: Record<string, string>) {
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
return overrides ? Object.assign(env, overrides) : env
}