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..e70d79b1628 --- /dev/null +++ b/packages/opencode/test/util/effect-log.test.ts @@ -0,0 +1,99 @@ +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 logger = { + debug, + info, + warn, + error, + tag() { + return logger + }, + clone() { + return logger + }, + time() { + return { + stop() {}, + [Symbol.dispose]() {}, + } + }, +} + +afterEach(() => { + debug.mockClear() + info.mockClear() + warn.mockClear() + error.mockClear() +}) + +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" }) + expect(info).toHaveBeenCalledWith("hello", expect.any(Object)) +}) + +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" }), + Effect.withLogSpan("provider-auth"), + Effect.provide(EffectLog.layer({ service: "effect-test-meta" })), + ), + ) + + expect(create).toHaveBeenCalledWith({ 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", () => { + using create = spyOn(Log, "create").mockReturnValue(logger) + const effect = EffectLog.make({ service: "effect-test-struct" }) + + effect.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(create).toHaveBeenCalledWith({ service: "effect-test-struct" }) + expect(warn).toHaveBeenCalledWith( + '{"hello":"world"}', + expect.objectContaining({ + fiber: 123n, + }), + ) +})