Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/server/src/serverLogger.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string> = [];
const testConsole = {
...globalThis.console,
log: (...args: ReadonlyArray<unknown>) => {
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* () {
Expand Down Expand Up @@ -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: "<redacted>", cause: Cause.isCause(this.cause) ? "cause" : "missing" };
},
});

assert.include(output, "token: '<redacted>'");
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}`;
Expand Down
43 changes: 41 additions & 2 deletions apps/server/src/serverLogger.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, void> => {
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;
Expand All @@ -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),
Expand Down
Loading