@xenterprises/fastify-xlogger 1.0.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/.gitlab-ci.yml +45 -0
- package/README.md +396 -0
- package/package.json +40 -0
- package/src/xLogger.js +462 -0
- package/test/xLogger.test.js +402 -0
package/.gitlab-ci.yml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# GitLab CI/CD Pipeline - xLogger
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# Runs tests on merge requests and commits to main/master
|
|
5
|
+
|
|
6
|
+
stages:
|
|
7
|
+
- test
|
|
8
|
+
|
|
9
|
+
variables:
|
|
10
|
+
NODE_ENV: test
|
|
11
|
+
|
|
12
|
+
# ============================================================================
|
|
13
|
+
# Shared Configuration
|
|
14
|
+
# ============================================================================
|
|
15
|
+
.shared_rules: &shared_rules
|
|
16
|
+
rules:
|
|
17
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
18
|
+
- if: '$CI_COMMIT_BRANCH == "main"'
|
|
19
|
+
- if: '$CI_COMMIT_BRANCH == "master"'
|
|
20
|
+
- if: '$CI_COMMIT_TAG'
|
|
21
|
+
|
|
22
|
+
# ============================================================================
|
|
23
|
+
# STAGE: TEST
|
|
24
|
+
# ============================================================================
|
|
25
|
+
test:
|
|
26
|
+
stage: test
|
|
27
|
+
image: node:20-alpine
|
|
28
|
+
<<: *shared_rules
|
|
29
|
+
|
|
30
|
+
cache:
|
|
31
|
+
key: ${CI_COMMIT_REF_SLUG}
|
|
32
|
+
paths:
|
|
33
|
+
- node_modules/
|
|
34
|
+
|
|
35
|
+
before_script:
|
|
36
|
+
- npm ci
|
|
37
|
+
|
|
38
|
+
script:
|
|
39
|
+
- echo "Running xLogger tests..."
|
|
40
|
+
- npm test
|
|
41
|
+
- npm audit --audit-level=high || true
|
|
42
|
+
|
|
43
|
+
retry:
|
|
44
|
+
max: 2
|
|
45
|
+
when: runner_system_failure
|
package/README.md
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# xLogger
|
|
2
|
+
|
|
3
|
+
A Fastify plugin for standardized logging with Pino. Provides automatic request context, secret redaction, canonical log schema, boundary logging for external APIs, and background job correlation.
|
|
4
|
+
|
|
5
|
+
## Philosophy
|
|
6
|
+
|
|
7
|
+
**Logging is infrastructure. Centralize it and keep it boring.**
|
|
8
|
+
|
|
9
|
+
- Use Fastify's built-in Pino logger as the single logging engine
|
|
10
|
+
- Never create a second logger
|
|
11
|
+
- Log structured objects, not concatenated strings
|
|
12
|
+
- Automatically redact secrets at the logger level
|
|
13
|
+
- Standardize context across all log entries
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install xlogger
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
import Fastify from "fastify";
|
|
25
|
+
import xLogger, { getLoggerOptions } from "xlogger";
|
|
26
|
+
|
|
27
|
+
// Create Fastify with xLogger-configured Pino options
|
|
28
|
+
const fastify = Fastify({
|
|
29
|
+
logger: getLoggerOptions({
|
|
30
|
+
serviceName: "my-api",
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Register the plugin
|
|
35
|
+
await fastify.register(xLogger, {
|
|
36
|
+
serviceName: "my-api",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Use structured logging everywhere
|
|
40
|
+
fastify.get("/users/:id", async (request, reply) => {
|
|
41
|
+
const { id } = request.params;
|
|
42
|
+
|
|
43
|
+
// Use the context-aware logger
|
|
44
|
+
request.contextLog.info({ userId: id }, "Fetching user");
|
|
45
|
+
|
|
46
|
+
// Or use the standard logger with context extraction
|
|
47
|
+
fastify.xlogger.logEvent({
|
|
48
|
+
event: "user.fetched",
|
|
49
|
+
data: { userId: id },
|
|
50
|
+
request,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return { id };
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration Options
|
|
58
|
+
|
|
59
|
+
| Option | Type | Default | Description |
|
|
60
|
+
|--------|------|---------|-------------|
|
|
61
|
+
| `active` | boolean | `true` | Enable/disable the plugin |
|
|
62
|
+
| `serviceName` | string | `process.env.SERVICE_NAME` | Service name for logs |
|
|
63
|
+
| `environment` | string | `process.env.NODE_ENV` | Environment name |
|
|
64
|
+
| `redactPaths` | string[] | `[]` | Additional paths to redact |
|
|
65
|
+
| `redactClobber` | boolean | `false` | Replace default redact paths |
|
|
66
|
+
| `contextExtractor` | function | `null` | Custom context extraction function |
|
|
67
|
+
| `enableBoundaryLogging` | boolean | `true` | Enable boundary logging helpers |
|
|
68
|
+
|
|
69
|
+
## Features
|
|
70
|
+
|
|
71
|
+
### 1. Automatic Request Context
|
|
72
|
+
|
|
73
|
+
Every log entry automatically includes:
|
|
74
|
+
- `requestId` - Unique request identifier
|
|
75
|
+
- `orgId` - Tenant/organization ID (from headers or user object)
|
|
76
|
+
- `userId` - User ID (from headers or user object)
|
|
77
|
+
- `route` - Route pattern
|
|
78
|
+
- `method` - HTTP method
|
|
79
|
+
- `traceId` / `spanId` - OpenTelemetry context (if present)
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
// Context is automatically added to request.contextLog
|
|
83
|
+
request.contextLog.info({ action: "invite_sent" }, "Invite sent");
|
|
84
|
+
|
|
85
|
+
// Output includes requestId, orgId, userId, route, method automatically
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 2. Secret Redaction
|
|
89
|
+
|
|
90
|
+
Secrets are automatically redacted at the logger level:
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
// These fields are automatically redacted:
|
|
94
|
+
// - authorization, cookie, set-cookie headers
|
|
95
|
+
// - password, token, secret, apiKey fields
|
|
96
|
+
// - cardNumber, cvv, ssn, creditCard
|
|
97
|
+
// - Nested paths like *.password, *.token
|
|
98
|
+
|
|
99
|
+
fastify.log.info({
|
|
100
|
+
user: {
|
|
101
|
+
email: "john@example.com",
|
|
102
|
+
password: "secret123" // Will be logged as [REDACTED]
|
|
103
|
+
}
|
|
104
|
+
}, "User data");
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Default Redact Paths:**
|
|
108
|
+
- `req.headers.authorization`
|
|
109
|
+
- `req.headers.cookie`
|
|
110
|
+
- `req.headers['set-cookie']`
|
|
111
|
+
- `req.headers['x-api-key']`
|
|
112
|
+
- `password`, `token`, `secret`, `apiKey`, `api_key`
|
|
113
|
+
- `accessToken`, `refreshToken`, `privateKey`
|
|
114
|
+
- `cardNumber`, `cvv`, `ssn`, `creditCard`
|
|
115
|
+
- `*.password`, `*.token`, `*.secret`, `*.apiKey`
|
|
116
|
+
|
|
117
|
+
### 3. Canonical Log Schema
|
|
118
|
+
|
|
119
|
+
Recommended fields for consistent log structure:
|
|
120
|
+
|
|
121
|
+
| Field | Description |
|
|
122
|
+
|-------|-------------|
|
|
123
|
+
| `event` | Event name (e.g., "user.created", "payment.completed") |
|
|
124
|
+
| `msg` | Human-readable message |
|
|
125
|
+
| `requestId` | Request identifier |
|
|
126
|
+
| `orgId` | Organization/tenant ID |
|
|
127
|
+
| `userId` | User ID |
|
|
128
|
+
| `route` | Route pattern |
|
|
129
|
+
| `method` | HTTP method |
|
|
130
|
+
| `statusCode` | Response status code |
|
|
131
|
+
| `durationMs` | Duration in milliseconds |
|
|
132
|
+
| `err` | Error object |
|
|
133
|
+
| `vendor` | External service name |
|
|
134
|
+
| `externalId` | External resource ID |
|
|
135
|
+
|
|
136
|
+
### 4. Boundary Logging (External API Calls)
|
|
137
|
+
|
|
138
|
+
Log external API calls with timing, vendor IDs, and retry info:
|
|
139
|
+
|
|
140
|
+
```javascript
|
|
141
|
+
// Simple boundary logging
|
|
142
|
+
fastify.xlogger.logBoundary({
|
|
143
|
+
vendor: "stripe",
|
|
144
|
+
operation: "createCustomer",
|
|
145
|
+
externalId: "cus_123",
|
|
146
|
+
durationMs: 150,
|
|
147
|
+
success: true,
|
|
148
|
+
request,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Or use the boundary logger helper with automatic timing
|
|
152
|
+
const boundary = fastify.xlogger.createBoundaryLogger("stripe", "createCustomer", request);
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const customer = await stripe.customers.create({ email });
|
|
156
|
+
boundary.success({ externalId: customer.id, statusCode: 200 });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
boundary.fail(err, { statusCode: err.statusCode });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// With retries
|
|
162
|
+
const boundary = fastify.xlogger.createBoundaryLogger("twilio", "sendSMS", request);
|
|
163
|
+
for (let i = 0; i < 3; i++) {
|
|
164
|
+
try {
|
|
165
|
+
const result = await twilio.messages.create({ to, body });
|
|
166
|
+
boundary.success({ externalId: result.sid });
|
|
167
|
+
break;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
boundary.retry();
|
|
170
|
+
if (i === 2) boundary.fail(err);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 5. Background Job Correlation
|
|
176
|
+
|
|
177
|
+
Pass context to background jobs for tracing:
|
|
178
|
+
|
|
179
|
+
```javascript
|
|
180
|
+
// In your route handler
|
|
181
|
+
fastify.post("/process", async (request, reply) => {
|
|
182
|
+
const context = fastify.xlogger.extractContext(request);
|
|
183
|
+
|
|
184
|
+
// Queue the job with context
|
|
185
|
+
await queue.add("processData", {
|
|
186
|
+
data: request.body,
|
|
187
|
+
...context,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return { queued: true };
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// In your job processor
|
|
194
|
+
async function processJob(job) {
|
|
195
|
+
const { requestId, orgId, userId, data } = job.data;
|
|
196
|
+
|
|
197
|
+
const jobContext = fastify.xlogger.createJobContext({
|
|
198
|
+
jobName: "processData",
|
|
199
|
+
requestId,
|
|
200
|
+
orgId,
|
|
201
|
+
userId,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
jobContext.start({ itemCount: data.length });
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await processData(data);
|
|
208
|
+
jobContext.complete({ processed: data.length });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
jobContext.fail(err);
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 6. Environment-Aware Output
|
|
217
|
+
|
|
218
|
+
- **Production**: JSON logs for aggregation systems
|
|
219
|
+
- **Development**: Pretty-printed logs with colors
|
|
220
|
+
|
|
221
|
+
```javascript
|
|
222
|
+
// Automatically detected from NODE_ENV
|
|
223
|
+
const options = getLoggerOptions();
|
|
224
|
+
|
|
225
|
+
// Or force pretty printing
|
|
226
|
+
const options = getLoggerOptions({ pretty: true });
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## API Reference
|
|
230
|
+
|
|
231
|
+
### Decorators
|
|
232
|
+
|
|
233
|
+
| Decorator | Description |
|
|
234
|
+
|-----------|-------------|
|
|
235
|
+
| `fastify.xlogger.config` | Plugin configuration |
|
|
236
|
+
| `fastify.xlogger.extractContext(request)` | Extract context from request |
|
|
237
|
+
| `fastify.xlogger.logEvent(params)` | Log a business event |
|
|
238
|
+
| `fastify.xlogger.logBoundary(params)` | Log an external API call |
|
|
239
|
+
| `fastify.xlogger.createBoundaryLogger(vendor, op, req)` | Create timed boundary logger |
|
|
240
|
+
| `fastify.xlogger.createJobContext(params)` | Create background job context |
|
|
241
|
+
| `fastify.xlogger.levels` | Log level constants |
|
|
242
|
+
| `fastify.xlogger.redactPaths` | Configured redact paths |
|
|
243
|
+
| `request.contextLog` | Child logger with request context |
|
|
244
|
+
|
|
245
|
+
### `logEvent(params)`
|
|
246
|
+
|
|
247
|
+
Log a business event with canonical schema.
|
|
248
|
+
|
|
249
|
+
```javascript
|
|
250
|
+
fastify.xlogger.logEvent({
|
|
251
|
+
event: "user.created", // Required: event name
|
|
252
|
+
msg: "User was created", // Optional: message
|
|
253
|
+
level: "info", // Optional: log level (default: "info")
|
|
254
|
+
data: { email: "john@example.com" }, // Optional: additional data
|
|
255
|
+
request, // Optional: request for context
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### `logBoundary(params)`
|
|
260
|
+
|
|
261
|
+
Log an external API call.
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
fastify.xlogger.logBoundary({
|
|
265
|
+
vendor: "stripe", // Required: service name
|
|
266
|
+
operation: "createCustomer", // Required: operation name
|
|
267
|
+
externalId: "cus_123", // Optional: external resource ID
|
|
268
|
+
durationMs: 150, // Optional: call duration
|
|
269
|
+
statusCode: 200, // Optional: response status
|
|
270
|
+
success: true, // Optional: success flag (default: true)
|
|
271
|
+
retryCount: 0, // Optional: number of retries
|
|
272
|
+
metadata: {}, // Optional: additional metadata
|
|
273
|
+
err: null, // Optional: error if failed
|
|
274
|
+
request, // Optional: request for context
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### `createBoundaryLogger(vendor, operation, request)`
|
|
279
|
+
|
|
280
|
+
Create a boundary logger with automatic timing.
|
|
281
|
+
|
|
282
|
+
```javascript
|
|
283
|
+
const boundary = fastify.xlogger.createBoundaryLogger("stripe", "createCustomer", request);
|
|
284
|
+
|
|
285
|
+
boundary.retry(); // Increment retry counter
|
|
286
|
+
boundary.success(params); // Log success with timing
|
|
287
|
+
boundary.fail(err, params); // Log failure with timing
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### `createJobContext(params)`
|
|
291
|
+
|
|
292
|
+
Create a correlation context for background jobs.
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
const job = fastify.xlogger.createJobContext({
|
|
296
|
+
jobName: "processPayments", // Required: job name
|
|
297
|
+
requestId: "req_123", // Optional: original request ID
|
|
298
|
+
orgId: "org_456", // Optional: organization ID
|
|
299
|
+
userId: "user_789", // Optional: user ID
|
|
300
|
+
correlationId: "corr_abc", // Optional: correlation ID (auto-generated if not provided)
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
job.context; // { correlationId, jobName, orgId, userId, originalRequestId }
|
|
304
|
+
job.log; // Child logger with context
|
|
305
|
+
job.start(data); // Log job started
|
|
306
|
+
job.complete(data); // Log job completed
|
|
307
|
+
job.fail(err, data); // Log job failed
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### `getLoggerOptions(options)`
|
|
311
|
+
|
|
312
|
+
Get Pino logger options for Fastify initialization.
|
|
313
|
+
|
|
314
|
+
```javascript
|
|
315
|
+
import { getLoggerOptions } from "xlogger";
|
|
316
|
+
|
|
317
|
+
const fastify = Fastify({
|
|
318
|
+
logger: getLoggerOptions({
|
|
319
|
+
level: "debug", // Optional: log level
|
|
320
|
+
serviceName: "my-api", // Optional: service name
|
|
321
|
+
redactPaths: ["custom"], // Optional: additional redact paths
|
|
322
|
+
pretty: false, // Optional: force pretty printing
|
|
323
|
+
}),
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Log Levels
|
|
328
|
+
|
|
329
|
+
| Level | Value | Use For |
|
|
330
|
+
|-------|-------|---------|
|
|
331
|
+
| `fatal` | 60 | Process cannot continue |
|
|
332
|
+
| `error` | 50 | Failures requiring attention |
|
|
333
|
+
| `warn` | 40 | Recoverable issues |
|
|
334
|
+
| `info` | 30 | Business events, normal operations |
|
|
335
|
+
| `debug` | 20 | Detailed debugging information |
|
|
336
|
+
| `trace` | 10 | Very detailed tracing |
|
|
337
|
+
|
|
338
|
+
## Best Practices
|
|
339
|
+
|
|
340
|
+
### Do
|
|
341
|
+
|
|
342
|
+
```javascript
|
|
343
|
+
// Log structured objects
|
|
344
|
+
fastify.log.info({ userId, action: "login" }, "User logged in");
|
|
345
|
+
|
|
346
|
+
// Use the canonical schema
|
|
347
|
+
fastify.xlogger.logEvent({
|
|
348
|
+
event: "payment.completed",
|
|
349
|
+
data: { amount: 100, currency: "USD" },
|
|
350
|
+
request,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Use boundary logging for external calls
|
|
354
|
+
const boundary = fastify.xlogger.createBoundaryLogger("stripe", "charge", request);
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Don't
|
|
358
|
+
|
|
359
|
+
```javascript
|
|
360
|
+
// Don't concatenate strings
|
|
361
|
+
fastify.log.info("User " + userId + " logged in"); // Bad!
|
|
362
|
+
|
|
363
|
+
// Don't log sensitive data (it will be redacted, but still)
|
|
364
|
+
fastify.log.info({ password: userPassword }); // Bad!
|
|
365
|
+
|
|
366
|
+
// Don't log full request/response payloads
|
|
367
|
+
fastify.log.info({ body: request.body }); // Usually bad!
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Integration with Error Tracking
|
|
371
|
+
|
|
372
|
+
Use logs for aggregation/search and Sentry for error tracking:
|
|
373
|
+
|
|
374
|
+
```javascript
|
|
375
|
+
fastify.setErrorHandler((error, request, reply) => {
|
|
376
|
+
// Log the error
|
|
377
|
+
request.contextLog.error({ err: error }, "Request failed");
|
|
378
|
+
|
|
379
|
+
// Send to Sentry with context
|
|
380
|
+
Sentry.captureException(error, {
|
|
381
|
+
extra: fastify.xlogger.extractContext(request),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
reply.status(500).send({ error: "Internal Server Error" });
|
|
385
|
+
});
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Testing
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
npm test
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## License
|
|
395
|
+
|
|
396
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xenterprises/fastify-xlogger",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fastify plugin for standardized logging with Pino - context, redaction, and canonical schema",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/xLogger.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/xLogger.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20.0.0",
|
|
12
|
+
"npm": ">=10.0.0"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/xLogger.test.js",
|
|
16
|
+
"lint": "eslint src/ test/"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"fastify",
|
|
20
|
+
"logging",
|
|
21
|
+
"pino",
|
|
22
|
+
"structured-logging",
|
|
23
|
+
"redaction",
|
|
24
|
+
"observability",
|
|
25
|
+
"saas"
|
|
26
|
+
],
|
|
27
|
+
"author": "X Enterprises",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"fastify-plugin": "^5.0.1"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"fastify": "^5.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"eslint": "^9.0.0",
|
|
37
|
+
"fastify": "^5.0.0",
|
|
38
|
+
"pino-pretty": "^11.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/xLogger.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xLogger - Standardized Logging for Fastify SaaS Plugins
|
|
3
|
+
*
|
|
4
|
+
* Wraps Fastify's built-in Pino logger with:
|
|
5
|
+
* - Standardized request context (requestId, orgId, userId, route, method)
|
|
6
|
+
* - Automatic secret redaction
|
|
7
|
+
* - Canonical log schema
|
|
8
|
+
* - Environment-aware formatting (JSON in prod, pretty in dev)
|
|
9
|
+
* - Boundary logging helpers for external API calls
|
|
10
|
+
* - Background job correlation support
|
|
11
|
+
*
|
|
12
|
+
* @see https://getpino.io/
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fp from "fastify-plugin";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default paths to redact from logs
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_REDACT_PATHS = [
|
|
21
|
+
// Auth headers
|
|
22
|
+
"req.headers.authorization",
|
|
23
|
+
"req.headers.cookie",
|
|
24
|
+
"req.headers['set-cookie']",
|
|
25
|
+
"req.headers['x-api-key']",
|
|
26
|
+
// Common secret fields
|
|
27
|
+
"password",
|
|
28
|
+
"token",
|
|
29
|
+
"secret",
|
|
30
|
+
"apiKey",
|
|
31
|
+
"api_key",
|
|
32
|
+
"accessToken",
|
|
33
|
+
"access_token",
|
|
34
|
+
"refreshToken",
|
|
35
|
+
"refresh_token",
|
|
36
|
+
"privateKey",
|
|
37
|
+
"private_key",
|
|
38
|
+
// Payment/PII
|
|
39
|
+
"cardNumber",
|
|
40
|
+
"card_number",
|
|
41
|
+
"cvv",
|
|
42
|
+
"ssn",
|
|
43
|
+
"creditCard",
|
|
44
|
+
// Nested paths
|
|
45
|
+
"*.password",
|
|
46
|
+
"*.token",
|
|
47
|
+
"*.secret",
|
|
48
|
+
"*.apiKey",
|
|
49
|
+
"*.api_key",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Log levels and their numeric values
|
|
54
|
+
*/
|
|
55
|
+
const LOG_LEVELS = {
|
|
56
|
+
fatal: 60,
|
|
57
|
+
error: 50,
|
|
58
|
+
warn: 40,
|
|
59
|
+
info: 30,
|
|
60
|
+
debug: 20,
|
|
61
|
+
trace: 10,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* xLogger Plugin
|
|
66
|
+
*
|
|
67
|
+
* @param {import('fastify').FastifyInstance} fastify - Fastify instance
|
|
68
|
+
* @param {Object} options - Plugin options
|
|
69
|
+
* @param {boolean} [options.active] - Enable/disable the plugin (default: true)
|
|
70
|
+
* @param {string[]} [options.redactPaths] - Additional paths to redact
|
|
71
|
+
* @param {boolean} [options.redactClobber] - Replace default redact paths instead of extending
|
|
72
|
+
* @param {boolean} [options.includeRequestBody] - Include request body in logs (default: false)
|
|
73
|
+
* @param {boolean} [options.includeResponseBody] - Include response body in logs (default: false)
|
|
74
|
+
* @param {string} [options.serviceName] - Service name for logs
|
|
75
|
+
* @param {string} [options.environment] - Environment name (default: process.env.NODE_ENV)
|
|
76
|
+
* @param {Function} [options.contextExtractor] - Custom function to extract context from request
|
|
77
|
+
* @param {boolean} [options.enableBoundaryLogging] - Enable external API call logging helpers (default: true)
|
|
78
|
+
*/
|
|
79
|
+
async function xLogger(fastify, options) {
|
|
80
|
+
// Check if plugin is disabled
|
|
81
|
+
if (options.active === false) {
|
|
82
|
+
console.info(" ⏸️ xLogger Disabled");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const config = {
|
|
87
|
+
redactPaths: options.redactClobber
|
|
88
|
+
? options.redactPaths || []
|
|
89
|
+
: [...DEFAULT_REDACT_PATHS, ...(options.redactPaths || [])],
|
|
90
|
+
includeRequestBody: options.includeRequestBody || false,
|
|
91
|
+
includeResponseBody: options.includeResponseBody || false,
|
|
92
|
+
serviceName: options.serviceName || process.env.SERVICE_NAME || "fastify-app",
|
|
93
|
+
environment: options.environment || process.env.NODE_ENV || "development",
|
|
94
|
+
contextExtractor: options.contextExtractor || null,
|
|
95
|
+
enableBoundaryLogging: options.enableBoundaryLogging !== false,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Store configuration
|
|
99
|
+
fastify.decorate("xlogger", {
|
|
100
|
+
config,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract standard context from request
|
|
105
|
+
* @param {import('fastify').FastifyRequest} request
|
|
106
|
+
* @returns {Object} Context object
|
|
107
|
+
*/
|
|
108
|
+
function extractContext(request) {
|
|
109
|
+
const context = {
|
|
110
|
+
requestId: request.id,
|
|
111
|
+
route: request.routeOptions?.url || request.url,
|
|
112
|
+
method: request.method,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Extract orgId and userId from various sources
|
|
116
|
+
// Check request user object (set by auth plugins)
|
|
117
|
+
if (request.user) {
|
|
118
|
+
if (request.user.orgId) context.orgId = request.user.orgId;
|
|
119
|
+
if (request.user.organizationId) context.orgId = request.user.organizationId;
|
|
120
|
+
if (request.user.tenantId) context.orgId = request.user.tenantId;
|
|
121
|
+
if (request.user.id) context.userId = request.user.id;
|
|
122
|
+
if (request.user.userId) context.userId = request.user.userId;
|
|
123
|
+
if (request.user.sub) context.userId = request.user.sub;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check headers for tenant context
|
|
127
|
+
if (request.headers["x-org-id"]) context.orgId = request.headers["x-org-id"];
|
|
128
|
+
if (request.headers["x-tenant-id"]) context.orgId = request.headers["x-tenant-id"];
|
|
129
|
+
if (request.headers["x-user-id"]) context.userId = request.headers["x-user-id"];
|
|
130
|
+
|
|
131
|
+
// OpenTelemetry trace context
|
|
132
|
+
if (request.headers.traceparent) {
|
|
133
|
+
const parts = request.headers.traceparent.split("-");
|
|
134
|
+
if (parts.length >= 3) {
|
|
135
|
+
context.traceId = parts[1];
|
|
136
|
+
context.spanId = parts[2];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Custom context extractor
|
|
141
|
+
if (config.contextExtractor) {
|
|
142
|
+
const customContext = config.contextExtractor(request);
|
|
143
|
+
Object.assign(context, customContext);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return context;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create a child logger with request context
|
|
151
|
+
* @param {import('fastify').FastifyRequest} request
|
|
152
|
+
* @returns {import('pino').Logger} Child logger with context
|
|
153
|
+
*/
|
|
154
|
+
function createRequestLogger(request) {
|
|
155
|
+
const context = extractContext(request);
|
|
156
|
+
return request.log.child(context);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Add context logger to request
|
|
160
|
+
fastify.decorateRequest("contextLog", null);
|
|
161
|
+
|
|
162
|
+
fastify.addHook("onRequest", async (request) => {
|
|
163
|
+
request.contextLog = createRequestLogger(request);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Log a business event with canonical schema
|
|
168
|
+
* @param {Object} params - Event parameters
|
|
169
|
+
* @param {string} params.event - Event name (e.g., "user.created", "payment.completed")
|
|
170
|
+
* @param {string} [params.msg] - Human-readable message
|
|
171
|
+
* @param {string} [params.level] - Log level (default: "info")
|
|
172
|
+
* @param {Object} [params.data] - Additional event data
|
|
173
|
+
* @param {import('fastify').FastifyRequest} [params.request] - Request for context
|
|
174
|
+
*/
|
|
175
|
+
function logEvent({ event, msg, level = "info", data = {}, request = null }) {
|
|
176
|
+
const logData = {
|
|
177
|
+
event,
|
|
178
|
+
...data,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Add request context if available
|
|
182
|
+
if (request) {
|
|
183
|
+
Object.assign(logData, extractContext(request));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const logger = request?.log || fastify.log;
|
|
187
|
+
const message = msg || event;
|
|
188
|
+
|
|
189
|
+
if (logger[level]) {
|
|
190
|
+
logger[level](logData, message);
|
|
191
|
+
} else {
|
|
192
|
+
logger.info(logData, message);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Log an external API call (boundary logging)
|
|
198
|
+
* @param {Object} params - Call parameters
|
|
199
|
+
* @param {string} params.vendor - External service name (e.g., "stripe", "twilio")
|
|
200
|
+
* @param {string} params.operation - Operation name (e.g., "createCustomer", "sendSMS")
|
|
201
|
+
* @param {string} [params.externalId] - External resource ID
|
|
202
|
+
* @param {number} [params.durationMs] - Call duration in milliseconds
|
|
203
|
+
* @param {number} [params.statusCode] - Response status code
|
|
204
|
+
* @param {boolean} [params.success] - Whether the call succeeded
|
|
205
|
+
* @param {number} [params.retryCount] - Number of retries
|
|
206
|
+
* @param {Object} [params.metadata] - Additional metadata (no sensitive data!)
|
|
207
|
+
* @param {Error} [params.err] - Error if failed
|
|
208
|
+
* @param {import('fastify').FastifyRequest} [params.request] - Request for context
|
|
209
|
+
*/
|
|
210
|
+
function logBoundary({
|
|
211
|
+
vendor,
|
|
212
|
+
operation,
|
|
213
|
+
externalId,
|
|
214
|
+
durationMs,
|
|
215
|
+
statusCode,
|
|
216
|
+
success = true,
|
|
217
|
+
retryCount,
|
|
218
|
+
metadata = {},
|
|
219
|
+
err,
|
|
220
|
+
request = null,
|
|
221
|
+
}) {
|
|
222
|
+
const logData = {
|
|
223
|
+
event: "boundary.call",
|
|
224
|
+
vendor,
|
|
225
|
+
operation,
|
|
226
|
+
success,
|
|
227
|
+
...metadata,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (externalId) logData.externalId = externalId;
|
|
231
|
+
if (durationMs !== undefined) logData.durationMs = durationMs;
|
|
232
|
+
if (statusCode !== undefined) logData.statusCode = statusCode;
|
|
233
|
+
if (retryCount !== undefined) logData.retryCount = retryCount;
|
|
234
|
+
if (err) logData.err = err;
|
|
235
|
+
|
|
236
|
+
// Add request context if available
|
|
237
|
+
if (request) {
|
|
238
|
+
Object.assign(logData, extractContext(request));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const logger = request?.log || fastify.log;
|
|
242
|
+
const level = success ? "info" : "error";
|
|
243
|
+
const message = `${vendor}.${operation} ${success ? "completed" : "failed"}`;
|
|
244
|
+
|
|
245
|
+
logger[level](logData, message);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Create a boundary logger helper that times the operation
|
|
250
|
+
* @param {string} vendor - External service name
|
|
251
|
+
* @param {string} operation - Operation name
|
|
252
|
+
* @param {import('fastify').FastifyRequest} [request] - Request for context
|
|
253
|
+
* @returns {Object} Boundary logger with start/success/fail methods
|
|
254
|
+
*/
|
|
255
|
+
function createBoundaryLogger(vendor, operation, request = null) {
|
|
256
|
+
const startTime = Date.now();
|
|
257
|
+
let retryCount = 0;
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
/**
|
|
261
|
+
* Increment retry counter
|
|
262
|
+
*/
|
|
263
|
+
retry() {
|
|
264
|
+
retryCount++;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Log successful completion
|
|
269
|
+
* @param {Object} [params] - Additional parameters
|
|
270
|
+
*/
|
|
271
|
+
success(params = {}) {
|
|
272
|
+
logBoundary({
|
|
273
|
+
vendor,
|
|
274
|
+
operation,
|
|
275
|
+
durationMs: Date.now() - startTime,
|
|
276
|
+
success: true,
|
|
277
|
+
retryCount: retryCount > 0 ? retryCount : undefined,
|
|
278
|
+
request,
|
|
279
|
+
...params,
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Log failure
|
|
285
|
+
* @param {Error} err - The error
|
|
286
|
+
* @param {Object} [params] - Additional parameters
|
|
287
|
+
*/
|
|
288
|
+
fail(err, params = {}) {
|
|
289
|
+
logBoundary({
|
|
290
|
+
vendor,
|
|
291
|
+
operation,
|
|
292
|
+
durationMs: Date.now() - startTime,
|
|
293
|
+
success: false,
|
|
294
|
+
retryCount: retryCount > 0 ? retryCount : undefined,
|
|
295
|
+
err,
|
|
296
|
+
request,
|
|
297
|
+
...params,
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create a correlation context for background jobs
|
|
305
|
+
* @param {Object} params - Correlation parameters
|
|
306
|
+
* @param {string} [params.requestId] - Original request ID
|
|
307
|
+
* @param {string} [params.orgId] - Organization ID
|
|
308
|
+
* @param {string} [params.userId] - User ID
|
|
309
|
+
* @param {string} [params.correlationId] - Correlation ID (generated if not provided)
|
|
310
|
+
* @param {string} [params.jobName] - Job name
|
|
311
|
+
* @returns {Object} Correlation context and child logger
|
|
312
|
+
*/
|
|
313
|
+
function createJobContext({ requestId, orgId, userId, correlationId, jobName }) {
|
|
314
|
+
const context = {
|
|
315
|
+
correlationId: correlationId || `job_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
|
316
|
+
jobName,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
if (requestId) context.originalRequestId = requestId;
|
|
320
|
+
if (orgId) context.orgId = orgId;
|
|
321
|
+
if (userId) context.userId = userId;
|
|
322
|
+
|
|
323
|
+
const logger = fastify.log.child(context);
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
context,
|
|
327
|
+
log: logger,
|
|
328
|
+
/**
|
|
329
|
+
* Log job start
|
|
330
|
+
* @param {Object} [data] - Additional data
|
|
331
|
+
*/
|
|
332
|
+
start(data = {}) {
|
|
333
|
+
logger.info({ event: "job.started", ...data }, `Job ${jobName} started`);
|
|
334
|
+
},
|
|
335
|
+
/**
|
|
336
|
+
* Log job completion
|
|
337
|
+
* @param {Object} [data] - Additional data
|
|
338
|
+
*/
|
|
339
|
+
complete(data = {}) {
|
|
340
|
+
logger.info({ event: "job.completed", ...data }, `Job ${jobName} completed`);
|
|
341
|
+
},
|
|
342
|
+
/**
|
|
343
|
+
* Log job failure
|
|
344
|
+
* @param {Error} err - The error
|
|
345
|
+
* @param {Object} [data] - Additional data
|
|
346
|
+
*/
|
|
347
|
+
fail(err, data = {}) {
|
|
348
|
+
logger.error({ event: "job.failed", err, ...data }, `Job ${jobName} failed`);
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Add logging utilities to xlogger namespace
|
|
354
|
+
fastify.xlogger.extractContext = extractContext;
|
|
355
|
+
fastify.xlogger.logEvent = logEvent;
|
|
356
|
+
fastify.xlogger.logBoundary = logBoundary;
|
|
357
|
+
fastify.xlogger.createBoundaryLogger = createBoundaryLogger;
|
|
358
|
+
fastify.xlogger.createJobContext = createJobContext;
|
|
359
|
+
fastify.xlogger.levels = LOG_LEVELS;
|
|
360
|
+
fastify.xlogger.redactPaths = config.redactPaths;
|
|
361
|
+
|
|
362
|
+
// Log response with canonical fields
|
|
363
|
+
fastify.addHook("onResponse", async (request, reply) => {
|
|
364
|
+
const context = extractContext(request);
|
|
365
|
+
const logData = {
|
|
366
|
+
event: "http.response",
|
|
367
|
+
...context,
|
|
368
|
+
statusCode: reply.statusCode,
|
|
369
|
+
durationMs: Math.round(reply.elapsedTime),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Determine log level based on status code
|
|
373
|
+
let level = "info";
|
|
374
|
+
if (reply.statusCode >= 500) {
|
|
375
|
+
level = "error";
|
|
376
|
+
} else if (reply.statusCode >= 400) {
|
|
377
|
+
level = "warn";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
request.log[level](logData, `${request.method} ${request.url} ${reply.statusCode}`);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
console.info(" ✅ xLogger Standardized Logging Enabled");
|
|
384
|
+
console.info(` • Service: ${config.serviceName}`);
|
|
385
|
+
console.info(` • Environment: ${config.environment}`);
|
|
386
|
+
console.info(` • Redact paths: ${config.redactPaths.length} configured`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export default fp(xLogger, {
|
|
390
|
+
name: "xLogger",
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Get Pino logger options with xLogger defaults
|
|
395
|
+
* Use this when creating the Fastify instance
|
|
396
|
+
*
|
|
397
|
+
* @param {Object} [options] - Options
|
|
398
|
+
* @param {string} [options.level] - Log level (default: based on NODE_ENV)
|
|
399
|
+
* @param {string[]} [options.redactPaths] - Additional paths to redact
|
|
400
|
+
* @param {boolean} [options.pretty] - Force pretty printing
|
|
401
|
+
* @param {string} [options.serviceName] - Service name
|
|
402
|
+
* @returns {Object} Pino logger options
|
|
403
|
+
*/
|
|
404
|
+
export function getLoggerOptions(options = {}) {
|
|
405
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
406
|
+
const level = options.level || (isProd ? "info" : "debug");
|
|
407
|
+
|
|
408
|
+
const redactPaths = [...DEFAULT_REDACT_PATHS, ...(options.redactPaths || [])];
|
|
409
|
+
|
|
410
|
+
const loggerOptions = {
|
|
411
|
+
level,
|
|
412
|
+
redact: {
|
|
413
|
+
paths: redactPaths,
|
|
414
|
+
censor: "[REDACTED]",
|
|
415
|
+
},
|
|
416
|
+
serializers: {
|
|
417
|
+
req(request) {
|
|
418
|
+
return {
|
|
419
|
+
method: request.method,
|
|
420
|
+
url: request.url,
|
|
421
|
+
hostname: request.hostname,
|
|
422
|
+
remoteAddress: request.ip,
|
|
423
|
+
remotePort: request.socket?.remotePort,
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
res(reply) {
|
|
427
|
+
return {
|
|
428
|
+
statusCode: reply.statusCode,
|
|
429
|
+
};
|
|
430
|
+
},
|
|
431
|
+
err(err) {
|
|
432
|
+
return {
|
|
433
|
+
type: err.constructor.name,
|
|
434
|
+
message: err.message,
|
|
435
|
+
stack: err.stack,
|
|
436
|
+
code: err.code,
|
|
437
|
+
statusCode: err.statusCode,
|
|
438
|
+
};
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
base: {
|
|
442
|
+
service: options.serviceName || process.env.SERVICE_NAME || "fastify-app",
|
|
443
|
+
env: process.env.NODE_ENV || "development",
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Pretty print in development
|
|
448
|
+
if (!isProd || options.pretty) {
|
|
449
|
+
loggerOptions.transport = {
|
|
450
|
+
target: "pino-pretty",
|
|
451
|
+
options: {
|
|
452
|
+
colorize: true,
|
|
453
|
+
translateTime: "HH:MM:ss",
|
|
454
|
+
ignore: "pid,hostname",
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return loggerOptions;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export { DEFAULT_REDACT_PATHS, LOG_LEVELS };
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xLogger Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the xLogger Fastify plugin
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
8
|
+
import assert from "node:assert";
|
|
9
|
+
import Fastify from "fastify";
|
|
10
|
+
import xLogger, { getLoggerOptions, DEFAULT_REDACT_PATHS, LOG_LEVELS } from "../src/xLogger.js";
|
|
11
|
+
|
|
12
|
+
describe("xLogger Plugin", () => {
|
|
13
|
+
let fastify;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fastify = Fastify({
|
|
17
|
+
logger: {
|
|
18
|
+
level: "silent", // Suppress logs during tests
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await fastify.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("Plugin Registration", () => {
|
|
28
|
+
test("should register successfully", async () => {
|
|
29
|
+
await fastify.register(xLogger, {});
|
|
30
|
+
await fastify.ready();
|
|
31
|
+
|
|
32
|
+
assert.ok(fastify.xlogger, "xlogger should exist");
|
|
33
|
+
assert.ok(fastify.xlogger.config, "xlogger.config should exist");
|
|
34
|
+
assert.ok(fastify.xlogger.logEvent, "xlogger.logEvent should exist");
|
|
35
|
+
assert.ok(fastify.xlogger.logBoundary, "xlogger.logBoundary should exist");
|
|
36
|
+
assert.ok(fastify.xlogger.createBoundaryLogger, "xlogger.createBoundaryLogger should exist");
|
|
37
|
+
assert.ok(fastify.xlogger.createJobContext, "xlogger.createJobContext should exist");
|
|
38
|
+
assert.ok(fastify.xlogger.extractContext, "xlogger.extractContext should exist");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("should skip registration when active is false", async () => {
|
|
42
|
+
await fastify.register(xLogger, { active: false });
|
|
43
|
+
await fastify.ready();
|
|
44
|
+
|
|
45
|
+
assert.ok(!fastify.xlogger, "xlogger should not exist");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("should use default redact paths", async () => {
|
|
49
|
+
await fastify.register(xLogger, {});
|
|
50
|
+
await fastify.ready();
|
|
51
|
+
|
|
52
|
+
assert.ok(fastify.xlogger.redactPaths.includes("password"));
|
|
53
|
+
assert.ok(fastify.xlogger.redactPaths.includes("token"));
|
|
54
|
+
assert.ok(fastify.xlogger.redactPaths.includes("req.headers.authorization"));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("should extend redact paths with custom paths", async () => {
|
|
58
|
+
await fastify.register(xLogger, {
|
|
59
|
+
redactPaths: ["customSecret", "myApiKey"],
|
|
60
|
+
});
|
|
61
|
+
await fastify.ready();
|
|
62
|
+
|
|
63
|
+
assert.ok(fastify.xlogger.redactPaths.includes("password"));
|
|
64
|
+
assert.ok(fastify.xlogger.redactPaths.includes("customSecret"));
|
|
65
|
+
assert.ok(fastify.xlogger.redactPaths.includes("myApiKey"));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("should replace redact paths when redactClobber is true", async () => {
|
|
69
|
+
await fastify.register(xLogger, {
|
|
70
|
+
redactPaths: ["onlyThis"],
|
|
71
|
+
redactClobber: true,
|
|
72
|
+
});
|
|
73
|
+
await fastify.ready();
|
|
74
|
+
|
|
75
|
+
assert.ok(!fastify.xlogger.redactPaths.includes("password"));
|
|
76
|
+
assert.ok(fastify.xlogger.redactPaths.includes("onlyThis"));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("should store service name in config", async () => {
|
|
80
|
+
await fastify.register(xLogger, {
|
|
81
|
+
serviceName: "my-test-service",
|
|
82
|
+
});
|
|
83
|
+
await fastify.ready();
|
|
84
|
+
|
|
85
|
+
assert.strictEqual(fastify.xlogger.config.serviceName, "my-test-service");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("Request Context", () => {
|
|
90
|
+
test("should decorate request with contextLog", async () => {
|
|
91
|
+
await fastify.register(xLogger, {});
|
|
92
|
+
|
|
93
|
+
fastify.get("/test", async (request) => {
|
|
94
|
+
assert.ok(request.contextLog, "contextLog should exist on request");
|
|
95
|
+
return { ok: true };
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await fastify.ready();
|
|
99
|
+
|
|
100
|
+
const response = await fastify.inject({
|
|
101
|
+
method: "GET",
|
|
102
|
+
url: "/test",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.strictEqual(response.statusCode, 200);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("should extract context from request", async () => {
|
|
109
|
+
await fastify.register(xLogger, {});
|
|
110
|
+
let extractedContext;
|
|
111
|
+
|
|
112
|
+
fastify.get("/test", async (request) => {
|
|
113
|
+
extractedContext = fastify.xlogger.extractContext(request);
|
|
114
|
+
return { ok: true };
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await fastify.ready();
|
|
118
|
+
|
|
119
|
+
await fastify.inject({
|
|
120
|
+
method: "GET",
|
|
121
|
+
url: "/test",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.ok(extractedContext.requestId, "requestId should exist");
|
|
125
|
+
assert.strictEqual(extractedContext.method, "GET");
|
|
126
|
+
assert.strictEqual(extractedContext.route, "/test");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("should extract orgId from x-org-id header", async () => {
|
|
130
|
+
await fastify.register(xLogger, {});
|
|
131
|
+
let extractedContext;
|
|
132
|
+
|
|
133
|
+
fastify.get("/test", async (request) => {
|
|
134
|
+
extractedContext = fastify.xlogger.extractContext(request);
|
|
135
|
+
return { ok: true };
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await fastify.ready();
|
|
139
|
+
|
|
140
|
+
await fastify.inject({
|
|
141
|
+
method: "GET",
|
|
142
|
+
url: "/test",
|
|
143
|
+
headers: {
|
|
144
|
+
"x-org-id": "org_123",
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
assert.strictEqual(extractedContext.orgId, "org_123");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("should extract userId from x-user-id header", async () => {
|
|
152
|
+
await fastify.register(xLogger, {});
|
|
153
|
+
let extractedContext;
|
|
154
|
+
|
|
155
|
+
fastify.get("/test", async (request) => {
|
|
156
|
+
extractedContext = fastify.xlogger.extractContext(request);
|
|
157
|
+
return { ok: true };
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await fastify.ready();
|
|
161
|
+
|
|
162
|
+
await fastify.inject({
|
|
163
|
+
method: "GET",
|
|
164
|
+
url: "/test",
|
|
165
|
+
headers: {
|
|
166
|
+
"x-user-id": "user_456",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.strictEqual(extractedContext.userId, "user_456");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("should extract OpenTelemetry trace context", async () => {
|
|
174
|
+
await fastify.register(xLogger, {});
|
|
175
|
+
let extractedContext;
|
|
176
|
+
|
|
177
|
+
fastify.get("/test", async (request) => {
|
|
178
|
+
extractedContext = fastify.xlogger.extractContext(request);
|
|
179
|
+
return { ok: true };
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await fastify.ready();
|
|
183
|
+
|
|
184
|
+
await fastify.inject({
|
|
185
|
+
method: "GET",
|
|
186
|
+
url: "/test",
|
|
187
|
+
headers: {
|
|
188
|
+
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
assert.strictEqual(extractedContext.traceId, "0af7651916cd43dd8448eb211c80319c");
|
|
193
|
+
assert.strictEqual(extractedContext.spanId, "b7ad6b7169203331");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("should use custom context extractor", async () => {
|
|
197
|
+
await fastify.register(xLogger, {
|
|
198
|
+
contextExtractor: (request) => ({
|
|
199
|
+
customField: "custom_value",
|
|
200
|
+
fromQuery: request.query.foo,
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
let extractedContext;
|
|
204
|
+
|
|
205
|
+
fastify.get("/test", async (request) => {
|
|
206
|
+
extractedContext = fastify.xlogger.extractContext(request);
|
|
207
|
+
return { ok: true };
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await fastify.ready();
|
|
211
|
+
|
|
212
|
+
await fastify.inject({
|
|
213
|
+
method: "GET",
|
|
214
|
+
url: "/test?foo=bar",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
assert.strictEqual(extractedContext.customField, "custom_value");
|
|
218
|
+
assert.strictEqual(extractedContext.fromQuery, "bar");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("Boundary Logging", () => {
|
|
223
|
+
test("should create boundary logger with timing", async () => {
|
|
224
|
+
await fastify.register(xLogger, {});
|
|
225
|
+
await fastify.ready();
|
|
226
|
+
|
|
227
|
+
const boundary = fastify.xlogger.createBoundaryLogger("stripe", "createCustomer");
|
|
228
|
+
|
|
229
|
+
assert.ok(boundary.success, "success method should exist");
|
|
230
|
+
assert.ok(boundary.fail, "fail method should exist");
|
|
231
|
+
assert.ok(boundary.retry, "retry method should exist");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("should track retry count", async () => {
|
|
235
|
+
await fastify.register(xLogger, {});
|
|
236
|
+
await fastify.ready();
|
|
237
|
+
|
|
238
|
+
const boundary = fastify.xlogger.createBoundaryLogger("stripe", "createCustomer");
|
|
239
|
+
|
|
240
|
+
boundary.retry();
|
|
241
|
+
boundary.retry();
|
|
242
|
+
|
|
243
|
+
// The retry count is internal, but we can verify it doesn't throw
|
|
244
|
+
assert.doesNotThrow(() => boundary.success());
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("Job Context", () => {
|
|
249
|
+
test("should create job context with correlation ID", async () => {
|
|
250
|
+
await fastify.register(xLogger, {});
|
|
251
|
+
await fastify.ready();
|
|
252
|
+
|
|
253
|
+
const job = fastify.xlogger.createJobContext({
|
|
254
|
+
jobName: "processPayments",
|
|
255
|
+
orgId: "org_123",
|
|
256
|
+
userId: "user_456",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
assert.ok(job.context, "context should exist");
|
|
260
|
+
assert.ok(job.log, "log should exist");
|
|
261
|
+
assert.ok(job.start, "start method should exist");
|
|
262
|
+
assert.ok(job.complete, "complete method should exist");
|
|
263
|
+
assert.ok(job.fail, "fail method should exist");
|
|
264
|
+
assert.ok(job.context.correlationId, "correlationId should be generated");
|
|
265
|
+
assert.strictEqual(job.context.jobName, "processPayments");
|
|
266
|
+
assert.strictEqual(job.context.orgId, "org_123");
|
|
267
|
+
assert.strictEqual(job.context.userId, "user_456");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("should use provided correlation ID", async () => {
|
|
271
|
+
await fastify.register(xLogger, {});
|
|
272
|
+
await fastify.ready();
|
|
273
|
+
|
|
274
|
+
const job = fastify.xlogger.createJobContext({
|
|
275
|
+
jobName: "syncData",
|
|
276
|
+
correlationId: "custom_corr_123",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
assert.strictEqual(job.context.correlationId, "custom_corr_123");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("should include original request ID", async () => {
|
|
283
|
+
await fastify.register(xLogger, {});
|
|
284
|
+
await fastify.ready();
|
|
285
|
+
|
|
286
|
+
const job = fastify.xlogger.createJobContext({
|
|
287
|
+
jobName: "asyncTask",
|
|
288
|
+
requestId: "req_789",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
assert.strictEqual(job.context.originalRequestId, "req_789");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("Log Event", () => {
|
|
296
|
+
test("should log event without request", async () => {
|
|
297
|
+
await fastify.register(xLogger, {});
|
|
298
|
+
await fastify.ready();
|
|
299
|
+
|
|
300
|
+
// Should not throw
|
|
301
|
+
assert.doesNotThrow(() => {
|
|
302
|
+
fastify.xlogger.logEvent({
|
|
303
|
+
event: "user.created",
|
|
304
|
+
msg: "User was created",
|
|
305
|
+
data: { email: "test@example.com" },
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("should log event with different levels", async () => {
|
|
311
|
+
await fastify.register(xLogger, {});
|
|
312
|
+
await fastify.ready();
|
|
313
|
+
|
|
314
|
+
// Should not throw for different levels
|
|
315
|
+
assert.doesNotThrow(() => {
|
|
316
|
+
fastify.xlogger.logEvent({
|
|
317
|
+
event: "debug.event",
|
|
318
|
+
level: "debug",
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
assert.doesNotThrow(() => {
|
|
323
|
+
fastify.xlogger.logEvent({
|
|
324
|
+
event: "warn.event",
|
|
325
|
+
level: "warn",
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
assert.doesNotThrow(() => {
|
|
330
|
+
fastify.xlogger.logEvent({
|
|
331
|
+
event: "error.event",
|
|
332
|
+
level: "error",
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("getLoggerOptions", () => {
|
|
340
|
+
test("should return valid Pino options", () => {
|
|
341
|
+
const options = getLoggerOptions();
|
|
342
|
+
|
|
343
|
+
assert.ok(options.level, "level should exist");
|
|
344
|
+
assert.ok(options.redact, "redact should exist");
|
|
345
|
+
assert.ok(options.serializers, "serializers should exist");
|
|
346
|
+
assert.ok(options.base, "base should exist");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("should use debug level in non-production", () => {
|
|
350
|
+
const originalEnv = process.env.NODE_ENV;
|
|
351
|
+
process.env.NODE_ENV = "development";
|
|
352
|
+
|
|
353
|
+
const options = getLoggerOptions();
|
|
354
|
+
|
|
355
|
+
assert.strictEqual(options.level, "debug");
|
|
356
|
+
|
|
357
|
+
process.env.NODE_ENV = originalEnv;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("should include pretty transport in non-production", () => {
|
|
361
|
+
const originalEnv = process.env.NODE_ENV;
|
|
362
|
+
process.env.NODE_ENV = "development";
|
|
363
|
+
|
|
364
|
+
const options = getLoggerOptions();
|
|
365
|
+
|
|
366
|
+
assert.ok(options.transport, "transport should exist");
|
|
367
|
+
assert.strictEqual(options.transport.target, "pino-pretty");
|
|
368
|
+
|
|
369
|
+
process.env.NODE_ENV = originalEnv;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("should allow custom service name", () => {
|
|
373
|
+
const options = getLoggerOptions({ serviceName: "my-service" });
|
|
374
|
+
|
|
375
|
+
assert.strictEqual(options.base.service, "my-service");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("should extend redact paths", () => {
|
|
379
|
+
const options = getLoggerOptions({ redactPaths: ["customPath"] });
|
|
380
|
+
|
|
381
|
+
assert.ok(options.redact.paths.includes("customPath"));
|
|
382
|
+
assert.ok(options.redact.paths.includes("password"));
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("Exports", () => {
|
|
387
|
+
test("should export DEFAULT_REDACT_PATHS", () => {
|
|
388
|
+
assert.ok(Array.isArray(DEFAULT_REDACT_PATHS));
|
|
389
|
+
assert.ok(DEFAULT_REDACT_PATHS.length > 0);
|
|
390
|
+
assert.ok(DEFAULT_REDACT_PATHS.includes("password"));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("should export LOG_LEVELS", () => {
|
|
394
|
+
assert.ok(LOG_LEVELS);
|
|
395
|
+
assert.strictEqual(LOG_LEVELS.fatal, 60);
|
|
396
|
+
assert.strictEqual(LOG_LEVELS.error, 50);
|
|
397
|
+
assert.strictEqual(LOG_LEVELS.warn, 40);
|
|
398
|
+
assert.strictEqual(LOG_LEVELS.info, 30);
|
|
399
|
+
assert.strictEqual(LOG_LEVELS.debug, 20);
|
|
400
|
+
assert.strictEqual(LOG_LEVELS.trace, 10);
|
|
401
|
+
});
|
|
402
|
+
});
|