@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 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
+ });