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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rework-logger-transports.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
149 changes: 141 additions & 8 deletions packages/plugin-logger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,6 +57,94 @@ 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
// 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.

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.
Expand Down Expand Up @@ -97,6 +187,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:

Expand Down Expand Up @@ -125,17 +229,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
Expand All @@ -149,12 +269,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";
Expand All @@ -164,8 +296,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

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-logger/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
},
Expand Down
11 changes: 11 additions & 0 deletions packages/plugin-logger/src/assertions.ts
Original file line number Diff line number Diff line change
@@ -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 extends true> = T;

export type ModuleOptionsAreAcceptedByTheEvlogPlugin = Assert<
Omit<EvlogModuleOptions, "drain"> extends EvlogInlineOptions ? true : false
>;
Loading
Loading