From cf548ac6f3dcf3d3adc9d4796c3b81eee5648f46 Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 8 Oct 2026 17:49:33 +0200 Subject: [PATCH 1/3] feat(plugin-logger): rework the sentry, evlog and winston transports Derive a shared `message`, `error` and `context` on every log payload, so each transport reads an entry the same way instead of guessing, and rebuild the transports on top of them. - SentryTransport keeps the message and context next to an exception, can record breadcrumbs and send Sentry Logs, and flushes the client on close. The `@sentry/node` peer dependency is dropped. - EvlogTransport writes structured events through evlog's pipeline and maps all six levels. A new `@wolfstar/plugin-logger/evlog/plugin` entry runs `initLogger`, wraps the drain in evlog's pipeline on request, and emits one wide event per interaction, enrichable with `useInteractionLogger()`. The Stars module gains an `evlog` option. - WinstonTransport serialises errors, flags `fatal` entries and no longer hangs when closed twice. - Add tests for the consola, evlog and winston adapters. --- .changeset/rework-logger-transports.md | 5 + packages/plugin-logger/README.md | 153 +++++++++- packages/plugin-logger/package.json | 12 +- .../plugin-logger/src/evlog-interactions.ts | 173 ++++++++++++ packages/plugin-logger/src/evlog-plugin.ts | 160 +++++++++++ packages/plugin-logger/src/evlog.ts | 89 ++++-- packages/plugin-logger/src/index.ts | 1 + packages/plugin-logger/src/lib/Logger.ts | 5 +- packages/plugin-logger/src/lib/payload.ts | 75 +++++ .../src/lib/transports/SentryTransport.ts | 179 ++++++++++-- packages/plugin-logger/src/lib/types.ts | 19 ++ packages/plugin-logger/src/module.ts | 66 ++++- packages/plugin-logger/src/winston.ts | 26 +- .../tests/ConsolaTransport.test.ts | 46 +++ .../tests/EvlogTransport.test.ts | 102 +++++++ .../tests/SentryTransport.test.ts | 167 ++++++++++- .../tests/WinstonTransport.test.ts | 88 ++++++ .../tests/evlog-interactions.test.ts | 264 ++++++++++++++++++ .../plugin-logger/tests/evlog-plugin.test.ts | 172 ++++++++++++ packages/plugin-logger/tests/module.test.ts | 63 +++++ packages/plugin-logger/tests/payload.test.ts | 93 ++++++ packages/plugin-logger/tsdown.config.ts | 2 + pnpm-lock.yaml | 211 -------------- 23 files changed, 1873 insertions(+), 298 deletions(-) create mode 100644 .changeset/rework-logger-transports.md create mode 100644 packages/plugin-logger/src/evlog-interactions.ts create mode 100644 packages/plugin-logger/src/evlog-plugin.ts create mode 100644 packages/plugin-logger/src/lib/payload.ts create mode 100644 packages/plugin-logger/tests/ConsolaTransport.test.ts create mode 100644 packages/plugin-logger/tests/EvlogTransport.test.ts create mode 100644 packages/plugin-logger/tests/WinstonTransport.test.ts create mode 100644 packages/plugin-logger/tests/evlog-interactions.test.ts create mode 100644 packages/plugin-logger/tests/evlog-plugin.test.ts create mode 100644 packages/plugin-logger/tests/payload.test.ts diff --git a/.changeset/rework-logger-transports.md b/.changeset/rework-logger-transports.md new file mode 100644 index 00000000..65db60b0 --- /dev/null +++ b/.changeset/rework-logger-transports.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-logger": minor +--- + +feat(plugin-logger)!: derive a shared `message`, `error` and `context` on every payload, and rework the Sentry, evlog and winston transports on top of them. `LogPayload` gains the three fields (see `createLogPayload`, for custom transports and tests). `SentryTransport` keeps the message and context next to an exception, can record breadcrumbs (`breadcrumbLevel`) and send Sentry Logs (`logLevel`), and flushes the client on close; the `@sentry/node` peer dependency is dropped. `EvlogTransport` now writes structured events through evlog's pipeline instead of flattened strings, maps all six levels, and flushes the `drain` on close; the `evlog` peer is raised to `^2.30.0`. `WinstonTransport` serialises errors, flags `fatal` entries, and no longer hangs when closed twice. The Stars module gains an `evlog` option that runs `initLogger` and adds the transport: `evlog: true`, or the options written inline (`env`, `sampling`, `redact`, `pipeline`, ...) with `drain` pointing to a file default-exporting `defineEvlogDrain(...)` (new `@wolfstar/plugin-logger/evlog/plugin` entry, whose default export is the plugin factory taking the drain directly outside Stars). The evlog plugin also emits one evlog wide event per interaction (command, component, modal, optionally autocomplete) with its outcome, error, duration and who/where, controlled by the `interactions` option, and `useInteractionLogger()` adds fields to it from the code of a command. diff --git a/packages/plugin-logger/README.md b/packages/plugin-logger/README.md index 43d43b31..3d65f967 100644 --- a/packages/plugin-logger/README.md +++ b/packages/plugin-logger/README.md @@ -35,9 +35,11 @@ Every logging backend is an **optional** peer dependency — install only the on pnpm add consola # for @wolfstar/plugin-logger/consola pnpm add evlog # for @wolfstar/plugin-logger/evlog pnpm add winston # for @wolfstar/plugin-logger/winston -pnpm add @sentry/node ``` +`SentryTransport` takes the Sentry client you already use (`@sentry/node`, `@sentry/bun`, ...) through +its constructor, so it needs no extra dependency. + ## Usage ### Stars module @@ -55,6 +57,98 @@ export default defineConfig({ The options are written into the built entry, so they must be JSON-serialisable (`level`, ...). Transports are objects: set them through `ClientOptions.logger.transports`. +#### evlog through the module + +The `evlog` option runs evlog's `initLogger` for you and adds an `EvlogTransport` to the logger. The +options are written inline: + +```ts +// stars.config.ts +export default defineConfig({ + modules: [ + [ + "@wolfstar/plugin-logger/module", + { + level: 20, + evlog: { + env: { service: "bot" }, + sampling: { rates: { debug: 10 } }, + redact: true, + pipeline: { batch: { size: 25 } }, + drain: "./src/evlog-drain.ts", + }, + }, + ], + ], +}); +``` + +```ts +// src/evlog-drain.ts +import { createAxiomDrain } from "evlog/axiom"; +import { defineEvlogDrain } from "@wolfstar/plugin-logger/evlog/plugin"; + +export default defineEvlogDrain(createAxiomDrain()); +``` + +The options besides `drain` go to evlog's `initLogger` (`env`, `pretty`, `silent`, `minLevel`, +`sampling`, `redact`, ...), plus `tag` and `level` for the transport. They must be JSON, since they +are written into the built entry. The drain is a function, so it comes from a file of yours, which +Stars bundles with the bot and calls with those options. `evlog: true`, or options without `drain`, +need no file. + +`pipeline` wraps the drain in evlog's drain pipeline (batching, retry, bounded buffer): `true` for its +defaults, or its options. The drain then receives events by batch, as the drain adapters do. A drain +that already has a `flush` is used as it is. Either way, closing the logger flushes it. + +`drain` is a path (relative to the project root when it starts with `.`), a `file:` URL, a package +specifier, or `{ from, export }` for a named export. evlog prints to the console itself, so no +`ConsoleTransport` is added next to it; use `silent: true` when the drain should be the only output. + +##### Wide events per interaction + +The evlog plugin also follows the client's interaction lifecycle and drains **one wide event per +interaction** (evlog's [custom framework](https://www.evlog.dev/raw/extend/custom-framework.md) +model): created when a command, component or modal starts, filled with its outcome, and emitted when +it finishes. The event carries `method` (`COMMAND`, `AUTOCOMPLETE`, `COMPONENT` or `MODAL`), `path` +(the command or handler name), `requestId` (the interaction id), `outcome`, the `error` when it +failed, `durationMs`, and `guildId`, `channelId`, `userId` and `locale`. It goes through the same +drain, sampling, redaction and plugins as every other evlog event. + +```ts +evlog: { + interactions: { + autocomplete: true; + } +} // commands and handlers are on by default +evlog: { + interactions: false; +} // only the container.logger entries +``` + +Autocomplete is off by default, since Discord sends a request for every keystroke. + +From the code of a command, autocomplete or handler, `useInteractionLogger()` returns the interaction's +evlog request logger, to add fields to its wide event. It throws outside of an interaction, like +evlog's own `useLogger()`: + +```ts +import { useInteractionLogger } from "@wolfstar/plugin-logger/evlog/plugin"; + +useInteractionLogger().set({ cart: { items: 3 } }); +``` + +Without Stars, give the drain to the plugin directly, evlog first: + +```ts +import evlogPlugin from "@wolfstar/plugin-logger/evlog/plugin"; + +plugins: [ + evlogPlugin({ env: { service: "bot" }, pipeline: true, drain: createAxiomDrain() }), + loggerPlugin(), +]; +``` + Never combine the module (or the `@wolfstar/plugin-logger/plugin` factory below) with `import "@wolfstar/plugin-logger/register"`: both paths install the same hooks, so combining them installs them twice. @@ -97,6 +191,20 @@ interface Transport { } ``` +A `LogPayload` carries the raw `values` the caller passed, plus three fields derived from them once, +so every transport reads an entry the same way instead of guessing: + +| Field | Content | +| --------- | ---------------------------------------------------------------------------------------- | +| `message` | strings and other non-object values joined by spaces (the error's message as a fallback) | +| `error` | the first `Error` among the values | +| `context` | the plain objects among the values, shallow-merged | + +```ts +logger.error("Failed to charge", { orderId: 7 }, error); +// message: "Failed to charge", context: { orderId: 7 }, error +``` + `level` is optional and filters **on top of** the logger's own level, which is how a Sentry sink can take only errors while the console keeps everything: @@ -125,17 +233,33 @@ caught and reported to `console.error`. | Transport | Entrypoint | Peer dependency | | ------------------ | ---------- | --------------- | | `ConsoleTransport` | `.` | none | -| `SentryTransport` | `.` | `@sentry/node` | +| `SentryTransport` | `.` | none | `SentryTransport` lives in the core entrypoint but takes its Sentry client through the constructor, -so the package carries no runtime dependency on `@sentry/node`. The module namespace works directly: +so the package carries no runtime dependency on a Sentry SDK. The module namespace works directly, +and what Sentry receives depends on the entry's level: + +| Option | Sends | Default | +| ----------------- | ------------------------------------------------- | ---------------- | +| `level` | an issue, with the message and context in `extra` | `LogLevel.Error` | +| `breadcrumbLevel` | a breadcrumb, for entries below `level` | off | +| `logLevel` | a Sentry Log (needs `enableLogs: true`) | off | ```ts import * as Sentry from "@sentry/node"; -new SentryTransport({ client: Sentry, level: LogLevel.Warn }); +new SentryTransport({ + client: Sentry, + level: LogLevel.Error, // issues + breadcrumbLevel: LogLevel.Info, // context attached to the next issue + logLevel: LogLevel.Info, // structured logs, searchable in Sentry +}); ``` +Breadcrumbs are off by default because Sentry's default console integration already records them for +`console.*`: turning them on next to a `ConsoleTransport` would duplicate them. Closing the logger +flushes the client (`flushTimeout`, 2s by default), so the last `fatal` before an exit is delivered. + ### Backend adapters Each adapter wraps a third-party logger as a transport, and lives behind its own subpath so the @@ -149,12 +273,24 @@ new ConsolaTransport({ instance: consola }); ``` ```ts -import { log } from "evlog"; +import { initLogger, log } from "evlog"; +import { createAxiomDrain } from "evlog/axiom"; +import { createDrainPipeline } from "evlog/pipeline"; import { EvlogTransport } from "@wolfstar/plugin-logger/evlog"; -new EvlogTransport({ instance: log, tag: "bot" }); +// evlog owns the pipeline: drains, enrichers, sampling and redaction are configured here. +const drain = createDrainPipeline()(createAxiomDrain()); +initLogger({ env: { service: "bot" }, drain }); + +new EvlogTransport({ instance: log, drain, tag: "bot" }); ``` +`EvlogTransport` hands each entry to evlog as a structured event (`message`, the context fields and +the `error` with its `cause` chain), which is the form that flows through evlog's drains. It does +not format or deliver anything itself, so everything evlog supports applies to the bot's logs. Pass +the `drain` pipeline so closing the logger flushes it. evlog also ships a Sentry drain, which makes +`SentryTransport` redundant when evlog is already your sink. + ```ts import { createLogger, transports } from "winston"; import { WinstonTransport } from "@wolfstar/plugin-logger/winston"; @@ -164,8 +300,9 @@ new WinstonTransport({ }); ``` -Note that `evlog` only has four levels and `winston`'s default `npm` levels have no `fatal`, so -`trace` collapses into `debug` and `fatal` into `error` on those backends. +`winston`'s default `npm` levels have no `fatal`, so a `fatal` entry is written as `error` with a +`fatal: true` field. Create the winston logger with `level: "silly"`: its own level (`info` by +default) filters on top of the plugin's, and would silently drop the lower ones. ## Migration diff --git a/packages/plugin-logger/package.json b/packages/plugin-logger/package.json index ac839492..e1bee083 100644 --- a/packages/plugin-logger/package.json +++ b/packages/plugin-logger/package.json @@ -66,6 +66,12 @@ "default": "./dist/esm/evlog.js" } }, + "./evlog/plugin": { + "import": { + "types": "./dist/esm/evlog-plugin.d.ts", + "default": "./dist/esm/evlog-plugin.js" + } + }, "./winston": { "import": { "types": "./dist/esm/winston.d.ts", @@ -91,17 +97,13 @@ "winston": "^3.19.0" }, "peerDependencies": { - "@sentry/node": "^8.0.0 || ^9.0.0 || ^10.0.0", "@wolfstar/http-framework": "^3.4.0 || ^5.0.0 || ^6.0.0", "@wolfstar/kit": "^0.1.0", "consola": "^3.0.0", - "evlog": "^2.0.0", + "evlog": "^2.30.0", "winston": "^3.0.0" }, "peerDependenciesMeta": { - "@sentry/node": { - "optional": true - }, "@wolfstar/kit": { "optional": true }, diff --git a/packages/plugin-logger/src/evlog-interactions.ts b/packages/plugin-logger/src/evlog-interactions.ts new file mode 100644 index 00000000..f197fb2c --- /dev/null +++ b/packages/plugin-logger/src/evlog-interactions.ts @@ -0,0 +1,173 @@ +import { Events, type Client } from "@wolfstar/http-framework"; +import { createRequestLogger } from "evlog"; +import { createLoggerStorage } from "evlog/toolkit/storage"; + +/** + * Which interactions are logged as wide events. + */ +export interface EvlogInteractionsOptions { + /** + * Slash, user and message commands. + * + * @default true + */ + commands?: boolean; + + /** + * Autocomplete requests. Off by default: Discord sends one for every keystroke. + * + * @default false + */ + autocomplete?: boolean; + + /** + * Message components (buttons, select menus) and modals. + * + * @default true + */ + handlers?: boolean; +} + +/** + * The slice of an interaction the wide event describes. Structural, as the three kinds of + * interaction share it. + */ +interface InteractionLike { + id: string; + type: number; + guild_id?: string; + channel_id?: string; + channel?: { id: string }; + locale?: string; + member?: { user?: { id: string } }; + user?: { id: string }; +} + +interface EventContext { + command?: { name: string }; + handler?: { name: string }; + interaction: InteractionLike; +} + +type Listener = (...args: any[]) => void; + +const { storage, useLogger } = createLoggerStorage( + "an interaction. Make sure the evlog plugin is registered before the logger plugin.", + "@wolfstar/plugin-logger:interaction", +); + +/** + * The evlog request logger of the interaction being handled, to enrich its wide event from the code + * of a command, an autocomplete or a handler. Throws outside of one, like evlog's `useLogger`. + * + * @example + * ```ts + * import { useInteractionLogger } from '@wolfstar/plugin-logger/evlog/plugin'; + * + * useInteractionLogger().set({ cart: { items: 3 } }); + * ``` + */ +export const useInteractionLogger = useLogger; + +type RequestLogger = ReturnType>>; + +/** + * Discord's `InteractionType` values telling the kinds apart. + */ +const AUTOCOMPLETE = 4; +const MODAL_SUBMIT = 5; + +/** + * Turns the client's interaction lifecycle into evlog wide events: one request logger per command, + * autocomplete or handler run, created when it starts, filled with its outcome and emitted when it + * finishes, so the drain, enrichers, sampling and plugins of evlog see a single event per + * interaction instead of a trail of log lines. + * + * The framework's events do not wrap the run of the command, but they are emitted synchronously from + * the flow that goes on to run it: entering the logger into `AsyncLocalStorage` from the `*Run` + * listener makes it reachable from the command's code, see {@link useInteractionLogger}. It is emitted + * by the `*Finish` event. + * + * @param client The client whose events are listened to. + * @param options Which interactions are logged. + */ +export function attachInteractionLogging( + client: Client, + options: Required, +): void { + const on = client.on.bind(client) as (event: string, listener: Listener) => unknown; + const loggers = new WeakMap(); + + const groups = [ + [ + options.commands, + Events.CommandRun, + Events.CommandSuccess, + Events.CommandError, + Events.CommandFinish, + ], + [ + options.autocomplete, + Events.AutocompleteRun, + Events.AutocompleteSuccess, + Events.AutocompleteError, + Events.AutocompleteFinish, + ], + [ + options.handlers, + Events.InteractionHandlerRun, + Events.InteractionHandlerSuccess, + Events.InteractionHandlerError, + Events.InteractionHandlerFinish, + ], + ] as const; + + for (const [enabled, run, success, error, finish] of groups) { + if (!enabled) continue; + + on(run, (context: EventContext) => { + const logger = begin(context); + loggers.set(context, logger); + // Not `run()`: nothing here wraps the command, the flow that emitted this event runs it next. + storage.enterWith(logger); + }); + on(success, (context: EventContext) => { + loggers.get(context)?.set({ outcome: "success" }); + }); + on(error, (reason: unknown, context: EventContext) => { + const logger = loggers.get(context); + logger?.error(reason instanceof Error ? reason : String(reason)); + logger?.set({ outcome: "error" }); + }); + on(finish, (context: EventContext) => { + const logger = loggers.get(context); + loggers.delete(context); + // What `storage.run` ending would do: past the finish, the rest of the flow has no logger. + storage.enterWith(undefined as never); + // `emit` reports a failing drain on its own; nothing here may take an interaction down. + void Promise.resolve(logger?.emit()).catch(() => undefined); + }); + } +} + +function begin({ command, handler, interaction }: EventContext): RequestLogger { + const logger = createRequestLogger({ + method: methodOf(interaction, command), + path: (command ?? handler)?.name, + requestId: interaction.id, + }); + + logger.set({ + guildId: interaction.guild_id, + channelId: interaction.channel_id ?? interaction.channel?.id, + userId: (interaction.member?.user ?? interaction.user)?.id, + locale: interaction.locale, + }); + + return logger; +} + +function methodOf(interaction: InteractionLike, command: EventContext["command"]): string { + if (!command) return interaction.type === MODAL_SUBMIT ? "MODAL" : "COMPONENT"; + return interaction.type === AUTOCOMPLETE ? "AUTOCOMPLETE" : "COMMAND"; +} diff --git a/packages/plugin-logger/src/evlog-plugin.ts b/packages/plugin-logger/src/evlog-plugin.ts new file mode 100644 index 00000000..089c9ed9 --- /dev/null +++ b/packages/plugin-logger/src/evlog-plugin.ts @@ -0,0 +1,160 @@ +import { definePlugin, type LogLevel } from "@wolfstar/http-framework"; +import { initLogger, log, type DrainContext, type LoggerConfig } from "evlog"; +import { createDrainPipeline, type DrainPipelineOptions } from "evlog/pipeline"; +import { EvlogTransport } from "./evlog.js"; +import { + attachInteractionLogging, + useInteractionLogger, + type EvlogInteractionsOptions, +} from "./evlog-interactions.js"; + +/** + * A drain for evlog: a function receiving each event, or a pipeline (what `createDrainPipeline()` + * returns), which also has a `flush` called when the logger closes. + */ +export type EvlogDrain = NonNullable & { flush?(): Promise }; + +/** + * A drain receiving events by batch, which is what a drain adapter (`createAxiomDrain()`, ...) is + * and what {@link EvlogBaseConfig.pipeline `pipeline`} feeds. + */ +export type EvlogBatchDrain = (batch: DrainContext[]) => void | Promise; + +export interface EvlogBaseConfig extends Omit { + /** + * The tag every entry is written under. + * + * @default 'http-framework' + */ + tag?: string; + + /** + * The lowest level the {@link EvlogTransport} accepts. + * + * @default undefined // the logger's level applies + */ + level?: LogLevel; + + /** + * Logs every interaction as one evlog wide event (command, component, modal; autocomplete on + * request), carrying the outcome, the error if any, the duration and who/where it came from. + * `true` takes the defaults, `false` turns it off. + * + * @default true + */ + interactions?: boolean | EvlogInteractionsOptions; +} + +/** + * The configuration of {@link evlogPlugin}: evlog's `initLogger` options (`env`, `pretty`, `silent`, + * `minLevel`, `sampling`, `redact`, `plugins`, ...), plus the transport's `tag` and `level`. + * + * With `pipeline` set, the `drain` is wrapped in evlog's drain pipeline (batching, retry, bounded + * buffer) and receives events by batch, like the drain adapters do. Without it, the drain is given + * to `initLogger` as it is. + */ +export type EvlogConfig = + | (EvlogBaseConfig & { drain?: EvlogDrain; pipeline?: false }) + | (EvlogBaseConfig & { + drain: EvlogBatchDrain; + pipeline: true | DrainPipelineOptions; + }); + +/** + * The evlog plugin factory: runs evlog's `initLogger` and adds an {@link EvlogTransport} to the + * logger. The Stars module registers it with the inline `evlog` options, or `evlog: true`. + * + * It must run before the logger plugin, which builds the logger from the transports it finds; the + * Stars module registers them in that order. + * + * The transport is added to the ones in `ClientOptions.logger.transports`, nothing else is + * replaced: evlog prints to the console on its own (see `silent`), so no console transport is + * added when there is none. + */ +const evlogPlugin = definePlugin((config: EvlogConfig = {}) => { + const { tag, level, drain, pipeline, interactions = true, ...loggerConfig } = config; + + return { + name: "@wolfstar/plugin-logger:evlog", + enforce: "pre", + preGenericsInitialization(_client, clientOptions) { + const sink = createSink(drain, pipeline); + + initLogger({ ...loggerConfig, ...(sink && { drain: sink }) }); + + clientOptions.logger ??= {}; + clientOptions.logger.transports = [ + ...(clientOptions.logger.transports ?? []), + new EvlogTransport({ + instance: log, + tag, + level, + drain: sink?.flush ? { flush: () => sink.flush!() } : undefined, + }), + ]; + }, + postInitialization(client) { + if (!interactions) return; + + attachInteractionLogging(client, { + commands: true, + autocomplete: false, + handlers: true, + ...(interactions === true ? {} : interactions), + }); + }, + }; +}); + +export default evlogPlugin; + +export { useInteractionLogger, type EvlogInteractionsOptions }; + +/** + * The options of {@link evlogPlugin} that survive being written in `stars.config`: everything but the + * drain and evlog's own `plugins`. + */ +export type EvlogInlineOptions = Omit & { + pipeline?: boolean | DrainPipelineOptions; +}; + +/** + * Gives the evlog plugin its drain, from a file of your own listed in the module's `evlog.drain` + * option. Stars hands the other `evlog` options (the inline ones from `stars.config`) to the factory + * this returns, so the file only holds what `stars.config` cannot: the drain, a function. + * + * Outside Stars there is no need for it: give the drain to {@link evlogPlugin} directly. + * + * @param drain The drain evlog delivers events to. A batch drain, like the adapters, when the inline + * options set `pipeline`. + * + * @example + * ```ts + * // src/evlog-drain.ts + * import { createAxiomDrain } from 'evlog/axiom'; + * import { defineEvlogDrain } from '@wolfstar/plugin-logger/evlog/plugin'; + * + * export default defineEvlogDrain(createAxiomDrain()); + * ``` + */ +export function defineEvlogDrain(drain: EvlogDrain | EvlogBatchDrain) { + return definePlugin((options: EvlogInlineOptions = {}) => + evlogPlugin({ ...options, drain } as EvlogConfig), + ); +} + +function createSink( + drain: EvlogDrain | EvlogBatchDrain | undefined, + pipeline: boolean | DrainPipelineOptions | undefined, +): EvlogDrain | undefined { + if (!drain) return undefined; + + // A drain with a `flush` is already a pipeline: wrapping it again would batch twice. + if (pipeline && !("flush" in drain)) { + return createDrainPipeline(pipeline === true ? undefined : pipeline)( + drain as EvlogBatchDrain, + ); + } + + return drain as EvlogDrain; +} diff --git a/packages/plugin-logger/src/evlog.ts b/packages/plugin-logger/src/evlog.ts index ce929dc4..d664e10d 100644 --- a/packages/plugin-logger/src/evlog.ts +++ b/packages/plugin-logger/src/evlog.ts @@ -3,34 +3,46 @@ import type { Log } from "evlog"; import type { LogPayload, Transport } from "./lib/types.js"; /** - * The evlog method each {@link LogLevel} maps to. evlog exposes four levels, so `trace` collapses - * into `debug` and `fatal` into `error`. + * The evlog method each {@link LogLevel} maps to. evlog's simple API has the same six levels, so + * nothing collapses. */ const methods = new Map([ - [LogLevel.Trace, "debug"], + [LogLevel.Trace, "trace"], [LogLevel.Debug, "debug"], [LogLevel.Info, "info"], [LogLevel.Warn, "warn"], [LogLevel.Error, "error"], - [LogLevel.Fatal, "error"], + [LogLevel.Fatal, "fatal"], ]); /** - * A {@link Transport} writing entries through an evlog logger, for structured "wide event" logging - * and the drain adapters evlog ships (OTLP, Axiom, Datadog, ClickHouse, and alike). + * A {@link Transport} handing entries over to evlog as structured events. * - * evlog groups entries by a tag rather than by a logger name, so every entry written through this - * transport carries {@link EvlogTransportOptions.tag}. + * This transport does not format or deliver anything itself: evlog owns the whole pipeline, and the + * entry goes through it like any other event. Configure `initLogger` once at startup and everything + * evlog offers applies to the bot's logs: drains (Axiom, OTLP, Datadog, Sentry, ClickHouse, Loki, + * file system, and alike), enrichers, head and tail sampling, redaction. evlog's Sentry drain also + * makes {@link SentryTransport} redundant when evlog is already the sink. * - * The evlog instance is injected so this package never imports evlog at runtime; it is an optional + * Entries are written in evlog's object form, because only structured events flow through the drain + * pipeline. The message, the plain objects (spread as fields) and the `Error` (serialised with its + * `cause` chain) the caller passed each land in their own field instead of a flattened string. + * + * The evlog `log` is injected so this package never imports evlog at runtime; it is an optional * peer dependency, and only consumers importing `@wolfstar/plugin-logger/evlog` need it installed. * * @example * ```ts - * import { log } from 'evlog'; + * import { initLogger, log } from 'evlog'; + * import { createAxiomDrain } from 'evlog/axiom'; + * import { createDrainPipeline } from 'evlog/pipeline'; * import { EvlogTransport } from '@wolfstar/plugin-logger/evlog'; * - * const transport = new EvlogTransport({ instance: log, tag: 'bot' }); + * const drain = createDrainPipeline()(createAxiomDrain()); + * initLogger({ env: { service: 'bot' }, drain }); + * + * // `drain` is flushed when the logger closes, so buffered events survive a shutdown. + * const transport = new EvlogTransport({ instance: log, drain }); * ``` */ export class EvlogTransport implements Transport { @@ -46,6 +58,11 @@ export class EvlogTransport implements Transport { */ private readonly tag: string; + /** + * The drain pipeline flushed on {@link EvlogTransport.close}. + */ + private readonly drain: EvlogFlushable | undefined; + /** * @param options The transport options. */ @@ -53,33 +70,28 @@ export class EvlogTransport implements Transport { this.instance = options.instance; this.level = options.level; this.tag = options.tag ?? "http-framework"; + this.drain = options.drain; } public log(payload: LogPayload): void { const method = methods.get(payload.level); if (!method) return; - const error = payload.values.find((value): value is Error => value instanceof Error); - - // evlog's `error` overload takes an `Error` directly and derives a structured error from it, - // which is strictly better than flattening the stack into a message string. - if (error && method === "error") { - this.instance.error(error); - return; - } - - this.instance[method](this.tag, payload.values.map(stringify).join(" ")); + // The reserved fields come last so a context key can never overwrite them. + this.instance[method]({ + ...payload.context, + tag: this.tag, + ...(payload.message && { message: payload.message }), + ...(payload.error && { error: payload.error }), + }); } -} -function stringify(value: unknown): string { - if (typeof value === "string") return value; - if (value instanceof Error) return value.stack ?? value.message; - - try { - return JSON.stringify(value) ?? String(value); - } catch { - return String(value); + /** + * Flushes the drain pipeline, if one was given. evlog batches events, so without this the last + * ones are lost when the process exits. + */ + public async close(): Promise { + await this.drain?.flush(); } } @@ -102,9 +114,24 @@ export interface EvlogTransportOptions { * @default 'http-framework' */ tag?: string; + + /** + * The drain pipeline given to `initLogger` (what `createDrainPipeline()(adapter)` returns), so it + * is flushed when the logger closes. + * + * @default undefined // nothing to flush + */ + drain?: EvlogFlushable; +} + +/** + * The part of evlog's `PipelineDrain` {@link EvlogTransport} relies on. + */ +export interface EvlogFlushable { + flush(): Promise; } /** * The evlog methods {@link EvlogTransport} can write to. */ -export type EvlogMethod = "debug" | "error" | "info" | "warn"; +export type EvlogMethod = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; diff --git a/packages/plugin-logger/src/index.ts b/packages/plugin-logger/src/index.ts index 12f73253..dbf5aa26 100644 --- a/packages/plugin-logger/src/index.ts +++ b/packages/plugin-logger/src/index.ts @@ -2,6 +2,7 @@ import type { Transport } from "./lib/types.js"; export * from "./lib/transports/ConsoleTransport.js"; export * from "./lib/transports/SentryTransport.js"; +export * from "./lib/payload.js"; export * from "./lib/types.js"; export * from "./lib/Logger.js"; diff --git a/packages/plugin-logger/src/lib/Logger.ts b/packages/plugin-logger/src/lib/Logger.ts index f069353b..176a4277 100644 --- a/packages/plugin-logger/src/lib/Logger.ts +++ b/packages/plugin-logger/src/lib/Logger.ts @@ -1,6 +1,7 @@ import { LogLevel, type ClientLoggerOptions, type ILogger } from "@wolfstar/http-framework"; import { ConsoleTransport } from "./transports/ConsoleTransport.js"; -import type { LogPayload, Transport } from "./types.js"; +import { createLogPayload } from "./payload.js"; +import type { Transport } from "./types.js"; /** * The {@link ILogger} implementation this plugin installs as `container.logger`. @@ -63,7 +64,7 @@ export class Logger implements ILogger { public write(level: LogLevel, ...values: readonly unknown[]): void { if (!this.has(level)) return; - const payload: LogPayload = { level, values, timestamp: new Date() }; + const payload = createLogPayload(level, values); for (const transport of this.transports) { if (level < (transport.level ?? this.level)) continue; diff --git a/packages/plugin-logger/src/lib/payload.ts b/packages/plugin-logger/src/lib/payload.ts new file mode 100644 index 00000000..b29df120 --- /dev/null +++ b/packages/plugin-logger/src/lib/payload.ts @@ -0,0 +1,75 @@ +import type { LogLevel } from "@wolfstar/http-framework"; +import type { LogPayload } from "./types.js"; + +interface Derived { + message: string; + error: Error | undefined; + context: Record | undefined; +} + +/** + * Builds the {@link LogPayload} handed to every transport. + * + * `message`, `error` and `context` are derived from `values` the first time a transport reads them + * and then memoised, so a logger that only has a {@link ConsoleTransport} never pays for them. + * + * @param level The level the entry was written at. + * @param values The values passed to the logger method. + * @param timestamp The moment the entry was created. + */ +export function createLogPayload( + level: LogLevel, + values: readonly unknown[], + timestamp = new Date(), +): LogPayload { + let derived: Derived | undefined; + const derive = () => (derived ??= deriveFields(values)); + + return { + level, + values, + timestamp, + get message() { + return derive().message; + }, + get error() { + return derive().error; + }, + get context() { + return derive().context; + }, + }; +} + +function deriveFields(values: readonly unknown[]): Derived { + let error: Error | undefined; + let context: Record | undefined; + const parts: string[] = []; + + for (const value of values) { + if (value instanceof Error) { + error ??= value; + } else if (isPlainObject(value)) { + context = { ...context, ...value }; + } else { + parts.push(typeof value === "string" ? value : inspect(value)); + } + } + + return { message: parts.join(" ") || error?.message || "", error, context }; +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function inspect(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} diff --git a/packages/plugin-logger/src/lib/transports/SentryTransport.ts b/packages/plugin-logger/src/lib/transports/SentryTransport.ts index 5bfbd47c..a2d3cf0f 100644 --- a/packages/plugin-logger/src/lib/transports/SentryTransport.ts +++ b/packages/plugin-logger/src/lib/transports/SentryTransport.ts @@ -14,22 +14,47 @@ const severities = new Map([ ]); /** - * A {@link Transport} forwarding entries to Sentry, defaulting to `error` and above so an - * application's issue stream is not flooded with lifecycle logs. + * The `Sentry.logger` method each {@link LogLevel} is sent with. + */ +const logMethods = new Map([ + [LogLevel.Trace, "trace"], + [LogLevel.Debug, "debug"], + [LogLevel.Info, "info"], + [LogLevel.Warn, "warn"], + [LogLevel.Error, "error"], + [LogLevel.Fatal, "fatal"], +]); + +/** + * A {@link Transport} forwarding entries to Sentry. What Sentry receives depends on the entry's + * level, from the cheapest signal to the most prominent: + * + * - from {@link SentryTransportOptions.logLevel `logLevel`}: a Sentry Log (needs `enableLogs`); + * - from {@link SentryTransportOptions.breadcrumbLevel `breadcrumbLevel`}, below `level`: a + * breadcrumb attached to the next issue; + * - from {@link SentryTransportOptions.level `level`} (default `error`): an issue, so an + * application's issue stream is not flooded with lifecycle logs. + * + * Logs and breadcrumbs are off unless asked for. Sentry's default console integration already + * records breadcrumbs for `console.*`, so enabling them next to a {@link ConsoleTransport} would + * duplicate them. * * The Sentry client is injected rather than imported, exactly like {@link WinstonTransport} takes a - * winston instance: this keeps `@wolfstar/plugin-logger` free of a runtime dependency on - * `@sentry/node`, so consumers that do not use Sentry never pay for it. + * winston instance: this keeps `@wolfstar/plugin-logger` free of a runtime dependency on any Sentry + * SDK, so it works with `@sentry/node`, `@sentry/bun` and the like alike. * * @example * ```ts * import * as Sentry from '@sentry/node'; * import { SentryTransport } from '@wolfstar/plugin-logger'; * - * const transport = new SentryTransport({ client: Sentry }); + * const transport = new SentryTransport({ client: Sentry, breadcrumbLevel: LogLevel.Info }); * ``` */ export class SentryTransport implements Transport { + /** + * The lowest level any of the enabled signals accepts. + */ public readonly level: LogLevel; /** @@ -37,62 +62,151 @@ export class SentryTransport implements Transport { */ private readonly client: SentryClientLike; + private readonly captureLevel: LogLevel; + private readonly breadcrumbLevel: LogLevel | undefined; + private readonly logLevel: LogLevel | undefined; + private readonly flushTimeout: number; + /** * @param options The transport options. */ public constructor(options: SentryTransportOptions) { this.client = options.client; - this.level = options.level ?? LogLevel.Error; + this.captureLevel = options.level ?? LogLevel.Error; + this.breadcrumbLevel = options.breadcrumbLevel; + this.logLevel = options.logLevel; + this.flushTimeout = options.flushTimeout ?? 2000; + + if (this.breadcrumbLevel !== undefined && !this.client.addBreadcrumb) { + throw new TypeError( + "SentryTransport: `breadcrumbLevel` needs a client with `addBreadcrumb`.", + ); + } + + if (this.logLevel !== undefined && !this.client.logger) { + throw new TypeError( + "SentryTransport: `logLevel` needs a client with `logger` (a Sentry SDK >= 9.41 initialised with `enableLogs`).", + ); + } + + this.level = Math.min( + this.captureLevel, + this.breadcrumbLevel ?? this.captureLevel, + this.logLevel ?? this.captureLevel, + ); } public log(payload: LogPayload): void { + if (this.logLevel !== undefined && payload.level >= this.logLevel) this.sendLog(payload); + + if (payload.level >= this.captureLevel) { + this.capture(payload); + } else if (this.breadcrumbLevel !== undefined && payload.level >= this.breadcrumbLevel) { + this.addBreadcrumb(payload); + } + } + + /** + * Flushes the Sentry client so the last entries — typically the `fatal` one preceding an exit — + * are delivered. The client itself stays open: it belongs to the application. + */ + public async close(): Promise { + await this.client.flush?.(this.flushTimeout); + } + + private capture(payload: LogPayload): void { + const { error, context } = payload; + const message = payload.message; const severity = severities.get(payload.level) ?? "error"; - const error = payload.values.find((value): value is Error => value instanceof Error); + + const extra: Record = {}; + if (context) extra.context = context; // Prefer `captureException`: it is the only path producing a usable stack trace in Sentry. if (error) { - this.client.captureException(error, { level: severity, extra: { values: payload.values } }); + // The error's own message is already the issue title; only a message the caller added is news. + if (message && message !== error.message) extra.message = message; + this.client.captureException(error, { level: severity, extra }); return; } - this.client.captureMessage(payload.values.map(stringify).join(" "), severity); + this.client.captureMessage(message || "(empty log entry)", { level: severity, extra }); } -} -function stringify(value: unknown): string { - return typeof value === "string" ? value : inspect(value); -} + private addBreadcrumb(payload: LogPayload): void { + this.client.addBreadcrumb?.({ + level: severities.get(payload.level) ?? "info", + message: payload.message, + category: "log", + data: payload.context, + timestamp: payload.timestamp.getTime() / 1000, + }); + } + + private sendLog(payload: LogPayload): void { + const method = logMethods.get(payload.level); + if (!method) return; -function inspect(value: unknown): string { - try { - return JSON.stringify(value) ?? String(value); - } catch { - return String(value); + const attributes: Record = { ...payload.context }; + if (payload.error) { + attributes["error.name"] = payload.error.name; + attributes["error.message"] = payload.error.message; + attributes["error.stack"] = payload.error.stack; + } + + this.client.logger?.[method](payload.message, attributes); } } export interface SentryTransportOptions { /** - * The Sentry client entries are reported to. The `@sentry/node` module namespace satisfies this + * The Sentry client entries are reported to. A Sentry SDK's module namespace satisfies this * shape, as does a manually built `Scope`. */ client: SentryClientLike; /** - * The lowest {@link LogLevel} forwarded to Sentry. + * The lowest {@link LogLevel} reported as an issue. * * @default LogLevel.Error */ level?: LogLevel; + + /** + * The lowest {@link LogLevel} recorded as a breadcrumb, for entries below {@link level}. Needs a + * client exposing `addBreadcrumb`. + * + * @default undefined // no breadcrumbs + */ + breadcrumbLevel?: LogLevel; + + /** + * The lowest {@link LogLevel} sent to Sentry Logs, regardless of {@link level}. Needs a client + * exposing `logger`, and Sentry initialised with `enableLogs: true`. + * + * @default undefined // no Sentry Logs + */ + logLevel?: LogLevel; + + /** + * How long, in milliseconds, {@link SentryTransport.close} waits for the client to flush. + * + * @default 2000 + */ + flushTimeout?: number; } /** * The subset of Sentry's API {@link SentryTransport} relies on. Declared structurally so no - * `@sentry/node` type import is needed. + * Sentry SDK type import is needed. Only the capture methods are required; the rest unlock the + * optional signals. */ export interface SentryClientLike { captureException(exception: unknown, hint?: SentryCaptureHint): string; - captureMessage(message: string, level?: SentrySeverity): string; + captureMessage(message: string, captureContext?: SentryCaptureHint | SentrySeverity): string; + addBreadcrumb?(breadcrumb: SentryBreadcrumb): void; + flush?(timeout?: number): PromiseLike; + logger?: SentryLoggerLike; } export interface SentryCaptureHint { @@ -100,7 +214,28 @@ export interface SentryCaptureHint { extra?: Record; } +export interface SentryBreadcrumb { + level?: SentrySeverity; + message?: string; + category?: string; + data?: Record; + /** + * In seconds, as Sentry expects. + */ + timestamp?: number; +} + /** * The severity levels Sentry accepts. */ export type SentrySeverity = "debug" | "error" | "fatal" | "info" | "log" | "warning"; + +/** + * The `Sentry.logger` methods {@link SentryTransport} writes to. + */ +export type SentryLogMethod = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; + +export type SentryLoggerLike = Record< + SentryLogMethod, + (message: string, attributes?: Record) => void +>; diff --git a/packages/plugin-logger/src/lib/types.ts b/packages/plugin-logger/src/lib/types.ts index 8d3857ec..cec10333 100644 --- a/packages/plugin-logger/src/lib/types.ts +++ b/packages/plugin-logger/src/lib/types.ts @@ -19,6 +19,25 @@ export interface LogPayload { * The moment the entry was created, captured before any transport runs. */ readonly timestamp: Date; + + /** + * The human-readable message: every value that is neither an `Error` nor a plain object, joined + * by spaces. Falls back to the error's own message when nothing else was passed. + * + * Derived from {@link LogPayload.values} on first read, so transports share one interpretation of + * what the caller meant instead of each guessing on their own. + */ + readonly message: string; + + /** + * The first `Error` among the values, if any. + */ + readonly error: Error | undefined; + + /** + * The plain objects among the values, shallow-merged in order (the last one wins), if any. + */ + readonly context: Record | undefined; } /** diff --git a/packages/plugin-logger/src/module.ts b/packages/plugin-logger/src/module.ts index 49f92af5..2bac0348 100644 --- a/packages/plugin-logger/src/module.ts +++ b/packages/plugin-logger/src/module.ts @@ -1,5 +1,29 @@ +import { resolve } from "node:path"; import { defineModule } from "@wolfstar/kit"; import type { ClientLoggerOptions } from "@wolfstar/http-framework"; +import type { EvlogInlineOptions } from "./evlog-plugin"; + +/** + * The options of the module: the logger's own, plus `evlog` to run evlog's `initLogger` and route + * the logs through it. + */ +export interface LoggerModuleOptions extends ClientLoggerOptions { + /** + * Initialises evlog and adds an `EvlogTransport` to the logger. Needs the optional `evlog` peer. + * + * `true` takes evlog's defaults. An object holds the options evlog is initialised with (`env`, + * `pretty`, `sampling`, `redact`, `pipeline`, ...) plus `tag` and `level` for the transport, and + * `drain`: a file default-exporting `defineEvlogDrain(...)`, since a drain is a function and + * cannot be written here. + */ + evlog?: boolean | (EvlogInlineOptions & { drain?: EvlogSource }); +} + +/** + * A path starting with `.` is resolved against the project root; an absolute path, a `file:` URL or + * a package specifier is used as it is. Use `{ from, export }` for a named export. + */ +export type EvlogSource = string | { from: string; export?: string }; /** * The Stars module: listing `@wolfstar/plugin-logger/module` in `modules` in `stars.config` registers the logger @@ -9,18 +33,56 @@ import type { ClientLoggerOptions } from "@wolfstar/http-framework"; * The options are written into the built entry, so they have to be JSON-serialisable (`level`, ...). Transports are * objects: set them through `ClientOptions.logger.transports`. * + * With `evlog`, the module also runs evlog's `initLogger`. The options are written inline; only the drain, a + * function, comes from a file of yours default-exporting `defineEvlogDrain(...)`, which Stars bundles with the bot and + * calls with those options. The evlog plugin is registered before the logger one, which builds the logger from the + * transports it finds. + * * @example * ```ts * // stars.config.ts * export default defineConfig({ modules: [['@wolfstar/plugin-logger/module', { level: 20 }]] }); * ``` + * + * @example + * ```ts + * // stars.config.ts + * export default defineConfig({ + * modules: [ + * [ + * '@wolfstar/plugin-logger/module', + * { evlog: { env: { service: 'bot' }, pipeline: true, drain: './src/evlog-drain.ts' } } + * ] + * ] + * }); + * ``` */ -export default defineModule({ +export default defineModule({ meta: { name: "@wolfstar/plugin-logger", compatibility: { framework: ">=6.1.0" }, }, - setup(options, ctx) { + setup({ evlog, ...options }, ctx) { + // Before the logger plugin, which builds the logger from the transports it finds. + if (evlog) { + const { drain, ...evlogOptions } = evlog === true ? ({} as { drain?: undefined }) : evlog; + + ctx.addPlugin( + drain + ? { ...resolveSource(drain, ctx.root), options: evlogOptions } + : { from: "@wolfstar/plugin-logger/evlog/plugin", options: evlogOptions }, + ); + } + ctx.addPlugin({ from: "@wolfstar/plugin-logger/plugin", options }); }, }); + +function resolveSource(source: EvlogSource, root: string): { from: string; export?: string } { + const { from, export: exportName } = typeof source === "string" ? { from: source } : source; + + return { + from: from.startsWith(".") ? resolve(root, from) : from, + ...(exportName && { export: exportName }), + }; +} diff --git a/packages/plugin-logger/src/winston.ts b/packages/plugin-logger/src/winston.ts index df532b2c..84f91874 100644 --- a/packages/plugin-logger/src/winston.ts +++ b/packages/plugin-logger/src/winston.ts @@ -41,6 +41,8 @@ export class WinstonTransport implements Transport { */ private readonly instance: WinstonLogger; + private closing: Promise | undefined; + /** * @param options The transport options. */ @@ -50,19 +52,31 @@ export class WinstonTransport implements Transport { } public log(payload: LogPayload): void { - const [message, ...rest] = payload.values; + const { error } = payload; + // winston has no `fatal` in its default levels, so the entry is flagged instead of silently + // becoming a plain `error`. The error is serialised by hand: JSON turns an `Error` into `{}`. this.instance.log({ + ...payload.context, level: levels.get(payload.level) ?? "info", - message: typeof message === "string" ? message : String(message), - ...(rest.length > 0 ? { values: rest } : {}), + message: payload.message, + ...(payload.level === LogLevel.Fatal && { fatal: true }), + ...(error && { error: { name: error.name, message: error.message, stack: error.stack } }), }); } - public async close(): Promise { - await new Promise((resolve) => { - this.instance.end(() => resolve()); + /** + * Ends the winston logger, which flushes it. The wait is shared by every call: a second one must + * not wait for a `finish` event that was already emitted, as it would never resolve (winston's + * streams do not expose `writableFinished` to tell). + */ + public close(): Promise { + this.closing ??= new Promise((resolve) => { + this.instance.once("finish", () => resolve()); + this.instance.end(); }); + + return this.closing; } } diff --git a/packages/plugin-logger/tests/ConsolaTransport.test.ts b/packages/plugin-logger/tests/ConsolaTransport.test.ts new file mode 100644 index 00000000..28fb6aa5 --- /dev/null +++ b/packages/plugin-logger/tests/ConsolaTransport.test.ts @@ -0,0 +1,46 @@ +import { LogLevel } from "@wolfstar/http-framework"; +import { createConsola } from "consola"; +import { describe, expect, test } from "vitest"; +import { ConsolaTransport } from "../src/consola"; +import { Logger } from "../src/lib/Logger"; + +function setup() { + const entries: { type: string; args: unknown[] }[] = []; + const instance = createConsola({ + level: 5, + reporters: [{ log: (entry) => void entries.push({ type: entry.type, args: entry.args }) }], + }); + const logger = new Logger({ + level: LogLevel.Trace, + transports: [new ConsolaTransport({ instance })], + }); + + return { entries, logger }; +} + +describe("ConsolaTransport", () => { + test.each([ + ["trace", "trace"], + ["debug", "debug"], + ["info", "info"], + ["warn", "warn"], + ["error", "error"], + ["fatal", "fatal"], + ] as const)("GIVEN logger.%s THEN consola receives a %s entry", (method, type) => { + const { entries, logger } = setup(); + + logger[method]("hello"); + + expect(entries).toHaveLength(1); + expect(entries[0].type).toBe(type); + }); + + test("GIVEN several values THEN consola receives them untouched", () => { + const { entries, logger } = setup(); + const error = new Error("boom"); + + logger.error("Failed", { id: 1 }, error); + + expect(entries[0].args).toEqual(["Failed", { id: 1 }, error]); + }); +}); diff --git a/packages/plugin-logger/tests/EvlogTransport.test.ts b/packages/plugin-logger/tests/EvlogTransport.test.ts new file mode 100644 index 00000000..4c43ce3d --- /dev/null +++ b/packages/plugin-logger/tests/EvlogTransport.test.ts @@ -0,0 +1,102 @@ +import { LogLevel } from "@wolfstar/http-framework"; +import { initLogger, log, type DrainContext } from "evlog"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { EvlogTransport } from "../src/evlog"; +import { Logger } from "../src/lib/Logger"; + +// The real evlog is used on purpose: what matters is the event the drain pipeline receives, not +// which `log` method was called. +let events: DrainContext["event"][]; + +beforeEach(() => { + events = []; + initLogger({ + env: { service: "bot" }, + silent: true, + pretty: false, + sampling: { rates: { trace: 100 } }, + drain: (ctx) => void events.push(ctx.event), + }); +}); + +function createLogger(options: Partial[0]> = {}) { + return new Logger({ + level: LogLevel.Trace, + transports: [new EvlogTransport({ instance: log, ...options })], + }); +} + +describe("EvlogTransport", () => { + test("GIVEN a message and a context THEN the drain receives one structured event", async () => { + createLogger().info("User joined", { guildId: "1", shard: 0 }); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ + level: "info", + service: "bot", + tag: "http-framework", + message: "User joined", + guildId: "1", + shard: 0, + }); + }); + + test("GIVEN a message and an Error THEN neither is lost", async () => { + const error = new Error("payment declined", { cause: new Error("card expired") }); + + createLogger().error("Failed to charge", { orderId: 7 }, error); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ + level: "error", + message: "Failed to charge", + orderId: 7, + error: { + name: "Error", + message: "payment declined", + cause: { message: "card expired" }, + }, + }); + }); + + test.each([ + ["trace", "trace"], + ["debug", "debug"], + ["info", "info"], + ["warn", "warn"], + ["error", "error"], + ["fatal", "fatal"], + ] as const)("GIVEN logger.%s THEN the event level is %s", async (method, level) => { + createLogger()[method]("hello"); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0].level).toBe(level); + }); + + test("GIVEN a context key named like a reserved field THEN the log's own value wins", async () => { + createLogger({ tag: "bot" }).info("real", { message: "spoofed", tag: "spoofed" }); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ message: "real", tag: "bot" }); + }); + + test("GIVEN an entry without any text THEN no empty message is sent", async () => { + createLogger().info({ guildId: "1" }); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).not.toHaveProperty("message"); + }); + + test("GIVEN a drain THEN close flushes it", async () => { + const drain = { flush: vi.fn(async () => undefined) }; + const transport = new EvlogTransport({ instance: log, drain }); + + await transport.close(); + + expect(drain.flush).toHaveBeenCalledOnce(); + }); + + test("GIVEN no drain THEN close is a no-op", async () => { + await expect(new EvlogTransport({ instance: log }).close()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/plugin-logger/tests/SentryTransport.test.ts b/packages/plugin-logger/tests/SentryTransport.test.ts index 86656b45..9912a6d2 100644 --- a/packages/plugin-logger/tests/SentryTransport.test.ts +++ b/packages/plugin-logger/tests/SentryTransport.test.ts @@ -1,18 +1,35 @@ import { LogLevel } from "@wolfstar/http-framework"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { SentryTransport, type SentryClientLike } from "../src/lib/transports/SentryTransport"; +import { createLogPayload } from "../src/lib/payload"; import { Logger } from "../src/lib/Logger"; +import { SentryTransport, type SentryClientLike } from "../src/lib/transports/SentryTransport"; -let client: SentryClientLike; +let client: SentryClientLike & { + captureException: ReturnType; + captureMessage: ReturnType; + addBreadcrumb: ReturnType; + flush: ReturnType; + logger: Record>; +}; beforeEach(() => { client = { captureException: vi.fn(() => "event-id"), captureMessage: vi.fn(() => "event-id"), + addBreadcrumb: vi.fn(), + flush: vi.fn(async () => true), + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, }; }); -describe("SentryTransport", () => { +describe("SentryTransport events", () => { test("GIVEN no level THEN it defaults to Error", () => { expect(new SentryTransport({ client }).level).toBe(LogLevel.Error); }); @@ -32,27 +49,52 @@ describe("SentryTransport", () => { expect(client.captureMessage).toHaveBeenCalledTimes(2); expect(client.captureException).not.toHaveBeenCalled(); + expect(client.addBreadcrumb).not.toHaveBeenCalled(); + expect(client.logger.error).not.toHaveBeenCalled(); }); - test("GIVEN an Error value THEN captureException is used", () => { + test("GIVEN a message and an Error THEN the exception carries the message as extra", () => { const error = new Error("payment declined"); const transport = new SentryTransport({ client }); - transport.log({ level: LogLevel.Error, values: ["context", error], timestamp: new Date() }); + transport.log(createLogPayload(LogLevel.Error, ["Failed to charge", { orderId: 7 }, error])); expect(client.captureException).toHaveBeenCalledWith(error, { level: "error", - extra: { values: ["context", error] }, + extra: { message: "Failed to charge", context: { orderId: 7 } }, }); expect(client.captureMessage).not.toHaveBeenCalled(); }); - test("GIVEN no Error value THEN captureMessage is used with the joined values", () => { + test("GIVEN only an Error THEN no message extra duplicates the exception", () => { + const error = new Error("payment declined"); + const transport = new SentryTransport({ client }); + + transport.log(createLogPayload(LogLevel.Error, [error])); + + expect(client.captureException).toHaveBeenCalledWith(error, { level: "error", extra: {} }); + }); + + test("GIVEN no Error THEN captureMessage is used with the message and the context", () => { const transport = new SentryTransport({ client }); - transport.log({ level: LogLevel.Fatal, values: ["cannot", { id: 1 }], timestamp: new Date() }); + transport.log(createLogPayload(LogLevel.Fatal, ["cannot", "continue", { id: 1 }])); - expect(client.captureMessage).toHaveBeenCalledWith('cannot {"id":1}', "fatal"); + expect(client.captureMessage).toHaveBeenCalledWith("cannot continue", { + level: "fatal", + extra: { context: { id: 1 } }, + }); + }); + + test("GIVEN an entry without any text THEN captureMessage still gets a message", () => { + const transport = new SentryTransport({ client }); + + transport.log(createLogPayload(LogLevel.Error, [{ id: 1 }])); + + expect(client.captureMessage).toHaveBeenCalledWith( + "(empty log entry)", + expect.objectContaining({ level: "error" }), + ); }); test.each([ @@ -62,7 +104,7 @@ describe("SentryTransport", () => { ] as const)("GIVEN level %s THEN the severity is %s", (level, severity) => { const transport = new SentryTransport({ client, level: LogLevel.Trace }); - transport.log({ level, values: [new Error("x")], timestamp: new Date() }); + transport.log(createLogPayload(level, [new Error("x")])); expect(client.captureException).toHaveBeenCalledWith( expect.any(Error), @@ -78,6 +120,109 @@ describe("SentryTransport", () => { logger.warn("close to the limit"); - expect(client.captureMessage).toHaveBeenCalledWith("close to the limit", "warning"); + expect(client.captureMessage).toHaveBeenCalledWith("close to the limit", { + level: "warning", + extra: {}, + }); + }); +}); + +describe("SentryTransport breadcrumbs", () => { + test("GIVEN a breadcrumb level THEN lower entries become breadcrumbs, not events", () => { + const logger = new Logger({ + level: LogLevel.Trace, + transports: [new SentryTransport({ client, breadcrumbLevel: LogLevel.Info })], + }); + + logger.debug("skipped"); + logger.info("user joined", { guildId: "1" }); + logger.error("boom"); + + expect(client.addBreadcrumb).toHaveBeenCalledTimes(1); + expect(client.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: "info", + message: "user joined", + category: "log", + data: { guildId: "1" }, + timestamp: expect.any(Number), + }), + ); + expect(client.captureMessage).toHaveBeenCalledTimes(1); + }); + + test("GIVEN a breadcrumb level THEN the transport accepts entries down to it", () => { + const transport = new SentryTransport({ client, breadcrumbLevel: LogLevel.Debug }); + + expect(transport.level).toBe(LogLevel.Debug); + }); + + test("GIVEN a client without addBreadcrumb THEN construction fails loudly", () => { + const { addBreadcrumb: _unused, ...withoutBreadcrumbs } = client; + + expect( + () => new SentryTransport({ client: withoutBreadcrumbs, breadcrumbLevel: LogLevel.Info }), + ).toThrow(/addBreadcrumb/); + }); +}); + +describe("SentryTransport logs", () => { + test("GIVEN a log level THEN entries from it are sent to Sentry Logs with their attributes", () => { + const error = new Error("boom"); + const logger = new Logger({ + level: LogLevel.Trace, + transports: [new SentryTransport({ client, logLevel: LogLevel.Info })], + }); + + logger.debug("skipped"); + logger.info("user joined", { guildId: "1" }); + logger.warn("slow", error); + + expect(client.logger.debug).not.toHaveBeenCalled(); + expect(client.logger.info).toHaveBeenCalledWith("user joined", { guildId: "1" }); + expect(client.logger.warn).toHaveBeenCalledWith("slow", { + "error.name": "Error", + "error.message": "boom", + "error.stack": error.stack, + }); + }); + + test("GIVEN an entry at the capture level THEN it is sent as a log and as an event", () => { + const transport = new SentryTransport({ client, logLevel: LogLevel.Info }); + + transport.log(createLogPayload(LogLevel.Error, ["boom"])); + + expect(client.logger.error).toHaveBeenCalledWith("boom", {}); + expect(client.captureMessage).toHaveBeenCalledTimes(1); + }); + + test("GIVEN a client without logger THEN construction fails loudly", () => { + const { logger: _unused, ...withoutLogger } = client; + + expect(() => new SentryTransport({ client: withoutLogger, logLevel: LogLevel.Info })).toThrow( + /logger/, + ); + }); +}); + +describe("SentryTransport close", () => { + test("GIVEN close THEN the client is flushed with the timeout", async () => { + const transport = new SentryTransport({ client, flushTimeout: 500 }); + + await transport.close(); + + expect(client.flush).toHaveBeenCalledWith(500); + }); + + test("GIVEN no flushTimeout THEN it defaults to two seconds", async () => { + await new SentryTransport({ client }).close(); + + expect(client.flush).toHaveBeenCalledWith(2000); + }); + + test("GIVEN a client without flush THEN close is a no-op", async () => { + const { flush: _unused, ...withoutFlush } = client; + + await expect(new SentryTransport({ client: withoutFlush }).close()).resolves.toBeUndefined(); }); }); diff --git a/packages/plugin-logger/tests/WinstonTransport.test.ts b/packages/plugin-logger/tests/WinstonTransport.test.ts new file mode 100644 index 00000000..5c58d677 --- /dev/null +++ b/packages/plugin-logger/tests/WinstonTransport.test.ts @@ -0,0 +1,88 @@ +import { LogLevel } from "@wolfstar/http-framework"; +import { Writable } from "node:stream"; +import { createLogger, format, transports } from "winston"; +import { describe, expect, test } from "vitest"; +import { Logger } from "../src/lib/Logger"; +import { WinstonTransport } from "../src/winston"; + +// The real winston is used on purpose: what matters is the JSON line it ends up writing. +function setup(options: { level?: LogLevel } = {}) { + const lines: Record[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + lines.push(JSON.parse(String(chunk))); + callback(); + }, + }); + const instance = createLogger({ + level: "silly", + format: format.json(), + transports: [new transports.Stream({ stream })], + }); + const transport = new WinstonTransport({ instance, ...options }); + const logger = new Logger({ level: LogLevel.Trace, transports: [transport] }); + + return { lines, logger, transport }; +} + +describe("WinstonTransport", () => { + test("GIVEN a message and a context THEN the context is spread next to the message", async () => { + const { lines, logger, transport } = setup(); + + logger.info("User joined", { guildId: "1" }); + await transport.close(); + + expect(lines[0]).toMatchObject({ level: "info", message: "User joined", guildId: "1" }); + }); + + test("GIVEN a message and an Error THEN the error is serialised with its stack", async () => { + const { lines, logger, transport } = setup(); + const error = new Error("payment declined"); + + logger.error("Failed to charge", error); + await transport.close(); + + expect(lines[0]).toMatchObject({ + level: "error", + message: "Failed to charge", + error: { name: "Error", message: "payment declined", stack: error.stack }, + }); + }); + + test("GIVEN a non-string first value THEN it is not turned into [object Object]", async () => { + const { lines, logger, transport } = setup(); + + logger.info({ guildId: "1" }); + await transport.close(); + + expect(lines[0]).toMatchObject({ message: "", guildId: "1" }); + }); + + test("GIVEN trace and fatal THEN they map to silly and a flagged error", async () => { + const { lines, logger, transport } = setup(); + + logger.trace("t"); + logger.fatal("f"); + await transport.close(); + + expect(lines[0]).toMatchObject({ level: "silly", message: "t" }); + expect(lines[1]).toMatchObject({ level: "error", message: "f", fatal: true }); + }); + + test("GIVEN close THEN every written entry has reached the transports", async () => { + const { lines, logger, transport } = setup(); + + for (let index = 0; index < 50; index++) logger.info("entry", { index }); + await transport.close(); + + expect(lines).toHaveLength(50); + }); + + test("GIVEN close called twice THEN the second call does not hang", async () => { + const { transport } = setup(); + + await transport.close(); + + await expect(transport.close()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/plugin-logger/tests/evlog-interactions.test.ts b/packages/plugin-logger/tests/evlog-interactions.test.ts new file mode 100644 index 00000000..6e041b4a --- /dev/null +++ b/packages/plugin-logger/tests/evlog-interactions.test.ts @@ -0,0 +1,264 @@ +import { Client } from "@wolfstar/http-framework"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import evlogPlugin, { useInteractionLogger } from "../src/evlog-plugin"; +import loggerPlugin from "../src/plugin"; + +const base = { discordPublicKey: "a".repeat(64), discordToken: "token" }; + +let events: Record[]; + +beforeEach(() => { + events = []; +}); + +function createClient( + interactions?: Parameters[0] extends infer C + ? C extends { interactions?: infer I } + ? I + : never + : never, +) { + return new Client({ + ...base, + plugins: [ + evlogPlugin({ + silent: true, + env: { service: "bot" }, + drain: (ctx) => void events.push(ctx.event), + ...(interactions !== undefined && { interactions }), + }), + loggerPlugin(), + ], + } as ConstructorParameters[0]); +} + +function command(name = "ping", overrides: Record = {}) { + return { + command: { name }, + interaction: { + id: "interaction-1", + type: 2, + guild_id: "guild-1", + channel_id: "channel-1", + locale: "en-US", + member: { user: { id: "user-1" } }, + data: { name }, + ...overrides, + }, + response: {}, + }; +} + +describe("interaction wide events", () => { + test("GIVEN a command that succeeds THEN one wide event is drained when it finishes", async () => { + const client = createClient(); + const context = command(); + + client.emit("commandRun", context as never); + client.emit("commandSuccess", context as never, undefined); + expect(events).toHaveLength(0); + client.emit("commandFinish", context as never); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ + level: "info", + service: "bot", + method: "COMMAND", + path: "ping", + requestId: "interaction-1", + outcome: "success", + guildId: "guild-1", + channelId: "channel-1", + userId: "user-1", + locale: "en-US", + durationMs: expect.any(Number), + }); + }); + + test("GIVEN a command that fails THEN the event is an error carrying it", async () => { + const client = createClient(); + const context = command(); + const error = new Error("boom"); + + client.emit("commandRun", context as never); + client.emit("commandError", error, context as never); + client.emit("commandFinish", context as never); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ + level: "error", + outcome: "error", + error: { name: "Error", message: "boom" }, + }); + }); + + test("GIVEN a direct message THEN the user comes from the interaction and there is no guild", async () => { + const client = createClient(); + const context = command("ping", { + guild_id: undefined, + member: undefined, + user: { id: "dm-user" }, + }); + + client.emit("commandRun", context as never); + client.emit("commandFinish", context as never); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ userId: "dm-user" }); + expect(events[0]).not.toHaveProperty("guildId"); + }); + + test("GIVEN interleaved interactions THEN each keeps its own event", async () => { + const client = createClient(); + const first = command("first", { id: "one" }); + const second = command("second", { id: "two" }); + + client.emit("commandRun", first as never); + client.emit("commandRun", second as never); + client.emit("commandError", new Error("only second"), second as never); + client.emit("commandSuccess", first as never, undefined); + client.emit("commandFinish", second as never); + client.emit("commandFinish", first as never); + + await vi.waitFor(() => expect(events).toHaveLength(2)); + const byPath = Object.fromEntries(events.map((event) => [event.path, event])); + expect(byPath.first).toMatchObject({ requestId: "one", outcome: "success" }); + expect(byPath.second).toMatchObject({ requestId: "two", outcome: "error" }); + }); + + test("GIVEN a component or a modal handler THEN it is a wide event too", async () => { + const client = createClient(); + const component = { + handler: { name: "confirm" }, + interaction: { ...command().interaction, type: 3 }, + response: {}, + }; + const modal = { + handler: { name: "feedback" }, + interaction: { ...command().interaction, id: "m", type: 5 }, + response: {}, + }; + + for (const context of [component, modal]) { + client.emit("interactionHandlerRun", context as never); + client.emit("interactionHandlerSuccess", context as never, undefined); + client.emit("interactionHandlerFinish", context as never); + } + + await vi.waitFor(() => expect(events).toHaveLength(2)); + expect(events.map((event) => [event.method, event.path])).toEqual([ + ["COMPONENT", "confirm"], + ["MODAL", "feedback"], + ]); + }); + + test("GIVEN autocomplete THEN it is off by default, as it fires on every keystroke", async () => { + const client = createClient(); + const context = command("search", { type: 4 }); + + client.emit("autocompleteRun", context as never); + client.emit("autocompleteFinish", context as never); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(events).toHaveLength(0); + }); + + test("GIVEN autocomplete: true THEN it is drained", async () => { + const client = createClient({ autocomplete: true }); + const context = command("search", { type: 4 }); + + client.emit("autocompleteRun", context as never); + client.emit("autocompleteSuccess", context as never, undefined); + client.emit("autocompleteFinish", context as never); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ method: "AUTOCOMPLETE", path: "search" }); + }); + + test("GIVEN interactions: false THEN nothing is drained", async () => { + const client = createClient(false); + const context = command(); + + client.emit("commandRun", context as never); + client.emit("commandFinish", context as never); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(events).toHaveLength(0); + }); + + test("GIVEN commands: false THEN only the other kinds are drained", async () => { + const client = createClient({ commands: false }); + const context = command(); + + client.emit("commandRun", context as never); + client.emit("commandFinish", context as never); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(events).toHaveLength(0); + }); +}); + +// The framework emits `*Run` and then awaits the command in the same async flow, which is what +// this reproduces: the code under test runs after an async boundary, like a command would. +async function run( + client: Client, + context: ReturnType, + body: () => void | Promise, +) { + client.emit("commandRun", context as never); + await new Promise((resolve) => setTimeout(resolve, 5)); + await body(); + client.emit("commandSuccess", context as never, undefined); + client.emit("commandFinish", context as never); +} + +describe("useInteractionLogger", () => { + test("GIVEN code running a command THEN the logger enriches the interaction's wide event", async () => { + const client = createClient(); + + await run(client, command(), () => { + useInteractionLogger().set({ cart: { items: 3 } }); + }); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ path: "ping", cart: { items: 3 }, outcome: "success" }); + }); + + test("GIVEN concurrent commands THEN each one enriches its own event", async () => { + const client = createClient(); + + await Promise.all([ + run(client, command("first", { id: "one" }), async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + useInteractionLogger().set({ who: "first" }); + }), + run(client, command("second", { id: "two" }), () => { + useInteractionLogger().set({ who: "second" }); + }), + ]); + + await vi.waitFor(() => expect(events).toHaveLength(2)); + const byPath = Object.fromEntries(events.map((event) => [event.path, event])); + expect(byPath.first).toMatchObject({ who: "first" }); + expect(byPath.second).toMatchObject({ who: "second" }); + }); + + test("GIVEN code outside any interaction THEN it throws, like evlog's useLogger", () => { + createClient(); + + expect(() => useInteractionLogger()).toThrow(); + }); + + test("GIVEN the interaction finished THEN the logger is no longer reachable", async () => { + const client = createClient(); + const context = command(); + + client.emit("commandRun", context as never); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(() => useInteractionLogger()).not.toThrow(); + + client.emit("commandFinish", context as never); + + expect(() => useInteractionLogger()).toThrow(); + }); +}); diff --git a/packages/plugin-logger/tests/evlog-plugin.test.ts b/packages/plugin-logger/tests/evlog-plugin.test.ts new file mode 100644 index 00000000..e5b9d883 --- /dev/null +++ b/packages/plugin-logger/tests/evlog-plugin.test.ts @@ -0,0 +1,172 @@ +import { Client, container } from "@wolfstar/http-framework"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import evlogPlugin, { defineEvlogDrain, type EvlogDrain } from "../src/evlog-plugin"; +import { EvlogTransport } from "../src/evlog"; +import type { Logger } from "../src/lib/Logger"; +import loggerPlugin from "../src/plugin"; + +const base = { discordPublicKey: "a".repeat(64), discordToken: "token" }; + +type Context = Parameters[0]; + +let events: Context["event"][]; + +beforeEach(() => { + events = []; +}); + +// The plugins are listed in the order the Stars module registers them: evlog, then logger. +function createClient( + evlog: ReturnType, + logger?: ConstructorParameters[0]["logger"], +) { + return new Client({ + ...base, + logger, + plugins: [evlog, loggerPlugin()], + } as ConstructorParameters[0]); +} + +describe("evlogPlugin", () => { + test("GIVEN the factory THEN the plugin is named and runs before the others", () => { + const plugin = evlogPlugin() as { name: string; enforce?: string }; + + expect(plugin.name).toBe("@wolfstar/plugin-logger:evlog"); + expect(plugin.enforce).toBe("pre"); + }); + + test("GIVEN a drain THEN container.logger entries reach it as structured events", async () => { + createClient( + evlogPlugin({ + env: { service: "bot" }, + silent: true, + drain: (ctx) => void events.push(ctx.event), + }), + ); + + container.logger.info("User joined", { guildId: "1" }); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ + level: "info", + service: "bot", + tag: "http-framework", + message: "User joined", + guildId: "1", + }); + }); + + test("GIVEN a tag option THEN entries are written under it", async () => { + createClient( + evlogPlugin({ silent: true, tag: "bot", drain: (ctx) => void events.push(ctx.event) }), + ); + + container.logger.warn("slow"); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ level: "warn", tag: "bot" }); + }); + + test("GIVEN pipeline: true THEN events are batched and closing the logger flushes them", async () => { + createClient( + evlogPlugin({ + silent: true, + pipeline: true, + drain: (batch) => void events.push(...batch.map((ctx) => ctx.event)), + }), + ); + + container.logger.info("buffered"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(events).toHaveLength(0); + + await (container.logger as Logger).close(); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ message: "buffered" }); + }); + + test("GIVEN pipeline options THEN they configure the batching", async () => { + createClient( + evlogPlugin({ + silent: true, + pipeline: { batch: { size: 1 } }, + drain: (batch) => void events.push(...batch.map((ctx) => ctx.event)), + }), + ); + + container.logger.info("flushed by size"); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + }); + + test("GIVEN a drain with a flush THEN it is used as it is and closing flushes it", async () => { + const drain = Object.assign((ctx: Context) => void events.push(ctx.event), { + flush: vi.fn(async () => undefined), + }); + createClient(evlogPlugin({ silent: true, drain })); + + container.logger.info("direct"); + await vi.waitFor(() => expect(events).toHaveLength(1)); + await (container.logger as Logger).close(); + + expect(drain.flush).toHaveBeenCalledOnce(); + }); + + test("GIVEN no drain THEN the transport is installed and closing is harmless", async () => { + createClient(evlogPlugin({ silent: true })); + + const { transports } = container.logger as Logger; + + expect(transports).toHaveLength(1); + expect(transports[0]).toBeInstanceOf(EvlogTransport); + await expect((container.logger as Logger).close()).resolves.toBeUndefined(); + }); + + test("GIVEN no config at all THEN the plugin still installs", () => { + createClient(evlogPlugin()); + + expect((container.logger as Logger).transports[0]).toBeInstanceOf(EvlogTransport); + }); + + test("GIVEN transports in ClientOptions.logger THEN the evlog one is added after them", () => { + const own = { log: () => undefined }; + + createClient(evlogPlugin({ silent: true }), { transports: [own] }); + + const { transports } = container.logger as Logger; + expect(transports).toHaveLength(2); + expect(transports[0]).toBe(own); + expect(transports[1]).toBeInstanceOf(EvlogTransport); + }); +}); + +describe("defineEvlogDrain", () => { + test("GIVEN the inline options THEN the factory builds the plugin with them and the drain", async () => { + const factory = defineEvlogDrain((ctx) => void events.push(ctx.event)); + + createClient(factory({ env: { service: "bot" }, silent: true, tag: "inline" })); + container.logger.info("hello"); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(events[0]).toMatchObject({ service: "bot", tag: "inline", message: "hello" }); + }); + + test("GIVEN pipeline in the inline options THEN the drain is wrapped in it", async () => { + const factory = defineEvlogDrain( + ((batch: Context[]) => void events.push(...batch.map((ctx) => ctx.event))) as never, + ); + + createClient(factory({ silent: true, pipeline: true })); + container.logger.info("buffered"); + await (container.logger as Logger).close(); + + expect(events).toHaveLength(1); + }); + + test("GIVEN no options THEN the factory still works", () => { + createClient(defineEvlogDrain(() => undefined)()); + + expect((container.logger as Logger).transports[0]).toBeInstanceOf(EvlogTransport); + }); +}); diff --git a/packages/plugin-logger/tests/module.test.ts b/packages/plugin-logger/tests/module.test.ts index 3c0d3304..bd2b0565 100644 --- a/packages/plugin-logger/tests/module.test.ts +++ b/packages/plugin-logger/tests/module.test.ts @@ -1,4 +1,5 @@ import type { ModuleContext } from "@wolfstar/kit"; +import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; import loggerModule from "../src/module"; @@ -22,4 +23,66 @@ describe("loggerModule", () => { }); expect(ctx.addImports).not.toHaveBeenCalled(); }); + + describe("evlog", () => { + const root = resolve("/project"); + + function setup(options: Parameters>[0]) { + const ctx = { root, addPlugin: vi.fn(), addImports: vi.fn() }; + void loggerModule.setup!(options, ctx as unknown as ModuleContext); + return ctx.addPlugin; + } + + test("GIVEN evlog: true THEN the stock evlog plugin is registered before the logger one", () => { + expect(setup({ level: 20, evlog: true }).mock.calls).toEqual([ + [{ from: "@wolfstar/plugin-logger/evlog/plugin", options: {} }], + [{ from: "@wolfstar/plugin-logger/plugin", options: { level: 20 } }], + ]); + }); + + test("GIVEN evlog: false THEN only the logger plugin is registered", () => { + expect(setup({ evlog: false }).mock.calls).toEqual([ + [{ from: "@wolfstar/plugin-logger/plugin", options: {} }], + ]); + }); + + test("GIVEN inline evlog options THEN they are the stock plugin's options", () => { + const evlog = { env: { service: "bot" }, silent: true, pipeline: { batch: { size: 25 } } }; + + expect(setup({ evlog })).toHaveBeenNthCalledWith(1, { + from: "@wolfstar/plugin-logger/evlog/plugin", + options: evlog, + }); + }); + + test("GIVEN a relative drain file THEN it replaces the stock plugin and receives the options", () => { + const addPlugin = setup({ evlog: { env: { service: "bot" }, drain: "./src/drain.ts" } }); + + expect(addPlugin.mock.calls).toEqual([ + [{ from: resolve(root, "src/drain.ts"), options: { env: { service: "bot" } } }], + [{ from: "@wolfstar/plugin-logger/plugin", options: {} }], + ]); + }); + + test("GIVEN a drain with an export name THEN the export is kept", () => { + const addPlugin = setup({ evlog: { drain: { from: "/abs/drain.ts", export: "axiom" } } }); + + expect(addPlugin).toHaveBeenNthCalledWith(1, { + from: "/abs/drain.ts", + export: "axiom", + options: {}, + }); + }); + + test("GIVEN a file: URL or a package specifier THEN it is passed as it is", () => { + expect(setup({ evlog: { drain: "file:///project/drain.js" } })).toHaveBeenNthCalledWith(1, { + from: "file:///project/drain.js", + options: {}, + }); + expect(setup({ evlog: { drain: "my-drain" } })).toHaveBeenNthCalledWith(1, { + from: "my-drain", + options: {}, + }); + }); + }); }); diff --git a/packages/plugin-logger/tests/payload.test.ts b/packages/plugin-logger/tests/payload.test.ts new file mode 100644 index 00000000..75781776 --- /dev/null +++ b/packages/plugin-logger/tests/payload.test.ts @@ -0,0 +1,93 @@ +import { LogLevel } from "@wolfstar/http-framework"; +import { describe, expect, test } from "vitest"; +import { Logger } from "../src/lib/Logger"; +import { createLogPayload } from "../src/lib/payload"; +import type { LogPayload } from "../src/lib/types"; + +function payloadOf(...values: unknown[]) { + return createLogPayload(LogLevel.Info, values); +} + +describe("createLogPayload", () => { + test("GIVEN plain strings THEN they are joined into the message", () => { + const payload = payloadOf("a", "b"); + + expect(payload.message).toBe("a b"); + expect(payload.error).toBeUndefined(); + expect(payload.context).toBeUndefined(); + }); + + test("GIVEN a message and an Error THEN the message is kept next to the error", () => { + const error = new Error("payment declined"); + const payload = payloadOf("Failed to handle command", error); + + expect(payload.message).toBe("Failed to handle command"); + expect(payload.error).toBe(error); + }); + + test("GIVEN only an Error THEN the message falls back to the error's own", () => { + const payload = payloadOf(new Error("payment declined")); + + expect(payload.message).toBe("payment declined"); + }); + + test("GIVEN several Errors THEN the first is the error", () => { + const first = new Error("first"); + const payload = payloadOf(first, new Error("second")); + + expect(payload.error).toBe(first); + }); + + test("GIVEN plain objects THEN they are merged into the context, the last one winning", () => { + const payload = payloadOf("joined", { guildId: "1", shard: 0 }, { guildId: "2" }); + + expect(payload.message).toBe("joined"); + expect(payload.context).toEqual({ guildId: "2", shard: 0 }); + }); + + test("GIVEN non-plain objects THEN they are stringified into the message", () => { + const payload = payloadOf("ids", [1, 2], new Map()); + + expect(payload.message).toBe("ids [1,2] {}"); + expect(payload.context).toBeUndefined(); + }); + + test("GIVEN primitives THEN they are stringified into the message", () => { + expect(payloadOf("n", 1, null, undefined, true).message).toBe("n 1 null undefined true"); + }); + + test("GIVEN an unserialisable value THEN it falls back to String()", () => { + const circular: unknown[] = []; + circular.push(circular); + + expect(payloadOf(circular).message).toBe(""); + }); + + test("GIVEN the raw values THEN they are forwarded untouched", () => { + const values = ["a", { b: 1 }]; + + expect(createLogPayload(LogLevel.Warn, values).values).toBe(values); + }); + + test("GIVEN a payload THEN the derived fields are computed once", () => { + const payload = payloadOf("a", { b: 1 }); + + expect(payload.context).toBe(payload.context); + }); +}); + +describe("Logger payloads", () => { + test("GIVEN a logged entry THEN transports receive the derived fields", () => { + const received: LogPayload[] = []; + const logger = new Logger({ + transports: [{ log: (payload) => void received.push(payload) }], + }); + const error = new Error("boom"); + + logger.error("Failed", { id: 1 }, error); + + expect(received[0].message).toBe("Failed"); + expect(received[0].context).toEqual({ id: 1 }); + expect(received[0].error).toBe(error); + }); +}); diff --git a/packages/plugin-logger/tsdown.config.ts b/packages/plugin-logger/tsdown.config.ts index 130967f5..5fab7bcf 100644 --- a/packages/plugin-logger/tsdown.config.ts +++ b/packages/plugin-logger/tsdown.config.ts @@ -10,6 +10,7 @@ export default defineConfig( "./module", "./consola", "./evlog", + "./evlog/plugin", "./winston", ], entry: [ @@ -19,6 +20,7 @@ export default defineConfig( "src/module.ts", "src/consola.ts", "src/evlog.ts", + "src/evlog-plugin.ts", "src/winston.ts", ], }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f2a2c9e..cb9b7712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,10 +392,6 @@ importers: version: 0.1.1(magicast@0.5.5) packages/plugin-logger: - dependencies: - '@sentry/node': - specifier: ^8.0.0 || ^9.0.0 || ^10.0.0 - version: 10.76.1(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(supports-color@7.2.0) devDependencies: '@wolfstar/http-framework': specifier: ^6.1.0 @@ -885,48 +881,10 @@ packages: resolution: {integrity: sha512-5Couz53Pl/SgTwtMQ9jlxVIC6wZk9aAbWyeOD7D6D1UIP5DzV/yV5lPDY/kM0nmiqPSzJf9WLTdJgB2gNm0dOA==} engines: {node: ^14.18.0 || >=16.10.0} - '@opentelemetry/api-logs@0.220.0': - resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} - engines: {node: '>=8.0.0'} - '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@opentelemetry/core@2.12.0': - resolution: {integrity: sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/instrumentation@0.220.0': - resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/resources@2.12.0': - resolution: {integrity: sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/sdk-trace-base@2.12.0': - resolution: {integrity: sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/sdk-trace@2.12.0': - resolution: {integrity: sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - - '@opentelemetry/semantic-conventions@1.43.0': - resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} - engines: {node: '>=14'} - '@oxc-parser/binding-android-arm-eabi@0.150.0': resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1701,51 +1659,6 @@ packages: resolution: {integrity: sha512-QGLdC9+pT74Zd7aaObqn0EUfq40c4dyTL65pFnkM6WO1QYN7Yg/s4CdH+CXmx0Zcu6wcfCWILSftXPMosJHP5A==} engines: {node: '>=v14.0.0'} - '@sentry/conventions@0.16.0': - resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} - engines: {node: '>=14'} - - '@sentry/core@10.76.1': - resolution: {integrity: sha512-WZ1D30FDrvZPWcEgbCgaVHROBOaVGxNUcEeF5O5xRAdGu6UB7Epg+CQBeoatw/SxEctQq0BPbMIwtBgbe1QK3g==} - engines: {node: '>=18'} - - '@sentry/node-core@10.76.1': - resolution: {integrity: sha512-5IPAqITJle2wFn+7nkCiWchC9TfdEU2GrH0uS/WmTC7vc64YGAyPK/UZ0qzCU0MUPNH5anYGtUK4EMzlpdmWGQ==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - '@opentelemetry/core': ^1.30.1 || ^2.1.0 - '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' - '@opentelemetry/instrumentation': '>=0.57.1 <1' - '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@opentelemetry/core': - optional: true - '@opentelemetry/exporter-trace-otlp-http': - optional: true - '@opentelemetry/instrumentation': - optional: true - '@opentelemetry/sdk-trace-base': - optional: true - - '@sentry/node@10.76.1': - resolution: {integrity: sha512-caXHUb8pI7Gd9FIwe3soqGWpnTxVVaV15iYk+rueKsBBeUNETLCU6PzwTyD6tQ3WbkhTGYbSGYYLINtj2IroEA==} - engines: {node: '>=18'} - - '@sentry/opentelemetry@10.76.1': - resolution: {integrity: sha512-nfEQ4ALfEXGpYulXTPIOxE4QL3wsXPhlcCBbkgWmkozIT6D3s3vpjtRJsF//xrIkrAMm4OdV25fVqMhEkbJTGA==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - '@opentelemetry/core': ^1.30.1 || ^2.1.0 - '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 - - '@sentry/server-utils@10.76.1': - resolution: {integrity: sha512-E4BHBU2F975eQW/eo4w73DvxN2LNac3wqWzd8lvk4A6jXt8nJwtGSxxdPaTM0Il82aJkbZXJ3IIO/Px6rlX+lQ==} - engines: {node: '>=18'} - '@simple-libs/child-process-utils@2.0.0': resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} engines: {node: '>=22'} @@ -2434,9 +2347,6 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - cjs-module-lexer@2.3.0: - resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} - cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -2695,9 +2605,6 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - es-module-lexer@3.0.3: - resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} - es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} @@ -2988,10 +2895,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-in-the-middle@3.5.2: - resolution: {integrity: sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==} - engines: {node: '>=18'} - import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -3339,9 +3242,6 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - module-details-from-path@1.0.4: - resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3593,10 +3493,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - require-in-the-middle@8.0.1: - resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} - engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} - resolve-dir@1.0.1: resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} engines: {node: '>=0.10.0'} @@ -4807,50 +4703,9 @@ snapshots: pkg-types: 2.3.3 std-env: 4.3.0 - '@opentelemetry/api-logs@0.220.0': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api@1.9.1': optional: true - '@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.43.0 - - '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.220.0 - import-in-the-middle: 3.5.2 - require-in-the-middle: 8.0.1(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - - '@opentelemetry/resources@2.12.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - - '@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - - '@opentelemetry/sdk-trace@2.12.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.43.0 - - '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxc-parser/binding-android-arm-eabi@0.150.0': optional: true @@ -5251,53 +5106,6 @@ snapshots: '@sapphire/utilities@3.18.2': {} - '@sentry/conventions@0.16.0': {} - - '@sentry/core@10.76.1': - dependencies: - '@sentry/conventions': 0.16.0 - - '@sentry/node-core@10.76.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1))': - dependencies: - '@sentry/conventions': 0.16.0 - '@sentry/core': 10.76.1 - '@sentry/opentelemetry': 10.76.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) - import-in-the-middle: 3.5.2 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0) - '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) - - '@sentry/node@10.76.1(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(supports-color@7.2.0)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0) - '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) - '@sentry/conventions': 0.16.0 - '@sentry/core': 10.76.1 - '@sentry/node-core': 10.76.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) - '@sentry/opentelemetry': 10.76.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1)) - '@sentry/server-utils': 10.76.1 - import-in-the-middle: 3.5.2 - transitivePeerDependencies: - - '@opentelemetry/core' - - '@opentelemetry/exporter-trace-otlp-http' - - supports-color - - '@sentry/opentelemetry@10.76.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.12.0(@opentelemetry/api@1.9.1))': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.12.0(@opentelemetry/api@1.9.1) - '@sentry/conventions': 0.16.0 - '@sentry/core': 10.76.1 - - '@sentry/server-utils@10.76.1': - dependencies: - '@sentry/conventions': 0.16.0 - '@sentry/core': 10.76.1 - '@simple-libs/child-process-utils@2.0.0': dependencies: '@simple-libs/stream-utils': 2.0.0 @@ -5861,8 +5669,6 @@ snapshots: cjs-module-lexer@1.4.3: {} - cjs-module-lexer@2.3.0: {} - cli-boxes@4.0.1: {} cli-cursor@3.1.0: @@ -6087,8 +5893,6 @@ snapshots: es-module-lexer@2.3.2: {} - es-module-lexer@3.0.3: {} - es-toolkit@1.52.0: {} escalade@3.2.0: {} @@ -6316,12 +6120,6 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@3.5.2: - dependencies: - cjs-module-lexer: 2.3.0 - es-module-lexer: 3.0.3 - module-details-from-path: 1.0.4 - import-meta-resolve@4.2.0: {} import-without-cache@0.4.1: {} @@ -6674,8 +6472,6 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - module-details-from-path@1.0.4: {} - mri@1.2.0: {} ms@2.1.3: {} @@ -6995,13 +6791,6 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@8.0.1(supports-color@7.2.0): - dependencies: - debug: 4.4.3(supports-color@7.2.0) - module-details-from-path: 1.0.4 - transitivePeerDependencies: - - supports-color - resolve-dir@1.0.1: dependencies: expand-tilde: 2.0.2 From 33730133ffc18a196c1e813dbb01a045a5607567 Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 8 Oct 2026 17:56:54 +0200 Subject: [PATCH 2/3] fix(plugin-logger): keep evlog's types out of module.d.ts The Stars module typed its `evlog` option with the evlog plugin's options, which come from evlog's own types. `evlog` is an optional peer, so a consumer of `./module` without it got unresolved imports in `module.d.ts` when `skipLibCheck` is off. Declare the options structurally in `module.ts`, and check at compile time (`assertions.ts`) that they stay assignable to the evlog plugin's. Also replace the interactions README snippet, which was not valid TypeScript. --- packages/plugin-logger/README.md | 12 ++-- packages/plugin-logger/src/assertions.ts | 11 +++ packages/plugin-logger/src/module.ts | 80 ++++++++++++++++++++- packages/plugin-logger/tests/module.test.ts | 10 +++ 4 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 packages/plugin-logger/src/assertions.ts diff --git a/packages/plugin-logger/README.md b/packages/plugin-logger/README.md index 3d65f967..6f33153d 100644 --- a/packages/plugin-logger/README.md +++ b/packages/plugin-logger/README.md @@ -116,14 +116,10 @@ failed, `durationMs`, and `guildId`, `channelId`, `userId` and `locale`. It goes drain, sampling, redaction and plugins as every other evlog event. ```ts -evlog: { - interactions: { - autocomplete: true; - } -} // commands and handlers are on by default -evlog: { - interactions: false; -} // only the container.logger entries +// Commands and handlers are on by default; add autocomplete: +const options = { evlog: { interactions: { autocomplete: true } } }; +// Or turn the wide events off and keep only the `container.logger` entries: +const quiet = { evlog: { interactions: false } }; ``` Autocomplete is off by default, since Discord sends a request for every keystroke. diff --git a/packages/plugin-logger/src/assertions.ts b/packages/plugin-logger/src/assertions.ts new file mode 100644 index 00000000..3158ea5e --- /dev/null +++ b/packages/plugin-logger/src/assertions.ts @@ -0,0 +1,11 @@ +import type { EvlogInlineOptions } from "./evlog-plugin.js"; +import type { EvlogModuleOptions } from "./module.js"; + +// Compile-time only, not part of any entrypoint. The Stars module declares its `evlog` options +// without evlog's types (see `EvlogModuleOptions`); this fails the typecheck if they stop being +// accepted by the evlog plugin they are handed to. +type Assert = T; + +export type ModuleOptionsAreAcceptedByTheEvlogPlugin = Assert< + Omit extends EvlogInlineOptions ? true : false +>; diff --git a/packages/plugin-logger/src/module.ts b/packages/plugin-logger/src/module.ts index 2bac0348..037dcb4d 100644 --- a/packages/plugin-logger/src/module.ts +++ b/packages/plugin-logger/src/module.ts @@ -1,7 +1,6 @@ import { resolve } from "node:path"; import { defineModule } from "@wolfstar/kit"; -import type { ClientLoggerOptions } from "@wolfstar/http-framework"; -import type { EvlogInlineOptions } from "./evlog-plugin"; +import type { ClientLoggerOptions, LogLevel } from "@wolfstar/http-framework"; /** * The options of the module: the logger's own, plus `evlog` to run evlog's `initLogger` and route @@ -16,9 +15,84 @@ export interface LoggerModuleOptions extends ClientLoggerOptions { * `drain`: a file default-exporting `defineEvlogDrain(...)`, since a drain is a function and * cannot be written here. */ - evlog?: boolean | (EvlogInlineOptions & { drain?: EvlogSource }); + evlog?: boolean | EvlogModuleOptions; } +/** + * The `evlog` options of the module: the JSON-serialisable part of evlog's `initLogger` options, plus + * `tag`, `level`, `pipeline`, `interactions` and `drain`. + * + * Declared structurally, without evlog's own types: `evlog` is an optional peer, and a type imported + * from it would leave `module.d.ts` unresolvable for a consumer who does not have it installed. + * `assertions.ts` checks that this stays assignable to what the evlog plugin accepts. + */ +export interface EvlogModuleOptions { + enabled?: boolean; + env?: { + service?: string; + environment?: string; + version?: string; + commitHash?: string; + region?: string; + }; + pretty?: boolean; + silent?: boolean; + stringify?: boolean; + minLevel?: EvlogLevel; + sampling?: { + /** Percentages from 0 to 100 per level. */ + rates?: Partial, number>>; + keep?: { status?: number; duration?: number; path?: string }[]; + }; + redact?: + | boolean + | { + paths?: string[]; + builtins?: + | false + | ("creditCard" | "email" | "ipv4" | "phone" | "jwt" | "bearer" | "iban")[]; + }; + + /** + * Wraps the drain in evlog's drain pipeline: `true` for its defaults, or its options. + */ + pipeline?: + | boolean + | { + batch?: { size?: number; intervalMs?: number }; + retry?: { + maxAttempts?: number; + backoff?: "exponential" | "linear" | "fixed"; + initialDelayMs?: number; + maxDelayMs?: number; + }; + maxBufferSize?: number; + }; + + /** + * The tag every entry is written under. + */ + tag?: string; + + /** + * The lowest level the `EvlogTransport` accepts. + */ + level?: LogLevel; + + /** + * Which interactions are logged as wide events. `true` takes the defaults, `false` turns it off. + */ + interactions?: boolean | { commands?: boolean; autocomplete?: boolean; handlers?: boolean }; + + /** + * The file default-exporting `defineEvlogDrain(...)`, since a drain is a function and cannot be + * written here. + */ + drain?: EvlogSource; +} + +type EvlogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; + /** * A path starting with `.` is resolved against the project root; an absolute path, a `file:` URL or * a package specifier is used as it is. Use `{ from, export }` for a named export. diff --git a/packages/plugin-logger/tests/module.test.ts b/packages/plugin-logger/tests/module.test.ts index bd2b0565..23841f90 100644 --- a/packages/plugin-logger/tests/module.test.ts +++ b/packages/plugin-logger/tests/module.test.ts @@ -1,4 +1,5 @@ import type { ModuleContext } from "@wolfstar/kit"; +import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; import loggerModule from "../src/module"; @@ -24,6 +25,15 @@ describe("loggerModule", () => { expect(ctx.addImports).not.toHaveBeenCalled(); }); + // `evlog` is an optional peer: a type imported from it (or from the evlog plugin, which imports it) + // ends up in `module.d.ts`, and fails to resolve for a consumer without evlog when `skipLibCheck` + // is off. + test("GIVEN the module source THEN it imports nothing from evlog nor from the evlog plugin", () => { + const source = readFileSync(new URL("../src/module.ts", import.meta.url), "utf8"); + + expect(source).not.toMatch(/from\s+["'](?:evlog|\.\/evlog)/); + }); + describe("evlog", () => { const root = resolve("/project"); From 540ea92ec6e5dc0096d5982834b27d425ecc8457 Mon Sep 17 00:00:00 2001 From: RedStar Date: Thu, 8 Oct 2026 20:10:41 +0200 Subject: [PATCH 3/3] ci(plugin-logger): declare the type assertions file as a knip entry `src/assertions.ts` only holds a compile-time check that the Stars module's structural evlog options stay assignable to the evlog plugin's. Nothing imports it, so knip reported it as an unused file and failed the unused code check. Declare it as an entry, like the type-level tests of the other packages. --- knip.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knip.json b/knip.json index d0c117fc..a5c15929 100644 --- a/knip.json +++ b/knip.json @@ -14,6 +14,10 @@ "packages/*": { "project": ["src/**/*.ts"] }, + "packages/plugin-logger": { + "entry": ["src/assertions.ts"], + "project": ["src/**/*.ts"] + }, "packages/plugin-broker": { "entry": ["src/index.ts", "tests/types/*.ts"], "project": ["src/**/*.ts", "tests/types/*.ts"],