Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton
e13d44684d 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.
2026-03-12 17:00:20 -04:00
Kit Langton
9bbc874927 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.
2026-03-12 16:43:11 -04:00
2 changed files with 155 additions and 0 deletions

View File

@@ -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<string, unknown>) {
const log = Log.create(tags)
return Logger.make<unknown, void>((options) => {
const annotations = options.fiber.getRef(CurrentLogAnnotations as never) as Readonly<Record<string, unknown>>
const spans = options.fiber.getRef(CurrentLogSpans as never) as ReadonlyArray<readonly [string, number]>
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<string, unknown>, options?: { mergeWithExisting?: boolean }) {
return Logger.layer([make(tags)], options)
}

View File

@@ -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,
}),
)
})