diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 23eb39b3f5f76..70b4cd86b2cdc 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -1,12 +1,16 @@ import * as NodePath from "@effect/platform-node/NodePath"; import { assert, describe, it } from "@effect/vitest"; import * as NodeOS from "node:os"; +import * as NodeUtil from "node:util"; + import { ExternalLauncherBrowserSpawnError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Redactable from "effect/Redactable"; import * as Path from "effect/Path"; import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/http/HttpClient"; @@ -147,6 +151,25 @@ const logInSpanThrough = ( return { requests, spans }; }); +/** Logs one warning through the server logger and returns what its console printed. */ +const printThroughConsole = (fields: object) => + Effect.gen(function* () { + const printed: Array = []; + const testConsole = { + ...globalThis.console, + log: (...args: ReadonlyArray) => { + printed.push(NodeUtil.format(...args)); + }, + } satisfies Console.Console; + yield* Effect.logWarning("pull request watch check failed", fields).pipe( + Effect.provide( + ServerLogger.layer.pipe(Layer.provide(layerConfig({})), Layer.provide(layerCollector([]))), + ), + Effect.provideService(Console.Console, testConsole), + ); + return printed.join("\n"); + }); + describe("ServerLoggerLive", () => { it.effect("exports log records to the configured logs endpoint", () => Effect.gen(function* () { @@ -241,6 +264,49 @@ describe("ServerLoggerLive", () => { }), ); + it.effect("prints a cause passed as a log field instead of collapsing it", () => + Effect.gen(function* () { + const output = yield* printThroughConsole({ + threadId: "thread-1", + cause: Cause.fail(new Error("gh: HTTP 502 from api.github.com")), + }); + + assert.include(output, "threadId: 'thread-1'"); + assert.include(output, "gh: HTTP 502 from api.github.com"); + assert.notInclude(output, "[Object]"); + }), + ); + + it.effect("leaves getters uncalled while printing a cause field", () => + Effect.gen(function* () { + const output = yield* printThroughConsole({ + cause: Cause.fail(new Error("upstream refused")), + get detail(): string { + throw new Error("getter must not run"); + }, + }); + + assert.include(output, "upstream refused"); + assert.notInclude(output, "getter must not run"); + }), + ); + + it.effect("leaves a message with its own redaction hook to that hook", () => + Effect.gen(function* () { + const output = yield* printThroughConsole({ + token: "secret-token-value", + cause: Cause.fail(new Error("upstream refused")), + [Redactable.symbolRedactable](this: { readonly cause: unknown }) { + return { token: "", cause: Cause.isCause(this.cause) ? "cause" : "missing" }; + }, + }); + + assert.include(output, "token: ''"); + assert.include(output, "cause: 'cause'"); + assert.notInclude(output, "secret-token-value"); + }), + ); + describe("startup pairing URL", () => { const pairingToken = "PAIRTOKEN7Q4K9"; const pairingUrl = `http://localhost:3773/pair#token=${pairingToken}`; diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 2b61d843f2542..af7ea3805f208 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,14 +1,53 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; +import * as Redactable from "effect/Redactable"; import * as References from "effect/References"; import * as OtlpExporter from "effect/observability/OtlpExporter"; import * as OtlpLogger from "effect/observability/OtlpLogger"; import * as ServerConfig from "./config.ts"; +/** + * `Logger.consolePretty` prints message objects with `util.inspect`, which + * collapses a `Cause` passed as a field (`{ cause }`) to + * `{ failures: [ [Object] ] }`. Render those fields with `Cause.pretty`, the + * same text a `Cause` passed as its own log argument already gets. + */ +const consolePretty = (): Logger.Logger => { + const pretty = Logger.consolePretty(); + return Logger.make((options) => + pretty.log({ + ...options, + message: Array.isArray(options.message) + ? options.message.map(prettyCauseFields) + : prettyCauseFields(options.message), + }), + ); +}; + +function prettyCauseFields(value: unknown): unknown { + if (typeof value !== "object" || value === null) return value; + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return value; + // A message with its own redaction hook already decides how it prints. + if (Redactable.isRedactable(value)) return value; + // Copy descriptors rather than entries so getters are never invoked and + // symbol keys survive the copy. + const descriptors = Object.getOwnPropertyDescriptors(value); + let changed = false; + for (const descriptor of Object.values(descriptors)) { + if ("value" in descriptor && Cause.isCause(descriptor.value)) { + descriptor.value = Cause.pretty(descriptor.value); + changed = true; + } + } + return changed ? Object.create(prototype, descriptors) : value; +} + export const layer = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const hostName = yield* HostProcess.Hostname; @@ -34,8 +73,8 @@ export const layer = Effect.gen(function* () { // superset: keeping both would export every in-span message twice. const layerLogger = Logger.layer( otlpLogger === undefined - ? [Logger.consolePretty(), Logger.tracerLogger] - : [Logger.consolePretty(), otlpLogger], + ? [consolePretty(), Logger.tracerLogger] + : [consolePretty(), otlpLogger], { mergeWithExisting: false }, ).pipe( Layer.provide(OtlpExporter.layerFlusher),