@easyweb/logging 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/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # @easyweb/logging
2
+
3
+ One pino configuration for every Easyweb service, request-scoped context that
4
+ does not have to be threaded by hand, an HTTP access log, and the redaction a
5
+ log needs before it leaves the pod.
6
+
7
+ ## Why it exists
8
+
9
+ `src/lib/logger.ts` was copy-pasted into 21 services. It had drifted into two
10
+ variants; `domain` and `moderation` both defaulted their service name to
11
+ `"billing-service"`; and seven services were missing the guard that stops
12
+ pino-pretty's worker thread making Jest exit 1 under a green report.
13
+
14
+ The bigger problem was correlation. `getLogger(req)` returned a child bound to
15
+ `requestId` and had to be passed down as an explicit `log` parameter. Roughly
16
+ 100 call sites did that; the other ~1,300 logged through the module-level root
17
+ logger and carried no request id at all.
18
+
19
+ ## The mixin
20
+
21
+ `createServiceLogger` installs a pino `mixin` that reads an `AsyncLocalStorage`
22
+ store. Every existing call site gains the ambient fields with no edit:
23
+
24
+ ```ts
25
+ // unchanged call site
26
+ log.info({ projectId }, "Project created");
27
+
28
+ // what it now emits
29
+ { "service": "project-service", "requestId": "…", "userId": "…", "projectId": "…" }
30
+ ```
31
+
32
+ pino merges the mixin UNDER the object the call site passed, so an explicit
33
+ field always wins over an ambient one.
34
+
35
+ ## Usage
36
+
37
+ ```ts
38
+ // src/lib/logger.ts
39
+ import { createServiceLogger } from "@easyweb/logging";
40
+ import config from "../config";
41
+
42
+ const { logger, getLogger } = createServiceLogger({
43
+ serviceName: config.serviceName,
44
+ logLevel: config.logLevel,
45
+ });
46
+
47
+ export { getLogger };
48
+ export default logger;
49
+ ```
50
+
51
+ ```ts
52
+ // src/app.ts — the access log needs the scope, so it goes after requestContext
53
+ app.use(requestContext);
54
+ app.use(createHttpLogger(logger));
55
+ ```
56
+
57
+ Opening a scope outside a request — a BullMQ job, a broker handler:
58
+
59
+ ```ts
60
+ await runWithContext({ jobId: job.id, job: job.data.type }, () => handle(job));
61
+ ```
62
+
63
+ Adding to a scope already running — `authenticate` does this once it has
64
+ verified the token:
65
+
66
+ ```ts
67
+ bindContext({ userId: decoded.sub });
68
+ ```
69
+
70
+ ## The access log
71
+
72
+ One line per finished request, `msg: "request"`.
73
+
74
+ `route` is the route **pattern** (`/billing/me/invoices/:invoiceId`), and that
75
+ is a contract rather than a convenience: the RED metrics are recording rules
76
+ over it, so a per-id value would make the series unbounded. `path` carries the
77
+ real path beside it, from `req.originalUrl` — Express strips the mount prefix
78
+ off `req.path` during router dispatch, so reading that in the finish callback
79
+ names a route that does not exist.
80
+
81
+ The query string is never logged. `?token=…` on the verification route is a live
82
+ credential.
83
+
84
+ `/health`, `/livez`, `/readyz` and `/metrics` are skipped — the kubelet would
85
+ otherwise make the readiness probe the largest log stream in the cluster.
86
+
87
+ ## Redaction
88
+
89
+ Two mechanisms, covering different shapes:
90
+
91
+ - **`REDACT_PATHS`** — pino's fixed-path redaction, for our own payloads.
92
+ Passwords, tokens, cookies and auth headers, at the top level and one level
93
+ down.
94
+ - **`scrubResponseData`** — a recursive, depth- and size-capped walk over
95
+ `err.response.data`, for payloads we do not control. Xendit, Stripe, Resend,
96
+ Meta, Apify, Cloudflare and Gitea all put personal data in an error body, and
97
+ 407 `logger.error({ err })` call sites fed it into the log verbatim and
98
+ uncapped.
99
+
100
+ `err.response.data` is capped at 2 KB **after** scrubbing, not instead of it —
101
+ truncating first would keep whatever fitted, which for a failed charge is the
102
+ payer block.
103
+
104
+ ## What is deliberately NOT redacted
105
+
106
+ Email addresses. auth-service logs one on three lines — signup, an
107
+ operator-create refusal, and a Google registration — and they are the signup
108
+ audit trail: `userId` alone cannot answer "which address did they sign up
109
+ with". The exposure is real and bounded by log retention. See
110
+ `docs/platform/ADR-0001`.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The fields every log line in a scope carries, without any call site naming
3
+ * them.
4
+ *
5
+ * This is the whole reason the package exists. The platform had ~1,389
6
+ * `logger.info({...}, "...")` call sites and correlation reached almost none of
7
+ * them: `getLogger(req)` returned a child bound to `requestId` and had to be
8
+ * threaded down by hand as an explicit `log` parameter, so anything not handed
9
+ * one fell back to the module-level root logger and logged no requestId at all.
10
+ * Threading it through the remaining ~1,300 signatures was never going to
11
+ * happen. A pino `mixin` reading this store injects the fields at log time
12
+ * instead, so every existing call site correlates with no edit.
13
+ *
14
+ * The index signature is deliberate: a caller may add anything it wants to see
15
+ * on every line for the rest of the scope. What must NOT go in are values that
16
+ * are large, that change per line, or that are secret — the whole object is
17
+ * serialized onto every single log record inside the scope.
18
+ */
19
+ export interface LogContext {
20
+ /** From `x-request-id`, or minted. The join key across services. */
21
+ requestId?: string;
22
+ /** Bound by `authenticate` once the access token is verified. */
23
+ userId?: string;
24
+ sessionId?: string;
25
+ projectId?: string;
26
+ /** Broker consume scope. */
27
+ exchange?: string;
28
+ routingKey?: string;
29
+ queue?: string;
30
+ retryCount?: number;
31
+ /** BullMQ job scope. */
32
+ jobId?: string;
33
+ job?: string;
34
+ [key: string]: unknown;
35
+ }
36
+ /**
37
+ * Runs `fn` with `fields` merged over whatever scope is already active.
38
+ *
39
+ * Nesting is additive rather than replacing, so a broker handler that opens a
40
+ * job scope inside a request scope keeps the requestId. A FRESH object is
41
+ * created each time, which is what makes `bindContext`'s mutation safe.
42
+ */
43
+ export declare function runWithContext<T>(fields: LogContext, fn: () => T): T;
44
+ /**
45
+ * Adds fields to the scope that is ALREADY running.
46
+ *
47
+ * `authenticate` needs this: the scope is opened by `requestContext` before any
48
+ * token has been read, and `userId` only becomes known several middlewares
49
+ * later. Mutating in place is what makes that visible to a log line emitted
50
+ * earlier-registered-but-later-run — notably the access log's `res.on("finish")`
51
+ * callback, which captures the store by reference at request start.
52
+ *
53
+ * A no-op outside a scope. That is deliberate: a seeder or a script calling a
54
+ * service method directly has no request, and throwing there would make the
55
+ * logging layer the thing that breaks `npm run seed`.
56
+ */
57
+ export declare function bindContext(fields: LogContext): void;
58
+ /** The active scope, or undefined outside one. */
59
+ export declare function getContext(): LogContext | undefined;
60
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,4BAA4B;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,wBAAwB;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAID;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAGpE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAIpD;AAED,kDAAkD;AAClD,wBAAgB,UAAU,IAAI,UAAU,GAAG,SAAS,CAEnD"}
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runWithContext = runWithContext;
4
+ exports.bindContext = bindContext;
5
+ exports.getContext = getContext;
6
+ const async_hooks_1 = require("async_hooks");
7
+ const storage = new async_hooks_1.AsyncLocalStorage();
8
+ /**
9
+ * Runs `fn` with `fields` merged over whatever scope is already active.
10
+ *
11
+ * Nesting is additive rather than replacing, so a broker handler that opens a
12
+ * job scope inside a request scope keeps the requestId. A FRESH object is
13
+ * created each time, which is what makes `bindContext`'s mutation safe.
14
+ */
15
+ function runWithContext(fields, fn) {
16
+ const parent = storage.getStore();
17
+ return storage.run({ ...parent, ...fields }, fn);
18
+ }
19
+ /**
20
+ * Adds fields to the scope that is ALREADY running.
21
+ *
22
+ * `authenticate` needs this: the scope is opened by `requestContext` before any
23
+ * token has been read, and `userId` only becomes known several middlewares
24
+ * later. Mutating in place is what makes that visible to a log line emitted
25
+ * earlier-registered-but-later-run — notably the access log's `res.on("finish")`
26
+ * callback, which captures the store by reference at request start.
27
+ *
28
+ * A no-op outside a scope. That is deliberate: a seeder or a script calling a
29
+ * service method directly has no request, and throwing there would make the
30
+ * logging layer the thing that breaks `npm run seed`.
31
+ */
32
+ function bindContext(fields) {
33
+ const store = storage.getStore();
34
+ if (!store)
35
+ return;
36
+ Object.assign(store, fields);
37
+ }
38
+ /** The active scope, or undefined outside one. */
39
+ function getContext() {
40
+ return storage.getStore();
41
+ }
42
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":";;AAkDA,wCAGC;AAeD,kCAIC;AAGD,gCAEC;AA7ED,6CAAgD;AAyChD,MAAM,OAAO,GAAG,IAAI,+BAAiB,EAAc,CAAC;AAEpD;;;;;;GAMG;AACH,SAAgB,cAAc,CAAI,MAAkB,EAAE,EAAW;IAC/D,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAClC,OAAO,OAAO,CAAC,GAAG,CAAC,EAAE,GAAG,MAAM,EAAE,GAAG,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC;AACnD,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAgB,WAAW,CAAC,MAAkB;IAC5C,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IACjC,IAAI,CAAC,KAAK;QAAE,OAAO;IACnB,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAC/B,CAAC;AAED,kDAAkD;AAClD,SAAgB,UAAU;IACxB,OAAO,OAAO,CAAC,QAAQ,EAAE,CAAC;AAC5B,CAAC"}
@@ -0,0 +1,7 @@
1
+ export * from "./context";
2
+ export * from "./logger";
3
+ export * from "./middleware";
4
+ export * from "./redact";
5
+ export * from "./serialize";
6
+ export * from "./transport";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./context"), exports);
18
+ __exportStar(require("./logger"), exports);
19
+ __exportStar(require("./middleware"), exports);
20
+ __exportStar(require("./redact"), exports);
21
+ __exportStar(require("./serialize"), exports);
22
+ __exportStar(require("./transport"), exports);
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4CAA0B;AAC1B,2CAAyB;AACzB,+CAA6B;AAC7B,2CAAyB;AACzB,8CAA4B;AAC5B,8CAA4B"}
@@ -0,0 +1,44 @@
1
+ import pino from "pino";
2
+ import type { Request } from "express";
3
+ export interface ServiceLoggerOptions {
4
+ /**
5
+ * The `service` field on every line, and the rotated filename under
6
+ * `LOG_DIR`. Pass `config.serviceName` — every service already has one and
7
+ * every one of them is correct, which the duplicated logger.ts was not:
8
+ * domain's and moderation's both defaulted to "billing-service".
9
+ */
10
+ serviceName: string;
11
+ /** `config.logLevel`. Falls back to info in production, debug elsewhere. */
12
+ logLevel?: string | undefined;
13
+ logDir?: string | undefined;
14
+ env?: string | undefined;
15
+ /**
16
+ * Where the logger writes, when no `transport` is built.
17
+ *
18
+ * Exists for tests, which need to read back what was actually emitted —
19
+ * redaction and the mixin both happen inside pino, so asserting on them from
20
+ * the outside means capturing the stream. Under NODE_ENV=test there is no
21
+ * transport anyway (see `shouldPretty`), so this never competes with one.
22
+ */
23
+ destination?: pino.DestinationStream | undefined;
24
+ }
25
+ export interface ServiceLogger {
26
+ logger: pino.Logger;
27
+ /**
28
+ * Kept for the ~42 files that import it. It is now redundant — the mixin
29
+ * already puts `requestId` on every line inside a request — but harmless, and
30
+ * removing it would be a 42-file change for no behaviour.
31
+ */
32
+ getLogger: (req: Request) => pino.Logger;
33
+ }
34
+ /**
35
+ * Builds the service's root logger.
36
+ *
37
+ * The one genuinely new thing here is `mixin`. pino calls it on every level
38
+ * method and merges the result UNDER the object the call site passed, so an
39
+ * explicit field always wins over the ambient one. That is what lets every
40
+ * existing `log.info({ projectId }, "...")` gain a requestId without being
41
+ * touched.
42
+ */
43
+ export declare function createServiceLogger(options: ServiceLoggerOptions): ServiceLogger;
44
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAMvC,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,IAAI,CAAC,iBAAiB,GAAG,SAAS,CAAC;CAClD;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IACpB;;;;OAIG;IACH,SAAS,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC,MAAM,CAAC;CAC1C;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,oBAAoB,GAC5B,aAAa,CAoDf"}
package/dist/logger.js ADDED
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createServiceLogger = createServiceLogger;
7
+ const pino_1 = __importDefault(require("pino"));
8
+ const context_1 = require("./context");
9
+ const redact_1 = require("./redact");
10
+ const serialize_1 = require("./serialize");
11
+ const transport_1 = require("./transport");
12
+ /**
13
+ * Builds the service's root logger.
14
+ *
15
+ * The one genuinely new thing here is `mixin`. pino calls it on every level
16
+ * method and merges the result UNDER the object the call site passed, so an
17
+ * explicit field always wins over the ambient one. That is what lets every
18
+ * existing `log.info({ projectId }, "...")` gain a requestId without being
19
+ * touched.
20
+ */
21
+ function createServiceLogger(options) {
22
+ const env = options.env ?? process.env.NODE_ENV ?? "development";
23
+ const logDir = options.logDir ?? process.env.LOG_DIR;
24
+ // `...(x ? { x } : {})` rather than `transport: buildTransport(...)`.
25
+ // `exactOptionalPropertyTypes` is on in this package and OFF in every
26
+ // service, so the shape that compiled when this file was duplicated 21 times
27
+ // does not compile here. The spread is the repo's documented idiom for it.
28
+ const transport = (0, transport_1.buildTransport)(logDir, options.serviceName, (0, transport_1.shouldPretty)(env));
29
+ const pinoOptions = {
30
+ level: options.logLevel || (env === "production" ? "info" : "debug"),
31
+ base: {
32
+ service: options.serviceName,
33
+ env,
34
+ },
35
+ timestamp: pino_1.default.stdTimeFunctions.isoTime,
36
+ /**
37
+ * Never throws. A mixin that threw would take down the log call, and log
38
+ * calls are overwhelmingly on error paths — the failure would swallow
39
+ * exactly the line somebody needed.
40
+ */
41
+ mixin() {
42
+ try {
43
+ return (0, context_1.getContext)() ?? {};
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ },
49
+ serializers: {
50
+ err: serialize_1.serializeError,
51
+ error: serialize_1.serializeError,
52
+ },
53
+ redact: redact_1.REDACT,
54
+ ...(transport ? { transport } : {}),
55
+ };
56
+ // Two call forms rather than `pino(opts, undefined)`: pino's second parameter
57
+ // is a real destination, and handing it an explicit undefined relies on an
58
+ // internal falsy check rather than the documented overload.
59
+ const logger = options.destination
60
+ ? (0, pino_1.default)(pinoOptions, options.destination)
61
+ : (0, pino_1.default)(pinoOptions);
62
+ // Read defensively rather than importing the global `Express.Request`
63
+ // augmentation from @easyweb/authentication: that package depends on THIS
64
+ // one, so importing it back would be a cycle. Same call common-errors'
65
+ // errorHandler makes, for the same reason.
66
+ const getLogger = (req) => logger.child({ requestId: req.requestId });
67
+ return { logger, getLogger };
68
+ }
69
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":";;;;;AAiDA,kDAsDC;AAvGD,gDAAwB;AAExB,uCAAuC;AACvC,qCAAkC;AAClC,2CAA6C;AAC7C,2CAA2D;AAmC3D;;;;;;;;GAQG;AACH,SAAgB,mBAAmB,CACjC,OAA6B;IAE7B,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,aAAa,CAAC;IACjE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;IAErD,sEAAsE;IACtE,sEAAsE;IACtE,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAM,SAAS,GAAG,IAAA,0BAAc,EAAC,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE,IAAA,wBAAY,EAAC,GAAG,CAAC,CAAC,CAAC;IAEjF,MAAM,WAAW,GAAuB;QACtC,KAAK,EAAE,OAAO,CAAC,QAAQ,IAAI,CAAC,GAAG,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;QACpE,IAAI,EAAE;YACJ,OAAO,EAAE,OAAO,CAAC,WAAW;YAC5B,GAAG;SACJ;QACD,SAAS,EAAE,cAAI,CAAC,gBAAgB,CAAC,OAAO;QACxC;;;;WAIG;QACH,KAAK;YACH,IAAI,CAAC;gBACH,OAAO,IAAA,oBAAU,GAAE,IAAI,EAAE,CAAC;YAC5B,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,EAAE,CAAC;YACZ,CAAC;QACH,CAAC;QACD,WAAW,EAAE;YACX,GAAG,EAAE,0BAAc;YACnB,KAAK,EAAE,0BAAc;SACtB;QACD,MAAM,EAAE,eAAM;QACd,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACpC,CAAC;IAEF,8EAA8E;IAC9E,2EAA2E;IAC3E,4DAA4D;IAC5D,MAAM,MAAM,GAAG,OAAO,CAAC,WAAW;QAChC,CAAC,CAAC,IAAA,cAAI,EAAC,WAAW,EAAE,OAAO,CAAC,WAAW,CAAC;QACxC,CAAC,CAAC,IAAA,cAAI,EAAC,WAAW,CAAC,CAAC;IAEtB,sEAAsE;IACtE,0EAA0E;IAC1E,uEAAuE;IACvE,2CAA2C;IAC3C,MAAM,SAAS,GAAG,CAAC,GAAY,EAAe,EAAE,CAC9C,MAAM,CAAC,KAAK,CAAC,EAAE,SAAS,EAAG,GAA8B,CAAC,SAAS,EAAE,CAAC,CAAC;IAEzE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AAC/B,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { RequestHandler } from "express";
2
+ import type pino from "pino";
3
+ /**
4
+ * One line per finished request.
5
+ *
6
+ * The platform had NO access log of any kind: the only per-request line came
7
+ * from the shared error handler, and only for 5xx — `logClientErrors` defaults
8
+ * false, so every 400, 401, 403, 404 and 422 in the platform was invisible. A
9
+ * customer reporting "it says my session expired" produced nothing to look at.
10
+ *
11
+ * Mount it immediately after `requestContext`, so the ALS scope exists.
12
+ */
13
+ export declare function createHttpLogger(logger: pino.Logger): RequestHandler;
14
+ //# sourceMappingURL=http-logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-logger.d.ts","sourceRoot":"","sources":["../../src/middleware/http-logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAC/E,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AA6D7B;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,GAAG,cAAc,CA6DpE"}
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createHttpLogger = createHttpLogger;
4
+ const context_1 = require("../context");
5
+ /**
6
+ * Probe paths. The kubelet hits `/livez` every 20s and `/health` every 10s
7
+ * against ~30 pods, so logging them would make the readiness probe the single
8
+ * largest log stream in the cluster and push everything else out of retention.
9
+ *
10
+ * A probe FAILURE is still visible — the readiness gate shows up as an endpoint
11
+ * leaving the Service, which is a metric rather than a log line.
12
+ */
13
+ const SKIP_PATHS = new Set(["/health", "/livez", "/readyz", "/metrics"]);
14
+ /**
15
+ * The route PATTERN, not the URL.
16
+ *
17
+ * This field is a contract, not a convenience: the RED metrics are recording
18
+ * rules over it, so `/billing/me/invoices/:id` must stay one value rather than
19
+ * one per invoice. `req.path` is logged alongside it for the actual id, and
20
+ * carries no query string — same reason admin-service's audit row excludes one:
21
+ * `?token=…` on the verification route would put a live credential in the log.
22
+ */
23
+ function routeOf(req) {
24
+ const raw = req.route?.path;
25
+ const base = req.baseUrl ?? "";
26
+ const join = (p) => `${base}${p === "/" ? "" : p}` || "/";
27
+ if (typeof raw === "string")
28
+ return join(raw);
29
+ if (Array.isArray(raw))
30
+ return raw.map((p) => join(String(p))).join("|");
31
+ if (raw !== undefined)
32
+ return `${base}(pattern)`;
33
+ // No route matched — a 404, or a middleware that answered before routing.
34
+ return base ? `${base}(unmatched)` : "(unmatched)";
35
+ }
36
+ /**
37
+ * The caller's address, best effort.
38
+ *
39
+ * `req.ip` is only the real client where `trust proxy` is set, which is true in
40
+ * exactly three services (admin, analytics, auth). Everywhere else it is the
41
+ * ingress-nginx pod. Reading the first `x-forwarded-for` hop directly gives a
42
+ * useful answer in the other eighteen without changing `trust proxy`, which
43
+ * feeds real security decisions and is not something a logging change should
44
+ * touch.
45
+ *
46
+ * It is spoofable by anyone who can set the header. That is acceptable for a
47
+ * log field and would NOT be for an authorization one.
48
+ */
49
+ function clientIp(req) {
50
+ const forwarded = req.headers["x-forwarded-for"];
51
+ const first = typeof forwarded === "string"
52
+ ? forwarded.split(",")[0]
53
+ : Array.isArray(forwarded)
54
+ ? forwarded[0]
55
+ : undefined;
56
+ return first?.trim() || req.ip;
57
+ }
58
+ /**
59
+ * One line per finished request.
60
+ *
61
+ * The platform had NO access log of any kind: the only per-request line came
62
+ * from the shared error handler, and only for 5xx — `logClientErrors` defaults
63
+ * false, so every 400, 401, 403, 404 and 422 in the platform was invisible. A
64
+ * customer reporting "it says my session expired" produced nothing to look at.
65
+ *
66
+ * Mount it immediately after `requestContext`, so the ALS scope exists.
67
+ */
68
+ function createHttpLogger(logger) {
69
+ return function httpLogger(req, res, next) {
70
+ /**
71
+ * From `originalUrl`, never `req.path`.
72
+ *
73
+ * Express STRIPS the mount point off `req.url` while a mounted router
74
+ * dispatches, and `req.path` is a getter over it — so by the time the
75
+ * finish callback runs, a request to `/billing/me/invoices/x` reports
76
+ * `/me/invoices/x`. Every service in this platform mounts its router at a
77
+ * prefix, so that is every request, and the logged path would name a route
78
+ * that does not exist. `originalUrl` is not mutated.
79
+ *
80
+ * The query string is cut rather than logged: `?token=…` on the
81
+ * verification route is a live credential, and admin-service's audit row
82
+ * excludes one for the same reason.
83
+ */
84
+ const originalUrl = req.originalUrl || req.url;
85
+ const queryAt = originalUrl.indexOf("?");
86
+ const path = queryAt === -1 ? originalUrl : originalUrl.slice(0, queryAt);
87
+ if (SKIP_PATHS.has(path))
88
+ return next();
89
+ const startedAt = process.hrtime.bigint();
90
+ /**
91
+ * Captured by REFERENCE at request start, not read inside the callback.
92
+ *
93
+ * `res.on("finish")` fires from the socket write path, and whether the ALS
94
+ * scope is still active there depends on how the response was flushed —
95
+ * which is not a thing to make the access log depend on. `bindContext`
96
+ * mutates this same object, so `userId` set later by `authenticate` is
97
+ * still visible here.
98
+ */
99
+ const context = (0, context_1.getContext)();
100
+ const fallback = req.requestId;
101
+ res.on("finish", () => {
102
+ const status = res.statusCode;
103
+ const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
104
+ const contentLength = Number(res.getHeader("content-length"));
105
+ logger[level]({
106
+ ...(context ?? (fallback ? { requestId: fallback } : {})),
107
+ method: req.method,
108
+ route: routeOf(req),
109
+ path,
110
+ status,
111
+ durationMs: Math.round(Number(process.hrtime.bigint() - startedAt) / 1e5) / 10,
112
+ ...(Number.isFinite(contentLength) ? { bytes: contentLength } : {}),
113
+ ...(clientIp(req) ? { ip: clientIp(req) } : {}),
114
+ }, "request");
115
+ });
116
+ next();
117
+ };
118
+ }
119
+ //# sourceMappingURL=http-logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-logger.js","sourceRoot":"","sources":["../../src/middleware/http-logger.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export * from "./http-logger";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC"}
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./http-logger"), exports);
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,gDAA8B"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * pino's `redact.paths`, which is a fixed-path mechanism: `a.b.c`, `a[*].b` and
3
+ * `*.b` only. It cannot walk to arbitrary depth, which is why
4
+ * `scrubResponseData` in serialize.ts exists as well — these two are not
5
+ * alternatives, they cover different shapes.
6
+ *
7
+ * This list covers OUR payloads. The service inherited only the last ten
8
+ * entries, all of them axios auth headers, so a request body reaching a log
9
+ * carried its password in the clear.
10
+ */
11
+ export declare const REDACT_PATHS: string[];
12
+ export declare const REDACT: {
13
+ paths: string[];
14
+ censor: string;
15
+ };
16
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../src/redact.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,EAqDhC,CAAC;AAEF,eAAO,MAAM,MAAM;;;CAGlB,CAAC"}
package/dist/redact.js ADDED
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REDACT = exports.REDACT_PATHS = void 0;
4
+ const serialize_1 = require("./serialize");
5
+ /**
6
+ * pino's `redact.paths`, which is a fixed-path mechanism: `a.b.c`, `a[*].b` and
7
+ * `*.b` only. It cannot walk to arbitrary depth, which is why
8
+ * `scrubResponseData` in serialize.ts exists as well — these two are not
9
+ * alternatives, they cover different shapes.
10
+ *
11
+ * This list covers OUR payloads. The service inherited only the last ten
12
+ * entries, all of them axios auth headers, so a request body reaching a log
13
+ * carried its password in the clear.
14
+ */
15
+ exports.REDACT_PATHS = [
16
+ // Credentials at the top level of a logged object.
17
+ "password",
18
+ "newPassword",
19
+ "currentPassword",
20
+ "confirmPassword",
21
+ "passwordHash",
22
+ "token",
23
+ "accessToken",
24
+ "refreshToken",
25
+ "apiKey",
26
+ "secret",
27
+ "clientSecret",
28
+ // …and one level down, which is where a logged `body` or `payload` puts them.
29
+ "*.password",
30
+ "*.newPassword",
31
+ "*.currentPassword",
32
+ "*.confirmPassword",
33
+ "*.passwordHash",
34
+ "*.token",
35
+ "*.accessToken",
36
+ "*.refreshToken",
37
+ "*.apiKey",
38
+ "*.secret",
39
+ "*.clientSecret",
40
+ // Session material. `set-cookie` needs bracket form — the hyphen is not a
41
+ // valid bare path segment.
42
+ "cookie",
43
+ "*.cookie",
44
+ 'headers["set-cookie"]',
45
+ 'res.headers["set-cookie"]',
46
+ "headers.cookie",
47
+ "headers.Cookie",
48
+ "req.headers.cookie",
49
+ "req.headers.Cookie",
50
+ // The ten inherited entries — axios request/response auth headers.
51
+ "err.config.auth",
52
+ "err.config.headers.authorization",
53
+ "err.config.headers.Authorization",
54
+ "err.request._header",
55
+ "config.auth",
56
+ "config.headers.authorization",
57
+ "config.headers.Authorization",
58
+ "headers.authorization",
59
+ "headers.Authorization",
60
+ "*.auth.username",
61
+ // The same, on the shape the access log and error handler produce.
62
+ "req.headers.authorization",
63
+ "req.headers.Authorization",
64
+ ];
65
+ exports.REDACT = {
66
+ paths: exports.REDACT_PATHS,
67
+ censor: serialize_1.CENSOR,
68
+ };
69
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact.js","sourceRoot":"","sources":["../src/redact.ts"],"names":[],"mappings":";;;AAAA,2CAAqC;AAErC;;;;;;;;;GASG;AACU,QAAA,YAAY,GAAa;IACpC,mDAAmD;IACnD,UAAU;IACV,aAAa;IACb,iBAAiB;IACjB,iBAAiB;IACjB,cAAc;IACd,OAAO;IACP,aAAa;IACb,cAAc;IACd,QAAQ;IACR,QAAQ;IACR,cAAc;IAEd,8EAA8E;IAC9E,YAAY;IACZ,eAAe;IACf,mBAAmB;IACnB,mBAAmB;IACnB,gBAAgB;IAChB,SAAS;IACT,eAAe;IACf,gBAAgB;IAChB,UAAU;IACV,UAAU;IACV,gBAAgB;IAEhB,0EAA0E;IAC1E,2BAA2B;IAC3B,QAAQ;IACR,UAAU;IACV,uBAAuB;IACvB,2BAA2B;IAC3B,gBAAgB;IAChB,gBAAgB;IAChB,oBAAoB;IACpB,oBAAoB;IAEpB,mEAAmE;IACnE,iBAAiB;IACjB,kCAAkC;IAClC,kCAAkC;IAClC,qBAAqB;IACrB,aAAa;IACb,8BAA8B;IAC9B,8BAA8B;IAC9B,uBAAuB;IACvB,uBAAuB;IACvB,iBAAiB;IAEjB,mEAAmE;IACnE,2BAA2B;IAC3B,2BAA2B;CAC5B,CAAC;AAEW,QAAA,MAAM,GAAG;IACpB,KAAK,EAAE,oBAAY;IACnB,MAAM,EAAE,kBAAM;CACf,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * How much of a third party's error body is worth keeping. Enough for the
3
+ * gateway's own message and error code, not enough for a page of JSON.
4
+ */
5
+ export declare const MAX_DATA_BYTES = 2048;
6
+ export declare const CENSOR = "[redacted]";
7
+ /**
8
+ * Redacts personal and credential keys inside a third party's response body,
9
+ * then caps the whole thing.
10
+ *
11
+ * The cap is applied AFTER redaction rather than instead of it: truncating
12
+ * first would leave whatever fitted in the first 2 KB, which for a Xendit
13
+ * charge failure is the payer block.
14
+ *
15
+ * Exported so a service can scrub a body it logs deliberately, outside an
16
+ * error.
17
+ */
18
+ export declare function scrubResponseData(data: unknown): unknown;
19
+ /**
20
+ * The `err` / `error` serializer every service uses.
21
+ *
22
+ * The axios branch is unchanged in shape from the copy that was duplicated 21
23
+ * times — the same eight fields, so nothing that reads these logs has to
24
+ * change. What is new is that `data` and `stack` are bounded and scrubbed.
25
+ */
26
+ export declare function serializeError(err: any): unknown;
27
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,eAAO,MAAM,cAAc,OAAO,CAAC;AAKnC,eAAO,MAAM,MAAM,eAAe,CAAC;AAkDnC;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAuBxD;AASD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAehD"}
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CENSOR = exports.MAX_DATA_BYTES = void 0;
7
+ exports.scrubResponseData = scrubResponseData;
8
+ exports.serializeError = serializeError;
9
+ const pino_1 = __importDefault(require("pino"));
10
+ /**
11
+ * How much of a third party's error body is worth keeping. Enough for the
12
+ * gateway's own message and error code, not enough for a page of JSON.
13
+ */
14
+ exports.MAX_DATA_BYTES = 2048;
15
+ const MAX_STACK_CHARS = 4096;
16
+ const MAX_DEPTH = 6;
17
+ const MAX_ARRAY_ITEMS = 20;
18
+ exports.CENSOR = "[redacted]";
19
+ /**
20
+ * Keys whose VALUES are personal data, matched case-insensitively at any depth
21
+ * inside a third party's response body.
22
+ *
23
+ * This list is not about our own payloads — `redact.paths` covers those. It is
24
+ * about what Xendit, Stripe, Resend, Meta, Apify, Cloudflare and Gitea put in
25
+ * an error body, which we neither control nor review. Before this, 407
26
+ * `logger.error({ err, ... })` call sites fed `err.response.data` into the log
27
+ * verbatim and uncapped, and the payment gateways alone return payer name,
28
+ * email, VA number and card last-4 in a failed-charge body.
29
+ */
30
+ const PII_KEY = /(^|_)(email|phone|msisdn|whatsapp|address|nik|npwp|dob|birth_?date)($|_)|(^|_)(full|first|last|given|family|customer|payer|holder|account|recipient|sender)_?name($|_)|card|va_?number|account_?(number|holder)|payer|recipient/i;
31
+ /**
32
+ * Keys whose values are credentials. Same matching, same reason: a gateway that
33
+ * echoes the request back in its error body echoes the key with it.
34
+ */
35
+ const SECRET_KEY = /(^|_)(password|passwd|secret|token|apikey|api_key|authorization|auth|signature|cookie|credential|private_?key)($|_)|token$|secret$/i;
36
+ function scrubValue(value, depth) {
37
+ if (value === null || value === undefined)
38
+ return value;
39
+ if (Array.isArray(value)) {
40
+ if (depth >= MAX_DEPTH)
41
+ return "[depth]";
42
+ const kept = value.slice(0, MAX_ARRAY_ITEMS).map((v) => scrubValue(v, depth + 1));
43
+ return value.length > MAX_ARRAY_ITEMS
44
+ ? [...kept, `[+${value.length - MAX_ARRAY_ITEMS} more]`]
45
+ : kept;
46
+ }
47
+ if (typeof value === "object") {
48
+ if (depth >= MAX_DEPTH)
49
+ return "[depth]";
50
+ const out = {};
51
+ for (const [key, inner] of Object.entries(value)) {
52
+ if (SECRET_KEY.test(key) || PII_KEY.test(key)) {
53
+ out[key] = exports.CENSOR;
54
+ continue;
55
+ }
56
+ out[key] = scrubValue(inner, depth + 1);
57
+ }
58
+ return out;
59
+ }
60
+ return value;
61
+ }
62
+ /**
63
+ * Redacts personal and credential keys inside a third party's response body,
64
+ * then caps the whole thing.
65
+ *
66
+ * The cap is applied AFTER redaction rather than instead of it: truncating
67
+ * first would leave whatever fitted in the first 2 KB, which for a Xendit
68
+ * charge failure is the payer block.
69
+ *
70
+ * Exported so a service can scrub a body it logs deliberately, outside an
71
+ * error.
72
+ */
73
+ function scrubResponseData(data) {
74
+ if (data === null || data === undefined)
75
+ return data;
76
+ if (typeof data === "string") {
77
+ return data.length > exports.MAX_DATA_BYTES
78
+ ? `${data.slice(0, exports.MAX_DATA_BYTES)}…[truncated]`
79
+ : data;
80
+ }
81
+ const scrubbed = scrubValue(data, 0);
82
+ let serialized;
83
+ try {
84
+ serialized = JSON.stringify(scrubbed) ?? "";
85
+ }
86
+ catch {
87
+ // Circular, or a BigInt — `JSON.stringify` throws on both, and a logger
88
+ // that throws while logging an error loses the error it was called about.
89
+ return "[unserializable]";
90
+ }
91
+ if (serialized.length <= exports.MAX_DATA_BYTES)
92
+ return scrubbed;
93
+ return `${serialized.slice(0, exports.MAX_DATA_BYTES)}…[truncated ${serialized.length} bytes]`;
94
+ }
95
+ function capStack(stack) {
96
+ if (typeof stack !== "string")
97
+ return stack;
98
+ return stack.length > MAX_STACK_CHARS
99
+ ? `${stack.slice(0, MAX_STACK_CHARS)}…[truncated]`
100
+ : stack;
101
+ }
102
+ /**
103
+ * The `err` / `error` serializer every service uses.
104
+ *
105
+ * The axios branch is unchanged in shape from the copy that was duplicated 21
106
+ * times — the same eight fields, so nothing that reads these logs has to
107
+ * change. What is new is that `data` and `stack` are bounded and scrubbed.
108
+ */
109
+ function serializeError(err) {
110
+ if (err && (err.isAxiosError || err.config)) {
111
+ return {
112
+ name: err.name,
113
+ message: err.message,
114
+ code: err.code,
115
+ status: err.response?.status,
116
+ data: scrubResponseData(err.response?.data),
117
+ method: err.config?.method,
118
+ url: err.config?.url,
119
+ stack: capStack(err.stack),
120
+ };
121
+ }
122
+ return pino_1.default.stdSerializers.err(err);
123
+ }
124
+ //# sourceMappingURL=serialize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.js","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":";;;;;;AAwEA,8CAuBC;AAgBD,wCAeC;AA9HD,gDAAwB;AAExB;;;GAGG;AACU,QAAA,cAAc,GAAG,IAAI,CAAC;AACnC,MAAM,eAAe,GAAG,IAAI,CAAC;AAC7B,MAAM,SAAS,GAAG,CAAC,CAAC;AACpB,MAAM,eAAe,GAAG,EAAE,CAAC;AAEd,QAAA,MAAM,GAAG,YAAY,CAAC;AAEnC;;;;;;;;;;GAUG;AACH,MAAM,OAAO,GACX,kOAAkO,CAAC;AAErO;;;GAGG;AACH,MAAM,UAAU,GACd,qIAAqI,CAAC;AAExI,SAAS,UAAU,CAAC,KAAc,EAAE,KAAa;IAC/C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAExD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,IAAI,KAAK,IAAI,SAAS;YAAE,OAAO,SAAS,CAAC;QACzC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,eAAe,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;QAClF,OAAO,KAAK,CAAC,MAAM,GAAG,eAAe;YACnC,CAAC,CAAC,CAAC,GAAG,IAAI,EAAE,KAAK,KAAK,CAAC,MAAM,GAAG,eAAe,QAAQ,CAAC;YACxD,CAAC,CAAC,IAAI,CAAC;IACX,CAAC;IAED,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,IAAI,KAAK,IAAI,SAAS;YAAE,OAAO,SAAS,CAAC;QACzC,MAAM,GAAG,GAA4B,EAAE,CAAC;QACxC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAgC,CAAC,EAAE,CAAC;YAC5E,IAAI,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC9C,GAAG,CAAC,GAAG,CAAC,GAAG,cAAM,CAAC;gBAClB,SAAS;YACX,CAAC;YACD,GAAG,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC,KAAK,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;QAC1C,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAgB,iBAAiB,CAAC,IAAa;IAC7C,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IAErD,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC7B,OAAO,IAAI,CAAC,MAAM,GAAG,sBAAc;YACjC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,sBAAc,CAAC,cAAc;YAChD,CAAC,CAAC,IAAI,CAAC;IACX,CAAC;IAED,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAErC,IAAI,UAAkB,CAAC;IACvB,IAAI,CAAC;QACH,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,wEAAwE;QACxE,0EAA0E;QAC1E,OAAO,kBAAkB,CAAC;IAC5B,CAAC;IAED,IAAI,UAAU,CAAC,MAAM,IAAI,sBAAc;QAAE,OAAO,QAAQ,CAAC;IAEzD,OAAO,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,sBAAc,CAAC,eAAe,UAAU,CAAC,MAAM,SAAS,CAAC;AACzF,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,OAAO,KAAK,CAAC,MAAM,GAAG,eAAe;QACnC,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,eAAe,CAAC,cAAc;QAClD,CAAC,CAAC,KAAK,CAAC;AACZ,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,cAAc,CAAC,GAAQ;IACrC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,IAAI,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QAC5C,OAAO;YACL,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,MAAM,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM;YAC5B,IAAI,EAAE,iBAAiB,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC;YAC3C,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM;YAC1B,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG;YACpB,KAAK,EAAE,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC;SAC3B,CAAC;IACJ,CAAC;IAED,OAAO,cAAI,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AACtC,CAAC"}
@@ -0,0 +1,36 @@
1
+ import pino from "pino";
2
+ /**
3
+ * Unchanged from the copy that lived in all 21 services, deliberately — the
4
+ * local-dev experience and the `LOG_DIR` rotation behaviour are not what this
5
+ * package is changing.
6
+ *
7
+ * `LOG_DIR` is unset in every k8s ConfigMap on purpose, so nothing here writes
8
+ * to disk in a deployed environment. That is what allows
9
+ * `readOnlyRootFilesystem: true` on every Deployment, and it is why the
10
+ * aggregation story is "pino writes JSON to stdout and an agent tails the
11
+ * node's pod logs" rather than a sidecar over a shared volume.
12
+ */
13
+ export declare function buildTransport(logDir?: string, name?: string, pretty?: boolean): {
14
+ targets: pino.TransportTargetOptions[];
15
+ } | undefined;
16
+ /**
17
+ * Pretty everywhere but production — EXCEPT under test.
18
+ *
19
+ * pino-pretty is a `transport`, and a transport is a worker thread
20
+ * (thread-stream). Jest cannot reap it: every suite passes, Jest prints a green
21
+ * summary, and then the process exits 1 because the worker outlived it. It is a
22
+ * race, so it stays invisible on a fast machine and fails on a contended CI
23
+ * runner — the failure reads as "Process completed with exit code 1" under a
24
+ * fully green report.
25
+ *
26
+ * `tests/setup/env.ts` deletes LOG_DIR believing that disables the transport.
27
+ * It does not: `buildTransport` pushes pino-pretty on `pretty` alone,
28
+ * independent of logDir.
29
+ *
30
+ * Fourteen services carried this guard and SEVEN did not — domain, moderation,
31
+ * analytics, admin, support, memory and design were all one contended runner
32
+ * away from the same green-but-exit-1 failure. Having it in one place is half
33
+ * the reason this package exists.
34
+ */
35
+ export declare function shouldPretty(nodeEnv?: string | undefined): boolean;
36
+ //# sourceMappingURL=transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AAExB;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,MAAM,EACf,IAAI,SAAQ,EACZ,MAAM,UAAQ,GACb;IAAE,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,CAAA;CAAE,GAAG,SAAS,CA2BxD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,OAAO,qBAAuB,GAAG,OAAO,CAEpE"}
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildTransport = buildTransport;
4
+ exports.shouldPretty = shouldPretty;
5
+ /**
6
+ * Unchanged from the copy that lived in all 21 services, deliberately — the
7
+ * local-dev experience and the `LOG_DIR` rotation behaviour are not what this
8
+ * package is changing.
9
+ *
10
+ * `LOG_DIR` is unset in every k8s ConfigMap on purpose, so nothing here writes
11
+ * to disk in a deployed environment. That is what allows
12
+ * `readOnlyRootFilesystem: true` on every Deployment, and it is why the
13
+ * aggregation story is "pino writes JSON to stdout and an agent tails the
14
+ * node's pod logs" rather than a sidecar over a shared volume.
15
+ */
16
+ function buildTransport(logDir, name = "app", pretty = false) {
17
+ const targets = [];
18
+ if (pretty) {
19
+ targets.push({ target: "pino-pretty", options: { colorize: true } });
20
+ }
21
+ if (logDir) {
22
+ if (!pretty) {
23
+ // destination 1 = stdout fd
24
+ targets.push({ target: "pino/file", options: { destination: 1 } });
25
+ }
26
+ targets.push({
27
+ target: "pino-roll",
28
+ options: {
29
+ file: `${logDir}/${name}`,
30
+ frequency: "daily",
31
+ dateFormat: "yyyy-MM-dd",
32
+ extension: ".log",
33
+ mkdir: true,
34
+ symlink: true,
35
+ limit: { count: 30, removeOtherLogFiles: true },
36
+ },
37
+ });
38
+ }
39
+ return targets.length ? { targets } : undefined;
40
+ }
41
+ /**
42
+ * Pretty everywhere but production — EXCEPT under test.
43
+ *
44
+ * pino-pretty is a `transport`, and a transport is a worker thread
45
+ * (thread-stream). Jest cannot reap it: every suite passes, Jest prints a green
46
+ * summary, and then the process exits 1 because the worker outlived it. It is a
47
+ * race, so it stays invisible on a fast machine and fails on a contended CI
48
+ * runner — the failure reads as "Process completed with exit code 1" under a
49
+ * fully green report.
50
+ *
51
+ * `tests/setup/env.ts` deletes LOG_DIR believing that disables the transport.
52
+ * It does not: `buildTransport` pushes pino-pretty on `pretty` alone,
53
+ * independent of logDir.
54
+ *
55
+ * Fourteen services carried this guard and SEVEN did not — domain, moderation,
56
+ * analytics, admin, support, memory and design were all one contended runner
57
+ * away from the same green-but-exit-1 failure. Having it in one place is half
58
+ * the reason this package exists.
59
+ */
60
+ function shouldPretty(nodeEnv = process.env.NODE_ENV) {
61
+ return nodeEnv !== "production" && nodeEnv !== "test";
62
+ }
63
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":";;AAaA,wCA+BC;AAqBD,oCAEC;AAjED;;;;;;;;;;GAUG;AACH,SAAgB,cAAc,CAC5B,MAAe,EACf,IAAI,GAAG,KAAK,EACZ,MAAM,GAAG,KAAK;IAEd,MAAM,OAAO,GAAkC,EAAE,CAAC;IAElD,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;IACvE,CAAC;IAED,IAAI,MAAM,EAAE,CAAC;QACX,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,4BAA4B;YAC5B,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QACrE,CAAC;QACD,OAAO,CAAC,IAAI,CAAC;YACX,MAAM,EAAE,WAAW;YACnB,OAAO,EAAE;gBACP,IAAI,EAAE,GAAG,MAAM,IAAI,IAAI,EAAE;gBACzB,SAAS,EAAE,OAAO;gBAClB,UAAU,EAAE,YAAY;gBACxB,SAAS,EAAE,MAAM;gBACjB,KAAK,EAAE,IAAI;gBACX,OAAO,EAAE,IAAI;gBACb,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,mBAAmB,EAAE,IAAI,EAAE;aAChD;SACF,CAAC,CAAC;IACL,CAAC;IAED,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAClD,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,YAAY,CAAC,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ;IACzD,OAAO,OAAO,KAAK,YAAY,IAAI,OAAO,KAAK,MAAM,CAAC;AACxD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@easyweb/logging",
3
+ "version": "1.0.0",
4
+ "description": "Shared structured logging for Easyweb microservices: one pino configuration, request-scoped context over AsyncLocalStorage, an HTTP access log, and the redaction every service needs before its logs leave the pod",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "require": "./dist/index.js",
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "npm run clean && tsc",
20
+ "build:watch": "tsc --watch",
21
+ "prepublishOnly": "npm run build",
22
+ "clean": "rimraf dist",
23
+ "test": "jest",
24
+ "typecheck": "tsc --noEmit",
25
+ "typecheck:test": "tsc -p tsconfig.test.json --noEmit"
26
+ },
27
+ "keywords": [
28
+ "microservices",
29
+ "express",
30
+ "logging",
31
+ "pino",
32
+ "observability"
33
+ ],
34
+ "author": "Easy Web Team",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/Alpine-Solusi/website-builder.git",
39
+ "directory": "common-logging"
40
+ },
41
+ "peerDependencies": {
42
+ "express": "^5.0.0",
43
+ "pino": "^10.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@swc/core": "^1.15.47",
47
+ "@swc/jest": "^0.2.39",
48
+ "@types/express": "^5.0.6",
49
+ "@types/jest": "^30.0.0",
50
+ "@types/node": "^26.0.1",
51
+ "express": "^5.1.0",
52
+ "jest": "^30.4.2",
53
+ "pino": "^10.3.1",
54
+ "rimraf": "^6.1.3",
55
+ "typescript": "^6.0.3"
56
+ },
57
+ "engines": {
58
+ "node": ">=18.0.0"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }