@zerotal/arch 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/logger.md
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Logger
|
|
3
|
+
description: Record structured, multi-channel application logs and auto-capture framework signals like slow queries and request errors.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Logger
|
|
7
|
+
|
|
8
|
+
`zerotal/logger` is Zerotal's structured logger. It gives you a multi-channel
|
|
9
|
+
`LogManager` (console, file, daily-rotating, stack, null), a global `Log` facade
|
|
10
|
+
you can call anywhere, HTTP access logging via `LoggerMiddleware`, and automatic
|
|
11
|
+
logging of framework signals (slow queries, N+1, failed jobs, auth events,
|
|
12
|
+
request errors) by subscribing to the [FrameworkEvents](/docs/events)
|
|
13
|
+
instrumentation bus.
|
|
14
|
+
|
|
15
|
+
## Getting Started
|
|
16
|
+
|
|
17
|
+
The logger ships as part of `@zerotal/core` — there is nothing extra to install. Import it from the `zerotal/logger` subpath:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Log, LogProvider } from "zerotal/logger";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Register the provider
|
|
24
|
+
|
|
25
|
+
Add `LogProvider` to the providers array in `bootstrap/providers.ts`:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// bootstrap/providers.ts
|
|
29
|
+
import { LogProvider } from "zerotal/logger";
|
|
30
|
+
|
|
31
|
+
const providers = [
|
|
32
|
+
// …your other providers
|
|
33
|
+
LogProvider,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export default providers;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Registering the provider switches on the following, in lifecycle order:
|
|
40
|
+
|
|
41
|
+
- `onRegister` — binds the `LogManager` under the `log` container token as a lazy singleton.
|
|
42
|
+
- `onBooting` — wires the singleton into `LoggerMiddleware` (`setManager`) and registers it via `app.useOnce()`, so request access logging is active with no manual `.use()`.
|
|
43
|
+
- `onBooted` — subscribes to the [FrameworkEvents](/docs/events) bus (slow queries, N+1, failed jobs, scheduled tasks, auth events, request errors).
|
|
44
|
+
- `onStopping` — unsubscribes every handler, so nothing leaks between boots or test suites.
|
|
45
|
+
|
|
46
|
+
> **Note** — `LogProvider` runs in every environment (`web`, `console`,
|
|
47
|
+
> `worker`, `test`, `repl`), so `Log` is available in HTTP requests, commands,
|
|
48
|
+
> queue workers, and the REPL alike.
|
|
49
|
+
|
|
50
|
+
## Where logs go by default
|
|
51
|
+
|
|
52
|
+
Two places, always, with no config file at all:
|
|
53
|
+
|
|
54
|
+
- **The terminal**, so you can watch what is happening.
|
|
55
|
+
- **A dated file** — `storage/logs/YYYY-MM-DD.log`, one per day, kept for 14 days.
|
|
56
|
+
|
|
57
|
+
They are independent on purpose. Quietening the terminal must not cost you the
|
|
58
|
+
record, because the record is what you read after the process exited, after the
|
|
59
|
+
scrollback rolled over, on a machine nobody was watching. Both are properties of
|
|
60
|
+
the logger rather than channels you route to, so pointing `default` at a file
|
|
61
|
+
channel no longer silences your terminal.
|
|
62
|
+
|
|
63
|
+
> **Note** — The file trail is off when `APP_ENV=test`. The path is relative to
|
|
64
|
+
> the working directory, so a suite that boots an app would otherwise grow a
|
|
65
|
+
> `storage/logs` directory wherever it happened to run. Ask for it explicitly
|
|
66
|
+
> (`file: { path: "./tmp/logs" }`) if a test needs it.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
Create `config/logging.ts` and use the `LoggingConfig()` helper, which
|
|
71
|
+
deep-merges your options over the defaults so you only specify what you change:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// config/logging.ts
|
|
75
|
+
import { LoggingConfig } from "zerotal/logger";
|
|
76
|
+
|
|
77
|
+
export default LoggingConfig({
|
|
78
|
+
// Quiet terminal, full trail on disk, a month of history.
|
|
79
|
+
console: { level: "warn" },
|
|
80
|
+
file: { days: 30 },
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Field | Required | Default | Description |
|
|
85
|
+
| ------------- | -------- | ------------------------- | ------------------------------------------------------------------ |
|
|
86
|
+
| `console` | no | `{ format: "pretty" }` | The terminal sink. `false` silences it. |
|
|
87
|
+
| `file` | no | `./storage/logs`, 14 days | The dated file trail. `false` turns it off. |
|
|
88
|
+
| `default` | no | `"app"` | Name of the channel that `Log.info()` (and friends) also write to. |
|
|
89
|
+
| `channels` | no | `{}` | Named map of extra destinations, discriminated by `driver`. |
|
|
90
|
+
| `slowQueryMs` | no | `1000` | Queries slower than this (ms) are auto-logged at `warn`. |
|
|
91
|
+
|
|
92
|
+
### The two sinks
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// config/logging.ts
|
|
96
|
+
export default LoggingConfig({
|
|
97
|
+
console: { level: "info", format: "json" }, // or false
|
|
98
|
+
file: { path: "./var/log/app", days: 30, level: "debug" }, // or false
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`console` takes `level` (default `debug`) and `format` (`"pretty"` or `"json"`).
|
|
103
|
+
`file` takes `path`, `days`, and `level` — which also defaults to `debug`, so the
|
|
104
|
+
trail records everything even when the console is filtered. That asymmetry is the
|
|
105
|
+
point: you decide what to _watch_ without deciding what to _keep_.
|
|
106
|
+
|
|
107
|
+
Turning the file off is reasonable in a container that ships stdout to a
|
|
108
|
+
collector:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// config/logging.ts
|
|
112
|
+
export default LoggingConfig({ file: false });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Named channels
|
|
116
|
+
|
|
117
|
+
Channels are _extra_ destinations layered on top of the two sinks, for routing a
|
|
118
|
+
subsystem somewhere specific:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// config/logging.ts
|
|
122
|
+
export default LoggingConfig({
|
|
123
|
+
channels: {
|
|
124
|
+
audit: { driver: "daily", path: "./storage/logs/audit", days: 90 },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// elsewhere
|
|
129
|
+
Log.channel("audit").info("permission granted", { userId, ability });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
An entry routed to a channel still reaches the console and the trail — with one
|
|
133
|
+
exception that keeps output honest: a channel that already covers a sink
|
|
134
|
+
suppresses that baseline for its own entries. A `console` channel does not print
|
|
135
|
+
twice; a `single`/`daily` channel does not write the same entry to two files. A
|
|
136
|
+
`stack` inherits the coverage of its members.
|
|
137
|
+
|
|
138
|
+
`null` is not an exception. It discards its own writes; it does not suppress the
|
|
139
|
+
record.
|
|
140
|
+
|
|
141
|
+
## Channel drivers
|
|
142
|
+
|
|
143
|
+
Each channel is a `{ driver: … }` entry under `channels`, discriminated by its
|
|
144
|
+
`driver` field:
|
|
145
|
+
|
|
146
|
+
| Driver | Config fields | Behaviour |
|
|
147
|
+
| --------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
148
|
+
| `console` | `level?`, `format?` | Writes to `process.stdout`. `format: 'pretty'` (default) for coloured terminal lines; `format: 'json'` for one JSON object per line. |
|
|
149
|
+
| `single` | `level?`, `path` | Appends JSON lines to one file at `path`. Creates parent directories as needed; write errors are swallowed. |
|
|
150
|
+
| `daily` | `level?`, `path`, `days?` | One date-stamped JSON file (`YYYY-MM-DD.log`) per day under `path/`. When `days` is set, files older than that many days are pruned. |
|
|
151
|
+
| `stack` | `level?`, `channels` | Fan-out: each entry is written to every listed child channel simultaneously. |
|
|
152
|
+
| `null` | — | Discards everything. Use in test environments. |
|
|
153
|
+
|
|
154
|
+
> **Warning** — The `daily` driver only prunes old files when you set `days`. Omit
|
|
155
|
+
> `days` and files accumulate indefinitely. A `stack` referencing an unknown child
|
|
156
|
+
> channel throws at construction time.
|
|
157
|
+
|
|
158
|
+
### Switching destinations per environment
|
|
159
|
+
|
|
160
|
+
The two sinks already cover the common case, so this is for the extras:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// config/logging.ts
|
|
164
|
+
export default LoggingConfig({
|
|
165
|
+
// Machine-readable terminal output in production, human-readable locally.
|
|
166
|
+
console: { format: process.env.APP_ENV === "production" ? "json" : "pretty" },
|
|
167
|
+
// Keep a year of audit history in production only.
|
|
168
|
+
...(process.env.APP_ENV === "production"
|
|
169
|
+
? { channels: { audit: { driver: "daily", path: "./storage/logs/audit", days: 365 } } }
|
|
170
|
+
: {}),
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## The Log facade
|
|
175
|
+
|
|
176
|
+
`Log` is a static proxy over the `LogManager` singleton. Use it anywhere —
|
|
177
|
+
controllers, services, commands, event listeners:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// in a controller or service
|
|
181
|
+
import { Log } from "zerotal/logger";
|
|
182
|
+
|
|
183
|
+
Log.debug("User lookup started", { userId: 42 });
|
|
184
|
+
Log.info("Order created", { orderId: order.id, total: order.total });
|
|
185
|
+
Log.warn("Rate limit close", { ip, remaining: 2 });
|
|
186
|
+
Log.error("Payment failed", { orderId }, err);
|
|
187
|
+
Log.fatal("Database unreachable", { host: dbHost }, err);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Every level shares the same signature:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// signature — all five levels
|
|
194
|
+
Log.info(message: string, context?: Record<string, unknown>, err?: unknown): void
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- `context` — arbitrary key/value data merged into the structured log entry.
|
|
198
|
+
- `err` — an `Error` instance (message + stack captured) or any value (stringified).
|
|
199
|
+
|
|
200
|
+
## Targeting a specific channel
|
|
201
|
+
|
|
202
|
+
`Log.channel(name)` returns a `BoundLogger` that writes to the named channel
|
|
203
|
+
instead of the configured default:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// in a controller or service
|
|
207
|
+
import { Log } from "zerotal/logger";
|
|
208
|
+
|
|
209
|
+
const auditLog = Log.channel("daily");
|
|
210
|
+
auditLog.info("Admin action", { adminId: user.id, action: "delete_user", targetId });
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
This lets specific modules write to a dedicated channel without changing
|
|
214
|
+
`default` for the whole application.
|
|
215
|
+
|
|
216
|
+
## Adding persistent context
|
|
217
|
+
|
|
218
|
+
`Log.withContext(extra)` returns a `BoundLogger` (writing to the default channel)
|
|
219
|
+
that merges `extra` into every entry. Useful in long-running jobs or to tag a
|
|
220
|
+
group of log lines with a shared identifier:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// in a queue job
|
|
224
|
+
import { Log } from "zerotal/logger";
|
|
225
|
+
|
|
226
|
+
const logger = Log.withContext({ jobId: job.id, queue: job.queue });
|
|
227
|
+
|
|
228
|
+
logger.info("Job started");
|
|
229
|
+
logger.warn("Retrying step", { step: "charge_card", attempt: 2 });
|
|
230
|
+
logger.info("Job finished", { durationMs });
|
|
231
|
+
// All three entries carry jobId and queue automatically
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Scopes: which subsystem is talking
|
|
235
|
+
|
|
236
|
+
`Log.scope(name)` returns a `BoundLogger` that tags every entry with the part of
|
|
237
|
+
the system it came from. The console channel renders the tag as a padded column,
|
|
238
|
+
so a boot log reads down the page instead of across it:
|
|
239
|
+
|
|
240
|
+
```text
|
|
241
|
+
01:27:48.682 INFO [APP] Application booted {"durationMs":486,"environment":"web","providers":2}
|
|
242
|
+
01:27:50.310 INFO [FLOW] Compiled 4 page(s), 2 from cache, 3 bind-injected, 8 using runtime
|
|
243
|
+
01:27:50.314 INFO [ROUTER] Registered 2 static asset routes {"dir":"…/public"}
|
|
244
|
+
01:27:50.320 INFO [APP] Server listening on http://localhost:3000 {"port":3000}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The tag is a real field, not decoration — file and JSON channels keep it as
|
|
248
|
+
`scope`, so a log file can be filtered down to one subsystem.
|
|
249
|
+
|
|
250
|
+
The framework uses this for its own output. Everything a running Zerotal app
|
|
251
|
+
reports — boot, routing, the Flow compiler, the queue, the scheduler, page
|
|
252
|
+
registry generation — is an ordinary log entry on your configured channels, at a
|
|
253
|
+
level you can filter and in a file you can ship. Nothing writes to the terminal
|
|
254
|
+
behind the logger's back, which means a `daily` channel captures the framework's
|
|
255
|
+
own account of a boot, not just your application's.
|
|
256
|
+
|
|
257
|
+
The one deliberate exception is the CLI itself. `serve --dev`'s banner and its
|
|
258
|
+
`[zerotal:dev]` build/restart lines come from the supervisor process that watches
|
|
259
|
+
your files, not from the application, and they are interactive terminal output
|
|
260
|
+
rather than a record of anything. They stay on stdout.
|
|
261
|
+
|
|
262
|
+
## How context is rendered
|
|
263
|
+
|
|
264
|
+
On the console, context is written as `key=value` pairs rather than a JSON blob.
|
|
265
|
+
The keys are dimmed so the values carry the eye, and strings stay exactly as you
|
|
266
|
+
passed them:
|
|
267
|
+
|
|
268
|
+
```text
|
|
269
|
+
03:19:56.531 INFO [ROUTER] Registered 2 static asset routes dir=C:\Projects\app\public
|
|
270
|
+
03:19:56.537 INFO [APP] Server listening on http://localhost:3000 port=3000
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Literal strings matter more than they sound. `JSON.stringify` escapes every
|
|
274
|
+
backslash, so the same line used to read `{"dir":"C:\\Projects\\app\\public"}` —
|
|
275
|
+
a path you cannot copy, in a format that spends its width on punctuation.
|
|
276
|
+
|
|
277
|
+
A value containing whitespace is quoted (`dir="C:\Program Files\app"`) so the
|
|
278
|
+
pairs stay separable; nothing else is escaped. Nested objects and arrays fall
|
|
279
|
+
back to compact JSON, which is what they are.
|
|
280
|
+
|
|
281
|
+
When the pairs would run past the edge of your terminal, they fold onto a
|
|
282
|
+
continuation line indented under the message instead:
|
|
283
|
+
|
|
284
|
+
```text
|
|
285
|
+
07:02:37.464 INFO [FLOW] Compiled 0 page(s), 0 from cache, 3 bind-injected, 8 using runtime
|
|
286
|
+
↳ compiled=0 cached=0 injected=3 runtime=8 ms=82
|
|
287
|
+
07:02:37.469 INFO [ROUTER] Registered 2 static asset routes dir=C:\Projects\app\public
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
A short bag stays on the message line — one event reading as one line is worth
|
|
291
|
+
keeping. A long one is laid out here rather than left to the terminal, which
|
|
292
|
+
would break it mid-pair at whatever column it ran out of room and bury the
|
|
293
|
+
message it belongs to. Lines are filled greedily with whole pairs, so a
|
|
294
|
+
`key=value` is never split across two of them, and only the first continuation
|
|
295
|
+
carries the `↳`. The width comes from the terminal; piped or redirected output
|
|
296
|
+
assumes a page.
|
|
297
|
+
|
|
298
|
+
This applies to the console only. File and JSON channels are unchanged — they
|
|
299
|
+
write the full `LogEntry` as one JSON line, which is what a collector wants.
|
|
300
|
+
|
|
301
|
+
## Tables: structured data you can actually read
|
|
302
|
+
|
|
303
|
+
Past three or four keys, inline context stops being readable — `{"compiled":0,"cached":0,"injected":3,"runtime":8,"ms":124}`
|
|
304
|
+
is a wall the eye slides off. `table()` logs the same data and asks the console
|
|
305
|
+
to draw it in columns:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
Log.table("Compile summary", { compiled: 0, cached: 2, injected: 3, runtime: 8, ms: 124 });
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```text
|
|
312
|
+
02:03:19.263 INFO [FLOW] Compile summary
|
|
313
|
+
┌──────────┬─────┐
|
|
314
|
+
│ compiled │ 0 │
|
|
315
|
+
│ cached │ 2 │
|
|
316
|
+
│ injected │ 3 │
|
|
317
|
+
│ runtime │ 8 │
|
|
318
|
+
│ ms │ 124 │
|
|
319
|
+
└──────────┴─────┘
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Pass a list of objects instead and each key becomes a column, with a header. A
|
|
323
|
+
third argument sets the level, which defaults to `info`:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
Log.table(
|
|
327
|
+
"Pages rendering through the runtime",
|
|
328
|
+
[
|
|
329
|
+
{ page: "ListsPage", blocker: "<Demo> is a component", at: "lists.tsx:83:10" },
|
|
330
|
+
{ page: "IndexPage", blocker: "key={d.href} is not static", at: "index.tsx:96:15" },
|
|
331
|
+
],
|
|
332
|
+
"warn",
|
|
333
|
+
);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```text
|
|
337
|
+
02:03:19.263 WARN [FLOW] Pages rendering through the runtime
|
|
338
|
+
┌───────────┬────────────────────────────┬─────────────────┐
|
|
339
|
+
│ page │ blocker │ at │
|
|
340
|
+
├───────────┼────────────────────────────┼─────────────────┤
|
|
341
|
+
│ ListsPage │ <Demo> is a component │ lists.tsx:83:10 │
|
|
342
|
+
│ IndexPage │ key={d.href} is not static │ index.tsx:96:15 │
|
|
343
|
+
└───────────┴────────────────────────────┴─────────────────┘
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Numeric columns right-align so digits line up, a row missing a key gets a blank
|
|
347
|
+
cell rather than shifting the table, and an over-long cell is truncated instead
|
|
348
|
+
of wrapping into noise. Strings render literally, so a Windows path reads as
|
|
349
|
+
`C:\Projects\app\public` rather than the double-escaped form `JSON.stringify`
|
|
350
|
+
produces.
|
|
351
|
+
|
|
352
|
+
The table is presentation only. The rows are ordinary context, so a `daily` or
|
|
353
|
+
`single` channel writes exactly what any other level method would have written —
|
|
354
|
+
a list ends up under a `rows` key — and a JSON collector never sees a box-drawing
|
|
355
|
+
character. `renderTable(data)` is exported if you want the same lines somewhere
|
|
356
|
+
other than a log.
|
|
357
|
+
|
|
358
|
+
## Log entry shape
|
|
359
|
+
|
|
360
|
+
Every structured entry written by any channel has this shape:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
// zerotal/logger — LogEntry
|
|
364
|
+
interface LogEntry {
|
|
365
|
+
level: "debug" | "info" | "warn" | "error" | "fatal";
|
|
366
|
+
channel: string;
|
|
367
|
+
scope?: string; // subsystem tag, e.g. "app", "flow", "queue"
|
|
368
|
+
display?: "table"; // rendering hint for human-facing channels; ignored by JSON
|
|
369
|
+
message: string;
|
|
370
|
+
timestamp: string; // ISO 8601
|
|
371
|
+
app?: string;
|
|
372
|
+
env?: string;
|
|
373
|
+
hostname: string;
|
|
374
|
+
pid: number;
|
|
375
|
+
requestId?: string; // set automatically inside HTTP requests
|
|
376
|
+
context?: Record<string, unknown>;
|
|
377
|
+
error?: string;
|
|
378
|
+
stack?: string;
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`requestId` is populated automatically when the log call is made within a request
|
|
383
|
+
context (i.e. inside a middleware or controller) — no manual threading required.
|
|
384
|
+
|
|
385
|
+
## Log levels
|
|
386
|
+
|
|
387
|
+
Entries below a channel's configured `level` are silently dropped (a channel with
|
|
388
|
+
no `level` defaults to `debug`, letting everything through):
|
|
389
|
+
|
|
390
|
+
| Level | Numeric | Typical use |
|
|
391
|
+
| ------- | ------- | ------------------------------------------------------ |
|
|
392
|
+
| `debug` | 0 | Verbose diagnostic detail; development only |
|
|
393
|
+
| `info` | 1 | Normal operational events |
|
|
394
|
+
| `warn` | 2 | Recoverable issues, rate-limit proximity, slow queries |
|
|
395
|
+
| `error` | 3 | Caught exceptions, request errors |
|
|
396
|
+
| `fatal` | 4 | Unrecoverable failures, process about to exit |
|
|
397
|
+
|
|
398
|
+
## HTTP access logging
|
|
399
|
+
|
|
400
|
+
`LoggerMiddleware` is registered automatically by `LogProvider`. It times every
|
|
401
|
+
request, sets an `X-Request-Id` response header, and emits one line per request.
|
|
402
|
+
By default it writes a coloured text line straight to `process.stdout`:
|
|
403
|
+
|
|
404
|
+
```text
|
|
405
|
+
GET /posts ......................................... 200 42ms
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Set the format to `json` (via the `LOG_FORMAT` env var) to route the entry through
|
|
409
|
+
the configured `LogManager` channels instead, at `info`/`warn`/`error` by status:
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
# in your project root
|
|
413
|
+
LOG_FORMAT=json bun run start
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
> **Note** — In text mode the access line goes directly to stdout, not through the
|
|
417
|
+
> default channel. Use `LOG_FORMAT=json` when you want request lines persisted to
|
|
418
|
+
> your file/daily channels. To drop access logging entirely, omit `LogProvider`
|
|
419
|
+
> and register your own middleware.
|
|
420
|
+
|
|
421
|
+
## Automatic framework signals
|
|
422
|
+
|
|
423
|
+
Once `LogProvider.onBooted()` runs, it subscribes to the
|
|
424
|
+
[FrameworkEvents](/docs/events) bus and logs these signals automatically:
|
|
425
|
+
|
|
426
|
+
| Event | Level | Condition |
|
|
427
|
+
| ----------------------- | -------------- | --------------------------------------------------- |
|
|
428
|
+
| `QueryExecuted` | `warn` | Query duration ≥ `slowQueryMs` |
|
|
429
|
+
| `NPlusOneDetected` | `warn` | An N+1 access pattern is detected |
|
|
430
|
+
| `TransactionRolledBack` | `warn` | A database transaction is rolled back |
|
|
431
|
+
| `MigrationRan` | `info`/`error` | A migration completes (or fails) |
|
|
432
|
+
| `JobRan` | `warn`/`error` | A queued job is retried (`warn`) or fails (`error`) |
|
|
433
|
+
| `MessageFailed` | `error` | A mail send fails |
|
|
434
|
+
| `TaskRan` | `info`/`error` | A scheduled task ran (or failed its run) |
|
|
435
|
+
| `TaskFailed` | `error` | A scheduled task throws |
|
|
436
|
+
| `LoginSucceeded` | `info` | A user authenticates |
|
|
437
|
+
| `LoginFailed` | `warn` | An authentication attempt fails |
|
|
438
|
+
| `LoggedOut` | `info` | A user logs out |
|
|
439
|
+
| `AuthorizationDenied` | `warn` | An ability check is denied |
|
|
440
|
+
| `RequestHandled` | `warn`/`error` | A request returns a 4xx (`warn`) or 5xx (`error`) |
|
|
441
|
+
| `RequestFailed` | `error` | The request pipeline throws |
|
|
442
|
+
|
|
443
|
+
> **Tip** — Requests to internal paths (`/__zerotal/`, `/__flow/`, `/__dev/`)
|
|
444
|
+
> are skipped, so the dev tooling doesn't flood your logs.
|
|
445
|
+
|
|
446
|
+
## Testing
|
|
447
|
+
|
|
448
|
+
Set your suite up once as described in [Testing](/docs/testing). Asserting on a
|
|
449
|
+
log usually means asserting on a side-effect nobody else observes, so the logger
|
|
450
|
+
gives you a tap.
|
|
451
|
+
|
|
452
|
+
**`LogManager.tap()` receives every entry** after enrichment and before it
|
|
453
|
+
reaches a channel, and returns an unsubscribe function:
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
// tests/logging/AuditTrail.test.ts
|
|
457
|
+
import { test, expect, afterEach } from "bun:test";
|
|
458
|
+
import { LogManager, type LogEntry } from "zerotal/logger";
|
|
459
|
+
|
|
460
|
+
let stop: (() => void) | undefined;
|
|
461
|
+
afterEach(() => stop?.());
|
|
462
|
+
|
|
463
|
+
test("a failed charge is logged with the order id", async () => {
|
|
464
|
+
const entries: LogEntry[] = [];
|
|
465
|
+
stop = LogManager.tap((entry) => entries.push(entry));
|
|
466
|
+
|
|
467
|
+
await chargeOrder(order); // fails internally
|
|
468
|
+
|
|
469
|
+
const failure = entries.find((e) => e.level === "error");
|
|
470
|
+
expect(failure?.message).toContain("Charge failed");
|
|
471
|
+
expect(failure?.context?.orderId).toBe(order.id);
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
**Unsubscribe in `afterEach`, not at the end of the test.** Taps are static and
|
|
476
|
+
process-wide, so one left installed keeps collecting entries from every later
|
|
477
|
+
test in the run — the array grows, the assertions get slower, and a `find()`
|
|
478
|
+
starts matching something from a different test.
|
|
479
|
+
|
|
480
|
+
**Silence the logger in the suite** so a passing run stays readable. The `null`
|
|
481
|
+
driver discards everything:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
// tests/helpers.ts
|
|
485
|
+
.useConfig({
|
|
486
|
+
logging: { default: "null", channels: { null: { driver: "null" } } },
|
|
487
|
+
})
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
The tap still fires with `null` as the default channel — it runs before the
|
|
491
|
+
channel write — so you get quiet output and assertable logs at the same time.
|
|
492
|
+
|
|
493
|
+
> **Note** — Assert on `entry.context` rather than on the formatted message.
|
|
494
|
+
> The message is prose and will be reworded; the context is structured data and
|
|
495
|
+
> is what a log search actually queries.
|
|
496
|
+
|
|
497
|
+
## References
|
|
498
|
+
|
|
499
|
+
`Log` and every `BoundLogger` returned by `channel()`/`withContext()` share the
|
|
500
|
+
same surface:
|
|
501
|
+
|
|
502
|
+
| Method | Signature | Description |
|
|
503
|
+
| ------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
504
|
+
| `debug` | `(message: string, context?: Record<string, unknown>, err?: unknown) => void` | Write a `debug`-level entry to the bound channel. |
|
|
505
|
+
| `info` | `(message: string, context?: Record<string, unknown>, err?: unknown) => void` | Write an `info`-level entry. |
|
|
506
|
+
| `warn` | `(message: string, context?: Record<string, unknown>, err?: unknown) => void` | Write a `warn`-level entry. |
|
|
507
|
+
| `error` | `(message: string, context?: Record<string, unknown>, err?: unknown) => void` | Write an `error`-level entry. |
|
|
508
|
+
| `fatal` | `(message: string, context?: Record<string, unknown>, err?: unknown) => void` | Write a `fatal`-level entry. |
|
|
509
|
+
| `channel` | `(name: string) => BoundLogger` | Bind a logger to a specific channel (`Log` only). |
|
|
510
|
+
| `withContext` | `(extra: Record<string, unknown>) => BoundLogger` | Bind a logger that merges `extra` into every entry (`Log` only). |
|
|
511
|
+
|
|
512
|
+
## Next steps
|
|
513
|
+
|
|
514
|
+
- [Events](/docs/events) — the FrameworkEvents bus the logger subscribes to.
|
|
515
|
+
- [DevTools](/docs/devtools) — per-request log capture in the debug panel.
|
|
516
|
+
- [Commands](/docs/commands) — using `Log` inside scheduled commands.
|
|
517
|
+
- [Telemetry](/docs/telemetry) — distributed tracing alongside structured logs.
|