@tanngo11/log 0.3.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/CONTRACT.md +135 -0
  3. package/LICENSE +21 -0
  4. package/README.md +291 -0
  5. package/dist/bullmq.cjs +108 -0
  6. package/dist/bullmq.cjs.map +1 -0
  7. package/dist/bullmq.d.cts +14 -0
  8. package/dist/bullmq.d.ts +14 -0
  9. package/dist/bullmq.js +29 -0
  10. package/dist/bullmq.js.map +1 -0
  11. package/dist/chunk-BP264YAM.js +72 -0
  12. package/dist/chunk-BP264YAM.js.map +1 -0
  13. package/dist/chunk-BVKAY2OK.js +53 -0
  14. package/dist/chunk-BVKAY2OK.js.map +1 -0
  15. package/dist/chunk-DO5HNNTP.js +66 -0
  16. package/dist/chunk-DO5HNNTP.js.map +1 -0
  17. package/dist/chunk-KD5H5G6Y.js +209 -0
  18. package/dist/chunk-KD5H5G6Y.js.map +1 -0
  19. package/dist/chunk-KLBL7QEN.js +29 -0
  20. package/dist/chunk-KLBL7QEN.js.map +1 -0
  21. package/dist/chunk-QMC6BWGK.js +203 -0
  22. package/dist/chunk-QMC6BWGK.js.map +1 -0
  23. package/dist/chunk-UXKPPF4G.js +41 -0
  24. package/dist/chunk-UXKPPF4G.js.map +1 -0
  25. package/dist/express.cjs +316 -0
  26. package/dist/express.cjs.map +1 -0
  27. package/dist/express.d.cts +10 -0
  28. package/dist/express.d.ts +10 -0
  29. package/dist/express.js +12 -0
  30. package/dist/express.js.map +1 -0
  31. package/dist/fastify.cjs +309 -0
  32. package/dist/fastify.cjs.map +1 -0
  33. package/dist/fastify.d.cts +10 -0
  34. package/dist/fastify.d.ts +10 -0
  35. package/dist/fastify.js +10 -0
  36. package/dist/fastify.js.map +1 -0
  37. package/dist/http-BI6ARZlD.d.ts +25 -0
  38. package/dist/http-BXAiU-rN.d.cts +25 -0
  39. package/dist/index.cjs +677 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.cts +69 -0
  42. package/dist/index.d.ts +69 -0
  43. package/dist/index.js +214 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/logger-CFMTzjsm.d.cts +47 -0
  46. package/dist/logger-CFMTzjsm.d.ts +47 -0
  47. package/dist/nest.cjs +458 -0
  48. package/dist/nest.cjs.map +1 -0
  49. package/dist/nest.d.cts +41 -0
  50. package/dist/nest.d.ts +41 -0
  51. package/dist/nest.js +107 -0
  52. package/dist/nest.js.map +1 -0
  53. package/dist/next-edge.cjs +44 -0
  54. package/dist/next-edge.cjs.map +1 -0
  55. package/dist/next-edge.d.cts +7 -0
  56. package/dist/next-edge.d.ts +7 -0
  57. package/dist/next-edge.js +16 -0
  58. package/dist/next-edge.js.map +1 -0
  59. package/dist/next.cjs +423 -0
  60. package/dist/next.cjs.map +1 -0
  61. package/dist/next.d.cts +36 -0
  62. package/dist/next.d.ts +36 -0
  63. package/dist/next.js +138 -0
  64. package/dist/next.js.map +1 -0
  65. package/dist/prisma.cjs +63 -0
  66. package/dist/prisma.cjs.map +1 -0
  67. package/dist/prisma.d.cts +31 -0
  68. package/dist/prisma.d.ts +31 -0
  69. package/dist/prisma.js +38 -0
  70. package/dist/prisma.js.map +1 -0
  71. package/package.json +138 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ Added:
6
+ - `@tanngo11/log/prisma`: `prismaLogging(log)` Prisma Client extension (Prisma 5 and 6). Logs
7
+ `db.slow_query` with model, operation, rows and duration, and optionally `db.error`. It never
8
+ logs SQL or arguments. Tested against a real Prisma client on SQLite.
9
+ - Custom redaction: `redactKeys` accepts RegExp, `redactValues` masks value patterns in every
10
+ string, and the `redact(key, value)` hook can replace or drop fields. Opt-in
11
+ `REDACT_PATTERNS`: `phoneVN`, `paymentCard`, `ipv4`.
12
+ - NestJS on the Fastify adapter. `setupNestLogging(app, log)` configures either adapter in one
13
+ call.
14
+
15
+ ## 0.2.0
16
+
17
+ Breaking:
18
+ - `installProcessHandlers` no longer listens to SIGTERM/SIGINT. Call `logShutdown(log)` from your
19
+ own shutdown handler to log `app.stopping`. The old behavior could kill an app in the middle of
20
+ its graceful shutdown.
21
+ - On a crash the exit is deferred (`exitDelayMs`, default 1000 ms), so other crash handlers
22
+ (Sentry, APM) still run.
23
+ - When the route is unknown, the `http.request` message is `"<METHOD> <status>"` (no raw path).
24
+
25
+ Fixes:
26
+ - Secrets were not redacted inside arrays, objects nested deeper than 3 levels, or non-error `err`
27
+ values. Camel-case keys were not redacted when `snakeCase: false`.
28
+ - Many secret key names were missing: connection strings, DSNs, JWTs, session ids, credentials.
29
+ Keys are now matched on any snake_case part.
30
+ - Credentials in URLs, Bearer tokens, JWTs and emails are masked inside any string.
31
+ - `withLogging` accepts handlers typed with `NextRequest`. Non-object throws are logged once.
32
+ - Express keeps the mount prefix in `http_route` when a sub-router fails.
33
+ - `addContext` / `recordError` inside nested contexts reach the request's summary line.
34
+ - Fastify logs `499` when the client aborts.
35
+ - `NestLogger` keeps extra arguments (objects become fields, others go to `nest_args`).
36
+ - `loggedProcessor` forwards all BullMQ arguments and logs `UnrecoverableError` at error level.
37
+
38
+ Added:
39
+ - `patchConsole(log)`: routes `console.*` through the logger.
40
+ - `withAction`: wraps a Next.js server action (`action.completed` / `action.failed`).
41
+ - `jobData(data)`: adds the current `request_id` to a job payload.
42
+ - `logShutdown(log)`.
43
+
44
+ ## 0.1.0
45
+
46
+ - Core logger implementing log contract v1: normalization, redaction, size limits, async context.
47
+ - Adapters: Express, Fastify, NestJS, Next.js (route handlers, onRequestError, edge request id), BullMQ.
48
+ - Helpers: createFetch (http.outbound), runJob (job.*), process lifecycle (app.*).
package/CONTRACT.md ADDED
@@ -0,0 +1,135 @@
1
+ # Log contract v1
2
+
3
+ The rules every server-side service follows so its logs read the same way to people and to AI
4
+ agents. `@tanngo11/log` implements them for Node. Services in other languages can follow them by
5
+ hand.
6
+
7
+ Assumed pipeline: container stdout → Vector (adds metadata, redacts secrets) → OpenObserve (one
8
+ stream, nested fields flattened with `_`) → MCP for AI agents.
9
+
10
+ ## 1. Output format
11
+
12
+ 1. **One JSON object per line on stdout or stderr.** Do not write log files, and do not send logs
13
+ over HTTP from the app. Logs must pass through the redaction step on the host.
14
+ 2. **No multi-line output.** The stack trace goes in `err_stack` on the same line.
15
+ 3. **Flat snake_case fields.** Values are only strings, numbers or booleans. Group related fields
16
+ with a prefix (`http_`, `err_`, `db_`, `job_`). Turn arrays into counts or ids.
17
+ 4. **Do not rely on an app timestamp.** The collector sets the time.
18
+ 5. **Reserved names.** The collector sets or overwrites these, so apps never use them:
19
+ `project`, `service`, `vps`, `version`, `container_name`, `container_id`, `image`, `stream`,
20
+ `_timestamp`, `label`, `host`, `source_type`, `msg`, `lvl`, `severity`. To rename a
21
+ project or service, use the Docker labels `log.project` / `log.service`.
22
+ 6. **Size limits:**
23
+ - the whole line ≤ 16 KB, because Docker splits longer lines and the JSON breaks;
24
+ - `message` ≤ 500 characters;
25
+ - `err_stack` ≤ 4000 characters;
26
+ - other strings ≤ 1000 characters.
27
+
28
+ ## 2. Standard fields
29
+
30
+ | Group | Fields |
31
+ |---|---|
32
+ | Core | `level` (`trace\|debug\|info\|warn\|error\|fatal`), `message`, `event` (required for warn/error/fatal) |
33
+ | Correlation | `request_id`, `trace_id`, `span_id`, `job_name`, `job_id`, `job_attempt`, `user_id` (internal id, never an email), `tenant_id` |
34
+ | Inbound HTTP | `http_method`, `http_route` (template, e.g. `/orders/:id`), `http_path` (no query string), `http_status`, `duration_ms` |
35
+ | Outbound HTTP | `peer_service`, `http_method`, `http_url` (no query string or credentials), `http_status`, `duration_ms` |
36
+ | Error | `err_type`, `err_message`, `err_code` (string), `err_stack`, `err_cause` (cause chain joined with ` <- `, max 5) |
37
+ | Database | `db_system`, `db_operation`, `db_table`, `db_rows`, `duration_ms`, `db_statement` (parameterized only) |
38
+
39
+ Naming rules for domain fields:
40
+ - ids: `<entity>_id`;
41
+ - durations: `*_ms`;
42
+ - sizes: `*_bytes`;
43
+ - counts: `*_count`;
44
+ - booleans: `is_*` / `has_*`;
45
+ - money: `amount_minor` (integer, smallest unit) + `currency`.
46
+
47
+ A concept has one name in every service.
48
+
49
+ ## 3. Levels
50
+
51
+ | Level | Use for |
52
+ |---|---|
53
+ | `fatal` | The process cannot continue and is about to exit. |
54
+ | `error` | An operation failed and someone must investigate: a 5xx response, or a job that has used up its retries. |
55
+ | `warn` | Something unusual that was handled, or a client error: 4xx, retry, fallback, a slow query or call. |
56
+ | `info` | Lifecycle and business events: request summaries, start/stop, a job done, an order created. |
57
+ | `debug` / `trace` | Diagnostics. Off in production. |
58
+
59
+ - Invalid user input is never `error`.
60
+ - `LOG_LEVEL` controls the level and defaults to `info`.
61
+
62
+ ## 4. `event` and `message`
63
+
64
+ - `event` names are `<domain>.<action>`: lowercase, past tense for things that happened, for
65
+ example `order.created` or `payment.failed`.
66
+ - Once an `event` name is used, never rename it.
67
+ - `message` is a **constant** English phrase. Variables go in fields, not in the text.
68
+ - Two exceptions:
69
+ - `http.request` uses `"<METHOD> <route template> <status>"`, or `"<METHOD> <status>"` when the
70
+ route is unknown. It never contains the raw path.
71
+ - `console` lines (legacy `console.*` calls routed through the logger) keep the original text.
72
+
73
+ System events shared by all services:
74
+
75
+ | Event | Level | When |
76
+ |---|---|---|
77
+ | `app.started` | info | ready to serve (with `runtime_version`, `log_level`, `port`) |
78
+ | `app.stopping` | info | the app starts its own graceful shutdown |
79
+ | `app.crashed` | fatal | uncaught error, process exits |
80
+ | `app.unhandled_error` | error | uncaught error, process keeps running |
81
+ | `config.invalid` | fatal | missing or invalid config at startup (never log the value) |
82
+ | `http.request` | by status | one summary line per inbound request |
83
+ | `http.outbound` | warn | an outbound call failed (network error or 5xx) or was slow (≥ 1 s) |
84
+ | `db.slow_query` | warn | a query took longer than the threshold (default 500 ms) |
85
+ | `db.error` | error | a database error that no upper layer handles |
86
+ | `job.started` / `job.completed` | info | job lifecycle |
87
+ | `job.failed` | warn if retries remain, error on the final attempt or a non-retryable error | job error |
88
+ | `action.completed` | info | a server action finished, including a redirect or not-found (with `action_name`, `duration_ms`) |
89
+ | `action.failed` | error | a server action threw |
90
+ | `console` | by console method | a legacy `console.*` call (with `console_method`) |
91
+
92
+ ## 5. What to log
93
+
94
+ **Required:**
95
+ - `app.started` and `app.stopping`.
96
+ - One `http.request` line per request: 5xx at `error`, 4xx at `warn`, otherwise `info`. Skip
97
+ health checks, static assets and `OPTIONS`.
98
+ - **Log each error once, at the boundary.** Code that rethrows does not log. A failed request
99
+ produces one `error` line: its `http.request` line, carrying `err_*`.
100
+ - `job.completed` / `job.failed` with `job_name`, `job_id` and `duration_ms`.
101
+ - Outbound calls that fail or are slow.
102
+ - Important business events, with entity ids.
103
+
104
+ **Never log:**
105
+ - secrets: passwords, tokens, API keys, cookies, `Authorization` headers, connection strings;
106
+ - request or response bodies;
107
+ - query strings;
108
+ - personal data: email, phone, name, address, ID numbers, IP address, full user agent;
109
+ - card or bank numbers;
110
+ - SQL with its parameter values;
111
+ - dumps of environment variables.
112
+
113
+ ## 6. Correlation
114
+
115
+ - Reuse an incoming `x-request-id` if it is valid (≤ 64 characters, `[A-Za-z0-9._-]`).
116
+ Otherwise generate a UUID v4. Return it as a response header.
117
+ - Parse W3C `traceparent` into `trace_id` and `span_id`. Do not invent trace ids.
118
+ - Forward `x-request-id` and `traceparent` to services you own, never to third parties.
119
+ - A job enqueued from a request carries that request's `request_id` in its payload.
120
+ - Every log line inside a request or job gets these fields from async context, without being
121
+ passed by hand.
122
+
123
+ ## 7. Compliance levels
124
+
125
+ | Level | Requires |
126
+ |---|---|
127
+ | L1 | JSON lines, a textual `level`, `message`, no reserved names, no secrets or personal data |
128
+ | L2 | L1 + `request_id` on every line of a request, the `http.request` line, `err_*` on failures, `event` on warn/error/fatal |
129
+ | L3 | L2 + `http.outbound`, `job.*`, header propagation, constant messages |
130
+
131
+ ## 8. Versioning
132
+
133
+ - Adding fields or events keeps version v1.
134
+ - Renaming a field or changing its meaning needs a v2, with migration notes, and a major release
135
+ of the library.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tan Ngo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # @tanngo11/log
2
+
3
+ Structured JSON logging for Node services. It writes one JSON line per log to stdout, following
4
+ [log contract v1](CONTRACT.md),
5
+ so every service is searchable the same way in OpenObserve and readable by AI agents through MCP.
6
+
7
+ - Zero runtime dependencies, Node ≥ 20, ESM and CommonJS.
8
+ - Request context (`request_id`, `trace_id`, `user_id`...) on every line via `AsyncLocalStorage`.
9
+ - One `http.request` summary line per request, carrying the error when the request fails.
10
+ - Adapters for Express, Fastify, NestJS, Next.js (App Router) and BullMQ.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i @tanngo11/log
16
+ # before the npm package is published:
17
+ npm i github:TanNgo11/node-log#v0.1.0
18
+ ```
19
+
20
+ ## Core
21
+
22
+ ```ts
23
+ // src/lib/log.ts
24
+ import { createLogger } from "@tanngo11/log";
25
+
26
+ export const log = createLogger();
27
+ ```
28
+
29
+ ```ts
30
+ log.info("order created", { event: "order.created", order_id: order.id, items_count: 3 });
31
+ log.warn("shipping quote failed, using flat rate", { event: "shipping_quote.fallback_used", err });
32
+ log.error("create order failed", { event: "order.create_failed", err });
33
+ ```
34
+
35
+ Every level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`) takes `(message, fields?)`.
36
+
37
+ - Keep `message` constant. Put variable values in fields.
38
+ - `event` is required for `warn`, `error` and `fatal`.
39
+ - Pass errors as `err`. They become `err_type`, `err_message`, `err_code`, `err_stack` and
40
+ `err_cause`.
41
+
42
+ | Env | Default | Meaning |
43
+ |---|---|---|
44
+ | `LOG_LEVEL` | `info` | minimum level written |
45
+ | `LOG_FORMAT` | `json` | `pretty` prints readable lines for local development only |
46
+
47
+ `createLogger(options)` accepts:
48
+ - `level`, `format`;
49
+ - `base`: fields added to every line;
50
+ - `redactKeys`, `redactValues`, `redact`: custom redaction (see below);
51
+ - `snakeCase`: default `true`;
52
+ - `write`: replaces stdout, for tests.
53
+
54
+ ### Custom redaction
55
+
56
+ ```ts
57
+ import { createLogger, REDACT_PATTERNS } from "@tanngo11/log";
58
+
59
+ export const log = createLogger({
60
+ // extra key names, or RegExp tested on the snake_case key
61
+ redactKeys: ["tax_code", /^national_id/],
62
+ // value patterns masked in every string, including messages
63
+ redactValues: [REDACT_PATTERNS.phoneVN, REDACT_PATTERNS.paymentCard],
64
+ // last step for each field; return undefined to drop the field
65
+ redact: (key, value) => (key === "customer_name" && typeof value === "string" ? `${value[0]}***` : value),
66
+ });
67
+ ```
68
+
69
+ - `REDACT_PATTERNS` currently contains `phoneVN`, `paymentCard` and `ipv4`.
70
+ - These patterns are opt-in because they can match ordinary numbers. `paymentCard` does not
71
+ match 13-digit millisecond timestamps.
72
+ - The `redact` hook never receives `level`, `message` or `event`.
73
+ - If the hook throws, that line becomes a `log.serialize_failed` line instead.
74
+
75
+ ### Context
76
+
77
+ ```ts
78
+ import { addContext, recordError, withContext } from "@tanngo11/log";
79
+
80
+ await withContext({ tenant_id }, () => handle()); // every log inside carries tenant_id
81
+ addContext({ user_id: user.id }); // after authentication, inside a request
82
+ recordError(err); // attach err to the request's http.request line
83
+ ```
84
+
85
+ `log.child({ job_name: "sync_stock" })` returns a logger that always adds those fields.
86
+
87
+ ### What the logger normalizes
88
+
89
+ Applied in this order:
90
+ 1. Merges fields: `base`, then context, then `child`, then the call. Later sources win.
91
+ 2. Flattens nested objects with `_`, up to three key segments (`order.customer.id` becomes
92
+ `order_customer_id`). Arrays and deeper objects become JSON strings.
93
+ 3. Converts camelCase keys to snake_case (`orderId` becomes `order_id`). Pass `snakeCase: false`
94
+ to turn this off.
95
+ 4. Prefixes keys that Vector overwrites with `app_`: `project`, `service`, `version`, `host`,
96
+ `msg`, `level`, `message`...
97
+ 5. Replaces the values of sensitive keys with `[REDACTED]`, at any depth, including inside
98
+ arrays.
99
+ - A key counts as sensitive when any snake_case part of it names a secret: `password`,
100
+ `secret`, `token`, `api_key`, `authorization`, `cookie`, `session_id`, `credentials`, `dsn`,
101
+ `connection_string`, `jwt`...
102
+ - So `stripeSecretKey` and `x-api-key` are redacted.
103
+ - Keys that only describe a secret stay readable: `token_count`, `token_type`,
104
+ `password_reset_at`.
105
+ - `redactKeys` adds more key names.
106
+ 6. Masks secrets inside any string value or message:
107
+ - credentials in URLs (`postgres://[REDACTED]@db`);
108
+ - `Bearer` tokens;
109
+ - JWTs;
110
+ - email addresses (`***@example.com`).
111
+ 7. Strips the query string, hash and credentials from `http_path`, `http_url` and every
112
+ `*_url` field.
113
+ 8. Truncates `message` to 500 chars, `err_stack` to 4000 and other strings to 1000. Lines over
114
+ 16000 bytes keep only core fields and get `log_truncated: true`.
115
+
116
+ The logger never throws into your code.
117
+
118
+ ### Outbound calls, jobs, lifecycle
119
+
120
+ ```ts
121
+ import { createFetch, installProcessHandlers, jobData, logShutdown, logStartup, runJob } from "@tanngo11/log";
122
+
123
+ const ghn = createFetch(log, { peer_service: "ghn" }); // logs http.outbound on 5xx, network error, or > 1s
124
+ const api = createFetch(log, { peer_service: "backend-api", internal: true }); // also forwards x-request-id / traceparent
125
+
126
+ await runJob(log, { job_name: "sync_stock", job_id: id }, () => syncStock()); // job.completed / job.failed
127
+ await queue.add("send_invoice", jobData({ invoice_id })); // carries the current request_id into the job
128
+
129
+ installProcessHandlers(log); // app.crashed on uncaught errors, then exit(1) after 1s so Sentry & co. can flush
130
+ logStartup(log, { port: 3000 }); // app.started
131
+
132
+ process.once("SIGTERM", async () => {
133
+ logShutdown(log, { signal: "SIGTERM" }); // app.stopping; the library never handles signals itself
134
+ await server.close();
135
+ process.exit(0);
136
+ });
137
+ ```
138
+
139
+ ### Legacy `console.*` calls
140
+
141
+ ```ts
142
+ import { patchConsole } from "@tanngo11/log";
143
+
144
+ patchConsole(log); // console.log/info → info, warn → warn, error → error, debug/trace → debug
145
+ ```
146
+
147
+ Every `console.*` call becomes a JSON line with `event: "console"` and the request context. This
148
+ gets an old codebase to contract level L1 in one line. Framework output that goes through
149
+ `console`, such as Next.js error printing, is covered too. The function returns `restore()`.
150
+
151
+ ## Express
152
+
153
+ ```ts
154
+ import { errorRecorder, requestLogger } from "@tanngo11/log/express";
155
+
156
+ app.use(requestLogger(log)); // before routes
157
+ // ... routes ...
158
+ app.use(errorRecorder()); // after routes, before your error handler
159
+ app.use(yourErrorHandler);
160
+ ```
161
+
162
+ ## Fastify
163
+
164
+ ```ts
165
+ import { fastifyLogger } from "@tanngo11/log/fastify";
166
+
167
+ const app = Fastify({ logger: false });
168
+ await app.register(fastifyLogger, { log });
169
+ ```
170
+
171
+ ## NestJS (Express or Fastify adapter)
172
+
173
+ ```ts
174
+ import { NestLogger, setupNestLogging } from "@tanngo11/log/nest";
175
+
176
+ const app = await NestFactory.create(AppModule, { logger: new NestLogger(log) });
177
+ // or: NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: new NestLogger(log) })
178
+ await setupNestLogging(app, log); // before app.listen()
179
+ ```
180
+
181
+ `setupNestLogging` detects the adapter:
182
+ - Express gets the `requestLogger` middleware.
183
+ - Fastify gets the `fastifyLogger` plugin.
184
+
185
+ Both get `ErrorRecorderInterceptor`. For manual setup, `requestLogger`, `fastifyLogger` and
186
+ `ErrorRecorderInterceptor` are exported too.
187
+
188
+ Nest's own logs become JSON with `nest_context`. Extra arguments are kept: objects become fields,
189
+ other values go to `nest_args`. Unhandled exceptions are written once, on the
190
+ `http.request` line, not again by Nest's `ExceptionsHandler`. Requires `rxjs` (already a Nest
191
+ dependency).
192
+
193
+ ## Next.js (App Router)
194
+
195
+ Route handlers:
196
+
197
+ ```ts
198
+ // app/api/orders/[id]/route.ts
199
+ import { withLogging } from "@tanngo11/log/next";
200
+ import { log } from "@/lib/log";
201
+
202
+ export const GET = withLogging(log, async (request: NextRequest, { params }) => { /* ... */ }, { route: "/api/orders/[id]" });
203
+ ```
204
+
205
+ Server actions:
206
+
207
+ ```ts
208
+ "use server";
209
+ import { withAction } from "@tanngo11/log/next";
210
+
211
+ export const saveOrder = withAction(log, "save_order", async (input: OrderInput) => { /* ... */ });
212
+ // action.completed (info) or action.failed (error, once); redirect()/notFound() count as completed
213
+ ```
214
+
215
+ Server components and anything not wrapped:
216
+
217
+ ```ts
218
+ // instrumentation.ts
219
+ import { patchConsole } from "@tanngo11/log";
220
+ import { nextOnRequestError, registerNext } from "@tanngo11/log/next";
221
+ import { log } from "@/lib/log";
222
+
223
+ export function register() {
224
+ registerNext(log); // app.started + crash logging (Next keeps serving, so no exit)
225
+ patchConsole(log); // Next's own error output and leftover console.* become JSON
226
+ }
227
+ export const onRequestError = nextOnRequestError(log);
228
+ ```
229
+
230
+ Optional: give every request an `x-request-id` in `middleware.ts` (edge runtime):
231
+
232
+ ```ts
233
+ import { withRequestId } from "@tanngo11/log/next/edge";
234
+
235
+ export function middleware(request: NextRequest) {
236
+ return NextResponse.next({ request: { headers: withRequestId(request) } });
237
+ }
238
+ ```
239
+
240
+ Notes:
241
+ - `redirect()` and `notFound()` are logged with their real status (307, 404), not as errors.
242
+ - Errors already logged by `withLogging` are not logged again by `onRequestError`.
243
+ - If the edge build complains about the import in `instrumentation.ts`, import inside
244
+ `register()`: `if (process.env.NEXT_RUNTIME === "nodejs") { const { registerNext } = await import("@tanngo11/log/next"); ... }`.
245
+
246
+ ## BullMQ
247
+
248
+ ```ts
249
+ import { loggedProcessor } from "@tanngo11/log/bullmq";
250
+
251
+ new Worker("invoices", loggedProcessor(log, "send_invoice_email", async (job) => { /* ... */ }));
252
+ ```
253
+
254
+ - A failure on the final attempt, or an `UnrecoverableError`, is logged at `error`. Earlier
255
+ attempts fail at `warn`.
256
+ - When enqueueing from a request, use `queue.add(name, jobData(data))`. The job's logs then carry
257
+ that request's `request_id`.
258
+
259
+ ## Prisma (5 and 6)
260
+
261
+ ```ts
262
+ import { prismaLogging } from "@tanngo11/log/prisma";
263
+
264
+ export const prisma = new PrismaClient().$extends(prismaLogging(log, { db_system: "postgres" }));
265
+ ```
266
+
267
+ - Logs `db.slow_query` (`warn`) for queries that take 500 ms or more (`slowMs`). Fields:
268
+ `db_table` (the model), `db_operation` (`findMany`, `$queryRaw`...), `db_rows`, `duration_ms`.
269
+ - Never logs SQL text or query arguments.
270
+ - Failed queries are not logged by default. The error already reaches the request's
271
+ `http.request` line with `err_code` (`P2002`...), and the contract logs each error once. Pass
272
+ `logErrors: true` to also get a `db.error` line.
273
+
274
+ ## Migrating from the shared winston logger
275
+
276
+ For repos using the old shared winston logger (S3 transport, nested metadata):
277
+
278
+ 1. Replace `src/lib/logger/index.ts` with `export const log = createLogger(); export const logger = log;`.
279
+ 2. Rewrite each `log.error(msg, err, meta)` call as `log.error(msg, { ...meta, err })`.
280
+ 3. In `withErrorHandler`, replace the `logger.error("API Error", ...)` call with
281
+ `recordError(error)`. Then wrap routes with `withLogging`.
282
+ 4. Remove the S3 transport. OpenObserve already stores the logs.
283
+ 5. Call `patchConsole(log)` once at startup. Remaining `console.*` calls are then JSON too.
284
+
285
+ ## Rules the library cannot enforce
286
+
287
+ - Use a constant `message` and stable `event` names.
288
+ - Never log request or response bodies, query strings, email addresses, phone numbers, tokens or
289
+ card data.
290
+
291
+ See sections 4 and 5 of the contract.
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/bullmq.ts
21
+ var bullmq_exports = {};
22
+ __export(bullmq_exports, {
23
+ loggedProcessor: () => loggedProcessor
24
+ });
25
+ module.exports = __toCommonJS(bullmq_exports);
26
+
27
+ // src/context.ts
28
+ var import_node_async_hooks = require("async_hooks");
29
+
30
+ // src/global.ts
31
+ function globalSingleton(name, make) {
32
+ const key = /* @__PURE__ */ Symbol.for(`@tanngo11/log.${name}`);
33
+ const g = globalThis;
34
+ let value = g[key];
35
+ if (value === void 0) {
36
+ value = make();
37
+ g[key] = value;
38
+ }
39
+ return value;
40
+ }
41
+
42
+ // src/context.ts
43
+ var als = globalSingleton("als", () => new import_node_async_hooks.AsyncLocalStorage());
44
+ function runWithStore(fields, fn) {
45
+ const parent = als.getStore();
46
+ const store = { fields: { ...parent?.fields, ...fields }, parent };
47
+ return als.run(store, () => fn(store));
48
+ }
49
+ function withContext(fields, fn) {
50
+ return runWithStore(fields, () => fn());
51
+ }
52
+
53
+ // src/error.ts
54
+ var logged = globalSingleton("logged", () => /* @__PURE__ */ new WeakSet());
55
+ function markLogged(err) {
56
+ if (typeof err === "object" && err !== null) logged.add(err);
57
+ }
58
+
59
+ // src/job.ts
60
+ async function runJob(log, info, fn) {
61
+ const ctx = { ...info.fields, job_name: info.job_name };
62
+ if (info.job_id !== void 0) ctx.job_id = String(info.job_id);
63
+ if (info.job_attempt !== void 0) ctx.job_attempt = info.job_attempt;
64
+ if (info.request_id !== void 0) ctx.request_id = info.request_id;
65
+ return withContext(ctx, async () => {
66
+ const start = performance.now();
67
+ try {
68
+ const result = await fn();
69
+ log.info("job completed", { event: "job.completed", duration_ms: Math.round(performance.now() - start) });
70
+ return result;
71
+ } catch (err) {
72
+ const final = typeof info.final_attempt === "function" ? info.final_attempt(err) : info.final_attempt ?? true;
73
+ log[final ? "error" : "warn"]("job failed", {
74
+ event: "job.failed",
75
+ duration_ms: Math.round(performance.now() - start),
76
+ err
77
+ });
78
+ markLogged(err);
79
+ throw err;
80
+ }
81
+ });
82
+ }
83
+
84
+ // src/bullmq.ts
85
+ function loggedProcessor(log, name, processor) {
86
+ return (job, ...rest) => {
87
+ const attempts = job.opts?.attempts ?? 1;
88
+ const attempt = job.attemptsMade + 1;
89
+ const requestId = job.data?.request_id;
90
+ return runJob(
91
+ log,
92
+ {
93
+ job_name: name,
94
+ job_id: job.id,
95
+ job_attempt: attempt,
96
+ // UnrecoverableError skips the remaining retries.
97
+ final_attempt: (err) => attempt >= attempts || err?.name === "UnrecoverableError",
98
+ request_id: typeof requestId === "string" ? requestId : void 0
99
+ },
100
+ () => processor(job, ...rest)
101
+ );
102
+ };
103
+ }
104
+ // Annotate the CommonJS export names for ESM import in node:
105
+ 0 && (module.exports = {
106
+ loggedProcessor
107
+ });
108
+ //# sourceMappingURL=bullmq.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bullmq.ts","../src/context.ts","../src/global.ts","../src/error.ts","../src/job.ts"],"sourcesContent":["import { runJob } from \"./job\";\nimport type { Logger } from \"./logger\";\n\n/** Minimal shape of a BullMQ Job; avoids depending on bullmq. */\nexport interface BullJobLike {\n id?: string;\n attemptsMade: number;\n opts?: { attempts?: number };\n data?: unknown;\n}\n\nexport function loggedProcessor<J extends BullJobLike, R, A extends unknown[] = [token?: string, signal?: AbortSignal]>(\n log: Logger,\n name: string,\n processor: (job: J, ...rest: A) => Promise<R>,\n): (job: J, ...rest: A) => Promise<R> {\n return (job, ...rest) => {\n const attempts = job.opts?.attempts ?? 1;\n const attempt = job.attemptsMade + 1;\n const requestId = (job.data as { request_id?: unknown } | undefined)?.request_id;\n return runJob(\n log,\n {\n job_name: name,\n job_id: job.id,\n job_attempt: attempt,\n // UnrecoverableError skips the remaining retries.\n final_attempt: (err) => attempt >= attempts || (err as { name?: unknown } | null)?.name === \"UnrecoverableError\",\n request_id: typeof requestId === \"string\" ? requestId : undefined,\n },\n () => processor(job, ...rest),\n );\n };\n}\n","import { AsyncLocalStorage } from \"node:async_hooks\";\nimport { globalSingleton } from \"./global\";\nimport type { Fields } from \"./normalize\";\n\nexport interface ContextStore {\n fields: Fields;\n error?: unknown;\n /** Route template captured while the router still knew it (Express error path). */\n route?: string;\n parent?: ContextStore;\n}\n\nconst als = globalSingleton(\"als\", () => new AsyncLocalStorage<ContextStore>());\n\nexport const STORE_KEY: symbol = Symbol.for(\"@tanngo11/log.store\");\n\nexport function runWithStore<T>(fields: Fields, fn: (store: ContextStore) => T): T {\n const parent = als.getStore();\n const store: ContextStore = { fields: { ...parent?.fields, ...fields }, parent };\n return als.run(store, () => fn(store));\n}\n\nexport function withContext<T>(fields: Fields, fn: () => T): T {\n return runWithStore(fields, () => fn());\n}\n\nexport function currentStore(): ContextStore | undefined {\n return als.getStore();\n}\n\nexport function getContext(): Fields {\n return als.getStore()?.fields ?? {};\n}\n\n// Applies to the current context and every enclosing one, so a field added inside a nested\n// withContext/runJob still reaches the request's http.request line.\nexport function addContext(fields: Fields): void {\n for (let s = als.getStore(); s; s = s.parent) Object.assign(s.fields, fields);\n}\n\n// Sets the error on the store and on enclosing stores that have none yet.\nexport function recordErrorIn(store: ContextStore, err: unknown): void {\n store.error = err;\n for (let s = store.parent; s; s = s.parent) if (s.error === undefined) s.error = err;\n}\n\n// Marks the error that the current request/job summary line should carry. Does not write a line.\nexport function recordError(err: unknown): void {\n const store = als.getStore();\n if (store) recordErrorIn(store, err);\n}\n\nexport function attachStore(target: object, store: ContextStore): void {\n (target as Record<symbol, ContextStore>)[STORE_KEY] = store;\n}\n\nexport function storeOf(target: unknown): ContextStore | undefined {\n if (typeof target !== \"object\" || target === null) return undefined;\n return (target as Record<symbol, ContextStore | undefined>)[STORE_KEY];\n}\n","// Singletons shared by every copy of this package loaded in the process (ESM and CJS builds,\n// or duplicated installs), so context and dedupe state never split.\nexport function globalSingleton<T>(name: string, make: () => T): T {\n const key = Symbol.for(`@tanngo11/log.${name}`);\n const g = globalThis as unknown as Record<symbol, T | undefined>;\n let value = g[key];\n if (value === undefined) {\n value = make();\n g[key] = value;\n }\n return value;\n}\n","import { globalSingleton } from \"./global\";\nimport { defaultRedactor } from \"./redact\";\n\nexport interface ErrFields {\n err_type?: string;\n err_message?: string;\n err_code?: string;\n err_stack?: string;\n err_cause?: string;\n}\n\nconst MAX_CAUSE_DEPTH = 5;\n\nexport function isErrorLike(v: unknown): v is Error {\n if (v instanceof Error) return true;\n return (\n typeof v === \"object\" &&\n v !== null &&\n typeof (v as { message?: unknown }).message === \"string\" &&\n (\"stack\" in v || \"name\" in v)\n );\n}\n\nfunction errorType(e: Error): string {\n if (e.name && e.name !== \"Error\") return e.name;\n const ctor = (e as { constructor?: { name?: string } }).constructor?.name;\n return ctor && ctor !== \"Object\" ? ctor : \"Error\";\n}\n\nexport function serializeError(err: unknown): ErrFields {\n if (!isErrorLike(err)) {\n if (typeof err === \"string\") return { err_type: \"string\", err_message: err };\n return { err_type: typeof err, err_message: defaultRedactor.json(err) };\n }\n const e = err as Error & { code?: unknown; cause?: unknown };\n const out: ErrFields = { err_type: errorType(e), err_message: String(e.message ?? \"\") };\n if (e.code !== undefined && e.code !== null) out.err_code = String(e.code);\n if (typeof e.stack === \"string\") out.err_stack = e.stack;\n\n const causes: string[] = [];\n let cause = e.cause;\n while (cause !== undefined && cause !== null && causes.length < MAX_CAUSE_DEPTH) {\n if (isErrorLike(cause)) {\n causes.push(`${errorType(cause)}: ${cause.message}`);\n cause = (cause as { cause?: unknown }).cause;\n } else {\n causes.push(defaultRedactor.json(cause));\n cause = undefined;\n }\n }\n if (causes.length > 0) out.err_cause = causes.join(\" <- \");\n return out;\n}\n\nconst logged = globalSingleton(\"logged\", () => new WeakSet<object>());\n\n// Marks an error as already written, so a later boundary (e.g. Next's onRequestError)\n// does not log it a second time.\nexport function markLogged(err: unknown): void {\n if (typeof err === \"object\" && err !== null) logged.add(err);\n}\n\nexport function isLogged(err: unknown): boolean {\n return typeof err === \"object\" && err !== null && logged.has(err);\n}\n","import { getContext, withContext } from \"./context\";\nimport { markLogged } from \"./error\";\nimport type { Logger } from \"./logger\";\nimport type { Fields } from \"./normalize\";\n\nexport interface JobInfo {\n job_name: string;\n job_id?: string | number;\n job_attempt?: number;\n /** No retries left after this attempt (or decided per error). Defaults to true when unknown. */\n final_attempt?: boolean | ((err: unknown) => boolean);\n request_id?: string;\n fields?: Fields;\n}\n\nexport async function runJob<T>(log: Logger, info: JobInfo, fn: () => T | Promise<T>): Promise<T> {\n const ctx: Fields = { ...info.fields, job_name: info.job_name };\n if (info.job_id !== undefined) ctx.job_id = String(info.job_id);\n if (info.job_attempt !== undefined) ctx.job_attempt = info.job_attempt;\n if (info.request_id !== undefined) ctx.request_id = info.request_id;\n\n return withContext(ctx, async () => {\n const start = performance.now();\n try {\n const result = await fn();\n log.info(\"job completed\", { event: \"job.completed\", duration_ms: Math.round(performance.now() - start) });\n return result;\n } catch (err) {\n const final = typeof info.final_attempt === \"function\" ? info.final_attempt(err) : (info.final_attempt ?? true);\n log[final ? \"error\" : \"warn\"](\"job failed\", {\n event: \"job.failed\",\n duration_ms: Math.round(performance.now() - start),\n err,\n });\n markLogged(err);\n throw err;\n }\n });\n}\n\n/** Adds the current request_id to a job payload, so the job's logs link back to the request. */\nexport function jobData<T extends Record<string, unknown>>(data: T): T & { request_id?: string } {\n const requestId = getContext().request_id;\n if (typeof requestId !== \"string\" || data.request_id !== undefined) return data;\n return { ...data, request_id: requestId };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,8BAAkC;;;ACE3B,SAAS,gBAAmB,MAAc,MAAkB;AACjE,QAAM,MAAM,uBAAO,IAAI,iBAAiB,IAAI,EAAE;AAC9C,QAAM,IAAI;AACV,MAAI,QAAQ,EAAE,GAAG;AACjB,MAAI,UAAU,QAAW;AACvB,YAAQ,KAAK;AACb,MAAE,GAAG,IAAI;AAAA,EACX;AACA,SAAO;AACT;;;ADCA,IAAM,MAAM,gBAAgB,OAAO,MAAM,IAAI,0CAAgC,CAAC;AAIvE,SAAS,aAAgB,QAAgB,IAAmC;AACjF,QAAM,SAAS,IAAI,SAAS;AAC5B,QAAM,QAAsB,EAAE,QAAQ,EAAE,GAAG,QAAQ,QAAQ,GAAG,OAAO,GAAG,OAAO;AAC/E,SAAO,IAAI,IAAI,OAAO,MAAM,GAAG,KAAK,CAAC;AACvC;AAEO,SAAS,YAAe,QAAgB,IAAgB;AAC7D,SAAO,aAAa,QAAQ,MAAM,GAAG,CAAC;AACxC;;;AE8BA,IAAM,SAAS,gBAAgB,UAAU,MAAM,oBAAI,QAAgB,CAAC;AAI7D,SAAS,WAAW,KAAoB;AAC7C,MAAI,OAAO,QAAQ,YAAY,QAAQ,KAAM,QAAO,IAAI,GAAG;AAC7D;;;AC7CA,eAAsB,OAAU,KAAa,MAAe,IAAsC;AAChG,QAAM,MAAc,EAAE,GAAG,KAAK,QAAQ,UAAU,KAAK,SAAS;AAC9D,MAAI,KAAK,WAAW,OAAW,KAAI,SAAS,OAAO,KAAK,MAAM;AAC9D,MAAI,KAAK,gBAAgB,OAAW,KAAI,cAAc,KAAK;AAC3D,MAAI,KAAK,eAAe,OAAW,KAAI,aAAa,KAAK;AAEzD,SAAO,YAAY,KAAK,YAAY;AAClC,UAAM,QAAQ,YAAY,IAAI;AAC9B,QAAI;AACF,YAAM,SAAS,MAAM,GAAG;AACxB,UAAI,KAAK,iBAAiB,EAAE,OAAO,iBAAiB,aAAa,KAAK,MAAM,YAAY,IAAI,IAAI,KAAK,EAAE,CAAC;AACxG,aAAO;AAAA,IACT,SAAS,KAAK;AACZ,YAAM,QAAQ,OAAO,KAAK,kBAAkB,aAAa,KAAK,cAAc,GAAG,IAAK,KAAK,iBAAiB;AAC1G,UAAI,QAAQ,UAAU,MAAM,EAAE,cAAc;AAAA,QAC1C,OAAO;AAAA,QACP,aAAa,KAAK,MAAM,YAAY,IAAI,IAAI,KAAK;AAAA,QACjD;AAAA,MACF,CAAC;AACD,iBAAW,GAAG;AACd,YAAM;AAAA,IACR;AAAA,EACF,CAAC;AACH;;;AJ3BO,SAAS,gBACd,KACA,MACA,WACoC;AACpC,SAAO,CAAC,QAAQ,SAAS;AACvB,UAAM,WAAW,IAAI,MAAM,YAAY;AACvC,UAAM,UAAU,IAAI,eAAe;AACnC,UAAM,YAAa,IAAI,MAA+C;AACtE,WAAO;AAAA,MACL;AAAA,MACA;AAAA,QACE,UAAU;AAAA,QACV,QAAQ,IAAI;AAAA,QACZ,aAAa;AAAA;AAAA,QAEb,eAAe,CAAC,QAAQ,WAAW,YAAa,KAAmC,SAAS;AAAA,QAC5F,YAAY,OAAO,cAAc,WAAW,YAAY;AAAA,MAC1D;AAAA,MACA,MAAM,UAAU,KAAK,GAAG,IAAI;AAAA,IAC9B;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,14 @@
1
+ import { L as Logger } from './logger-CFMTzjsm.cjs';
2
+
3
+ /** Minimal shape of a BullMQ Job; avoids depending on bullmq. */
4
+ interface BullJobLike {
5
+ id?: string;
6
+ attemptsMade: number;
7
+ opts?: {
8
+ attempts?: number;
9
+ };
10
+ data?: unknown;
11
+ }
12
+ declare function loggedProcessor<J extends BullJobLike, R, A extends unknown[] = [token?: string, signal?: AbortSignal]>(log: Logger, name: string, processor: (job: J, ...rest: A) => Promise<R>): (job: J, ...rest: A) => Promise<R>;
13
+
14
+ export { type BullJobLike, loggedProcessor };
@@ -0,0 +1,14 @@
1
+ import { L as Logger } from './logger-CFMTzjsm.js';
2
+
3
+ /** Minimal shape of a BullMQ Job; avoids depending on bullmq. */
4
+ interface BullJobLike {
5
+ id?: string;
6
+ attemptsMade: number;
7
+ opts?: {
8
+ attempts?: number;
9
+ };
10
+ data?: unknown;
11
+ }
12
+ declare function loggedProcessor<J extends BullJobLike, R, A extends unknown[] = [token?: string, signal?: AbortSignal]>(log: Logger, name: string, processor: (job: J, ...rest: A) => Promise<R>): (job: J, ...rest: A) => Promise<R>;
13
+
14
+ export { type BullJobLike, loggedProcessor };