From 9bbc874927bdfa060f827f82785ba6fdedf6e52b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 16:43:11 -0400 Subject: [PATCH 1/2] feat(log): add Effect logger compatibility layer Add a small Effect logger bridge that routes Effect logs through the existing util/log backend so Effect-native services can adopt structured logging without changing the app's current file and stderr logging setup. Preserve level mapping and forward annotations, spans, and causes into the legacy logger metadata. --- packages/opencode/src/util/effect-log.ts | 56 +++++++++++ .../opencode/test/util/effect-log.test.ts | 97 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 packages/opencode/src/util/effect-log.ts create mode 100644 packages/opencode/test/util/effect-log.test.ts diff --git a/packages/opencode/src/util/effect-log.ts b/packages/opencode/src/util/effect-log.ts new file mode 100644 index 00000000000..59d42e7ed65 --- /dev/null +++ b/packages/opencode/src/util/effect-log.ts @@ -0,0 +1,56 @@ +import { Cause, Logger } from "effect" +import { CurrentLogAnnotations, CurrentLogSpans } from "effect/References" + +import { Log } from "./log" + +function text(input: unknown): string { + if (Array.isArray(input)) return input.map(text).join(" ") + if (input instanceof Error) return input.message + if (typeof input === "string") return input + if (typeof input === "object" && input !== null) { + try { + return JSON.stringify(input) + } catch { + return String(input) + } + } + return String(input) +} + +export function make(tags?: Record) { + const log = Log.create(tags) + + return Logger.make((options) => { + const annotations = options.fiber.getRef(CurrentLogAnnotations as never) as Readonly> + const spans = options.fiber.getRef(CurrentLogSpans as never) as ReadonlyArray + const extra = { + ...annotations, + fiber: options.fiber.id, + spans: spans.length + ? spans.map(([label, start]) => ({ + label, + duration: options.date.getTime() - start, + })) + : undefined, + cause: options.cause.reasons.length ? Cause.pretty(options.cause) : undefined, + } + + if (options.logLevel === "Debug" || options.logLevel === "Trace") { + return log.debug(text(options.message), extra) + } + + if (options.logLevel === "Info") { + return log.info(text(options.message), extra) + } + + if (options.logLevel === "Warn") { + return log.warn(text(options.message), extra) + } + + return log.error(text(options.message), extra) + }) +} + +export function layer(tags?: Record, options?: { mergeWithExisting?: boolean }) { + return Logger.layer([make(tags)], options) +} diff --git a/packages/opencode/test/util/effect-log.test.ts b/packages/opencode/test/util/effect-log.test.ts new file mode 100644 index 00000000000..14b349aabfb --- /dev/null +++ b/packages/opencode/test/util/effect-log.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, expect, mock, test } from "bun:test" +import { Cause, Effect } from "effect" +import { CurrentLogAnnotations, CurrentLogSpans } from "effect/References" + +const debug = mock(() => {}) +const info = mock(() => {}) +const warn = mock(() => {}) +const error = mock(() => {}) +const create = mock(() => ({ + debug, + info, + warn, + error, + tag() { + return this + }, + clone() { + return this + }, + time() { + return { + stop() {}, + [Symbol.dispose]() {}, + } + }, +})) + +mock.module("../../src/util/log", () => ({ + Log: { + create, + }, +})) + +const EffectLog = await import("../../src/util/effect-log") + +beforeEach(() => { + create.mockClear() + debug.mockClear() + info.mockClear() + warn.mockClear() + error.mockClear() +}) + +test("EffectLog.layer routes info logs through util/log", async () => { + await Effect.runPromise(Effect.logInfo("hello").pipe(Effect.provide(EffectLog.layer({ service: "effect-test" })))) + + expect(create).toHaveBeenCalledWith({ service: "effect-test" }) + expect(info).toHaveBeenCalledWith("hello", expect.any(Object)) +}) + +test("EffectLog.layer forwards annotations and spans to util/log", async () => { + await Effect.runPromise( + Effect.logInfo("hello").pipe( + Effect.annotateLogs({ requestId: "req-123" }), + Effect.withLogSpan("provider-auth"), + Effect.provide(EffectLog.layer({ service: "effect-test-meta" })), + ), + ) + + expect(info).toHaveBeenCalledWith( + "hello", + expect.objectContaining({ + requestId: "req-123", + spans: expect.arrayContaining([ + expect.objectContaining({ + label: "provider-auth", + }), + ]), + }), + ) +}) + +test("EffectLog.make formats structured messages and causes for legacy logger", () => { + const logger = EffectLog.make({ service: "effect-test-struct" }) + + logger.log({ + message: { hello: "world" }, + logLevel: "Warn", + cause: Cause.fail(new Error("boom")), + fiber: { + id: 123n, + getRef(ref: unknown) { + if (ref === CurrentLogAnnotations) return {} + if (ref === CurrentLogSpans) return [] + return undefined + }, + }, + date: new Date(), + } as never) + + expect(warn).toHaveBeenCalledWith( + '{"hello":"world"}', + expect.objectContaining({ + fiber: 123n, + }), + ) +}) From e13d44684da8d7e3b4e68dcf08c2cc1db3708fc2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 17:00:20 -0400 Subject: [PATCH 2/2] test(log): avoid global logger module mocking Replace the effect-log tests' global util/log module mock with local spies on Log.create so the logger compatibility tests no longer leak into unrelated unit suites. Keep the coverage for routing, annotations, spans, and cause formatting while allowing the full package test workflow to run cleanly. --- .../opencode/test/util/effect-log.test.ts | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/opencode/test/util/effect-log.test.ts b/packages/opencode/test/util/effect-log.test.ts index 14b349aabfb..e70d79b1628 100644 --- a/packages/opencode/test/util/effect-log.test.ts +++ b/packages/opencode/test/util/effect-log.test.ts @@ -1,21 +1,25 @@ -import { beforeEach, expect, mock, test } from "bun:test" +import { afterEach, expect, mock, spyOn, test } from "bun:test" import { Cause, Effect } from "effect" import { CurrentLogAnnotations, CurrentLogSpans } from "effect/References" +import * as EffectLog from "../../src/util/effect-log" +import { Log } from "../../src/util/log" + const debug = mock(() => {}) const info = mock(() => {}) const warn = mock(() => {}) const error = mock(() => {}) -const create = mock(() => ({ + +const logger = { debug, info, warn, error, tag() { - return this + return logger }, clone() { - return this + return logger }, time() { return { @@ -23,18 +27,9 @@ const create = mock(() => ({ [Symbol.dispose]() {}, } }, -})) - -mock.module("../../src/util/log", () => ({ - Log: { - create, - }, -})) +} -const EffectLog = await import("../../src/util/effect-log") - -beforeEach(() => { - create.mockClear() +afterEach(() => { debug.mockClear() info.mockClear() warn.mockClear() @@ -42,6 +37,8 @@ beforeEach(() => { }) test("EffectLog.layer routes info logs through util/log", async () => { + using create = spyOn(Log, "create").mockReturnValue(logger) + await Effect.runPromise(Effect.logInfo("hello").pipe(Effect.provide(EffectLog.layer({ service: "effect-test" })))) expect(create).toHaveBeenCalledWith({ service: "effect-test" }) @@ -49,6 +46,8 @@ test("EffectLog.layer routes info logs through util/log", async () => { }) test("EffectLog.layer forwards annotations and spans to util/log", async () => { + using create = spyOn(Log, "create").mockReturnValue(logger) + await Effect.runPromise( Effect.logInfo("hello").pipe( Effect.annotateLogs({ requestId: "req-123" }), @@ -57,6 +56,7 @@ test("EffectLog.layer forwards annotations and spans to util/log", async () => { ), ) + expect(create).toHaveBeenCalledWith({ service: "effect-test-meta" }) expect(info).toHaveBeenCalledWith( "hello", expect.objectContaining({ @@ -71,9 +71,10 @@ test("EffectLog.layer forwards annotations and spans to util/log", async () => { }) test("EffectLog.make formats structured messages and causes for legacy logger", () => { - const logger = EffectLog.make({ service: "effect-test-struct" }) + using create = spyOn(Log, "create").mockReturnValue(logger) + const effect = EffectLog.make({ service: "effect-test-struct" }) - logger.log({ + effect.log({ message: { hello: "world" }, logLevel: "Warn", cause: Cause.fail(new Error("boom")), @@ -88,6 +89,7 @@ test("EffectLog.make formats structured messages and causes for legacy logger", date: new Date(), } as never) + expect(create).toHaveBeenCalledWith({ service: "effect-test-struct" }) expect(warn).toHaveBeenCalledWith( '{"hello":"world"}', expect.objectContaining({