@fabbahiense/pulsar-pino-transport 0.3.0 → 0.4.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.
@@ -23,51 +23,232 @@ __export(middleware_exports, {
23
23
  pulsarRequestLogger: () => pulsarRequestLogger
24
24
  });
25
25
  module.exports = __toCommonJS(middleware_exports);
26
+
27
+ // ../shared/src/redact.ts
28
+ var DEFAULT_REDACT_KEYS = [
29
+ "password",
30
+ "passwd",
31
+ "password_hash",
32
+ "secret",
33
+ "client_secret",
34
+ "token",
35
+ "access_token",
36
+ "refresh_token",
37
+ "id_token",
38
+ "session_id",
39
+ "api_key",
40
+ "x-api-key",
41
+ "x-auth-token",
42
+ "authorization",
43
+ "proxy-authorization",
44
+ "cookie",
45
+ "set-cookie"
46
+ ];
47
+ function normalizeKey(key) {
48
+ return String(key).toLowerCase().replace(/[-_]/g, "");
49
+ }
50
+ function redactKeyList(extra = []) {
51
+ return [...new Set([...DEFAULT_REDACT_KEYS, ...extra].map(normalizeKey))];
52
+ }
53
+ function isRedactedKey(key, redactKeys) {
54
+ return redactKeys.includes(normalizeKey(key));
55
+ }
56
+
57
+ // ../shared/src/trace.ts
58
+ var import_node_async_hooks = require("async_hooks");
59
+ var import_node_crypto = require("crypto");
60
+ var TRACEPARENT_HEADER = "traceparent";
61
+ var TRACESTATE_HEADER = "tracestate";
62
+ var TRACE_ID_HEADER = "x-trace-id";
63
+ var MAX_TRACESTATE_CHARS = 512;
64
+ var ZERO_TRACE_ID = "0".repeat(32);
65
+ var ZERO_SPAN_ID = "0".repeat(16);
66
+ var TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$/;
67
+ var TRACE_ID_RE = /^[0-9a-f]{32}$/;
68
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
69
+ function newTraceId() {
70
+ for (; ; ) {
71
+ const id = (0, import_node_crypto.randomBytes)(16).toString("hex");
72
+ if (id !== ZERO_TRACE_ID) return id;
73
+ }
74
+ }
75
+ function newSpanId() {
76
+ for (; ; ) {
77
+ const id = (0, import_node_crypto.randomBytes)(8).toString("hex");
78
+ if (id !== ZERO_SPAN_ID) return id;
79
+ }
80
+ }
81
+ function sha256Hex(value) {
82
+ return (0, import_node_crypto.createHash)("sha256").update(value, "utf8").digest("hex");
83
+ }
84
+ function toW3CTraceId(traceId) {
85
+ const lower = traceId.toLowerCase();
86
+ if (TRACE_ID_RE.test(lower) && lower !== ZERO_TRACE_ID) return lower;
87
+ if (UUID_RE.test(lower)) {
88
+ const hex = lower.replace(/-/g, "");
89
+ if (hex !== ZERO_TRACE_ID) return hex;
90
+ }
91
+ return sha256Hex(traceId).slice(0, 32);
92
+ }
93
+ function parseTraceparent(value) {
94
+ if (typeof value !== "string") return null;
95
+ const header = value.trim();
96
+ const match = TRACEPARENT_RE.exec(header);
97
+ if (!match) return null;
98
+ const [, version, traceId, parentId, flagsHex, rest] = match;
99
+ if (version === "ff") return null;
100
+ if (version === "00" && rest !== void 0) return null;
101
+ if (traceId === ZERO_TRACE_ID || parentId === ZERO_SPAN_ID) return null;
102
+ const flags = parseInt(flagsHex, 16);
103
+ return { version, traceId, parentId, flags, sampled: (flags & 1) === 1 };
104
+ }
105
+ var STORE_KEY = /* @__PURE__ */ Symbol.for("@fabbahiense/pulsar.trace-context.v1");
106
+ function sharedStore() {
107
+ const g = globalThis;
108
+ let store = g[STORE_KEY];
109
+ if (!store) {
110
+ store = new import_node_async_hooks.AsyncLocalStorage();
111
+ g[STORE_KEY] = store;
112
+ }
113
+ return store;
114
+ }
115
+ var traceStore = sharedStore();
116
+ function text(value) {
117
+ return typeof value === "string" && value.length > 0 ? value : void 0;
118
+ }
119
+ function completeContext(init) {
120
+ const ctx = {
121
+ traceId: text(init?.traceId) ?? newTraceId(),
122
+ spanId: text(init?.spanId) ?? newSpanId(),
123
+ sampled: init?.sampled !== false
124
+ };
125
+ const parentSpanId = text(init?.parentSpanId);
126
+ if (parentSpanId) ctx.parentSpanId = parentSpanId;
127
+ const traceState = text(init?.traceState);
128
+ if (traceState) ctx.traceState = traceState;
129
+ const method = text(init?.method);
130
+ if (method) ctx.method = method;
131
+ const url = text(init?.url);
132
+ if (url) ctx.url = url;
133
+ return ctx;
134
+ }
135
+ function childContext(parent) {
136
+ if (!parent || !text(parent.traceId)) return completeContext(null);
137
+ const local = parent;
138
+ return completeContext({
139
+ traceId: parent.traceId,
140
+ parentSpanId: parent.spanId,
141
+ sampled: parent.sampled,
142
+ traceState: parent.traceState,
143
+ method: local.method,
144
+ url: local.url
145
+ });
146
+ }
147
+ function read(carrier, name) {
148
+ let value;
149
+ if (typeof carrier.get === "function") {
150
+ value = carrier.get(name);
151
+ } else {
152
+ const record = carrier;
153
+ value = record[name];
154
+ if (value === void 0) {
155
+ for (const key of Object.keys(record)) {
156
+ if (key.toLowerCase() === name) {
157
+ value = record[key];
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ }
163
+ if (Array.isArray(value)) value = value.find((v) => typeof v === "string");
164
+ return typeof value === "string" && value.length > 0 ? value : void 0;
165
+ }
166
+ function readTraceState(carrier) {
167
+ let raw;
168
+ if (typeof carrier.get === "function") {
169
+ raw = carrier.get(TRACESTATE_HEADER);
170
+ } else {
171
+ const record = carrier;
172
+ raw = record[TRACESTATE_HEADER];
173
+ if (raw === void 0) {
174
+ const key = Object.keys(record).find((k) => k.toLowerCase() === TRACESTATE_HEADER);
175
+ raw = key === void 0 ? void 0 : record[key];
176
+ }
177
+ }
178
+ const joined = Array.isArray(raw) ? raw.filter((v) => typeof v === "string").join(",") : raw;
179
+ if (typeof joined !== "string") return void 0;
180
+ const value = joined.trim();
181
+ return value.length > 0 && value.length <= MAX_TRACESTATE_CHARS ? value : void 0;
182
+ }
183
+ function extractTrace(carrier) {
184
+ if (typeof carrier !== "object" || carrier === null) return null;
185
+ try {
186
+ const source = carrier;
187
+ const legacy = read(source, TRACE_ID_HEADER);
188
+ const parent = parseTraceparent(read(source, TRACEPARENT_HEADER));
189
+ if (legacy !== void 0) {
190
+ if (parent && parent.traceId === toW3CTraceId(legacy)) {
191
+ return withTraceState({ traceId: legacy, spanId: parent.parentId, sampled: parent.sampled }, source);
192
+ }
193
+ return { traceId: legacy, sampled: true };
194
+ }
195
+ if (parent) return withTraceState({ traceId: parent.traceId, spanId: parent.parentId, sampled: parent.sampled }, source);
196
+ return null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ function withTraceState(trace, carrier) {
202
+ const traceState = readTraceState(carrier);
203
+ if (traceState) trace.traceState = traceState;
204
+ return trace;
205
+ }
206
+
207
+ // src/middleware.ts
26
208
  var DEFAULT_SKIP_URLS = ["/health", "/metrics", "/favicon.ico"];
27
- var DEFAULT_REDACT = ["authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token"];
28
209
  var DEFAULT_MAX_BODY = 5e4;
29
210
  function pulsarRequestLogger(options) {
30
211
  const logger = options.logger;
31
212
  if (!logger) throw new Error("pulsarRequestLogger: options.logger is required");
32
213
  const skipUrls = options.skipUrls ?? DEFAULT_SKIP_URLS;
33
- const redactKeys = [...DEFAULT_REDACT, ...options.redactKeys ?? []].map((k) => k.toLowerCase());
214
+ const redactKeys = redactKeyList(options.redactKeys ?? []);
34
215
  const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY;
35
216
  const getUserId = options.getUserId ?? ((req) => req.user?.id);
36
217
  const getLevel = options.getLevel ?? defaultLevel;
37
218
  return function pulsarMw(req, res, next) {
38
219
  const url = req.originalUrl || req.url;
39
- if (skipUrls.some((s) => url.includes(s))) return next();
220
+ const ctx = requestContext(req, url);
221
+ if (skipUrls.some((s) => url.includes(s))) return traceStore.run(ctx, next);
40
222
  const startedAt = Date.now();
41
- const chunks = [];
223
+ const capture = new BodyCapture(maxBodySize);
42
224
  const originalWrite = res.write.bind(res);
43
225
  const originalEnd = res.end.bind(res);
44
226
  res.write = function(chunk, ...rest) {
45
- try {
46
- if (chunk) chunks.push(toBuffer(chunk));
47
- } catch {
48
- }
227
+ capture.add(chunk, rest[0]);
49
228
  return originalWrite(chunk, ...rest);
50
229
  };
51
230
  res.end = function(chunk, ...rest) {
52
- try {
53
- if (chunk) chunks.push(toBuffer(chunk));
54
- } catch {
55
- }
231
+ capture.add(chunk, rest[0]);
56
232
  return originalEnd(chunk, ...rest);
57
233
  };
58
- res.on("finish", () => {
234
+ res.on("finish", () => traceStore.run(ctx, () => {
59
235
  try {
60
236
  const responseTime = Date.now() - startedAt;
61
237
  const status = res.statusCode;
62
238
  const level = getLevel(req, res);
63
- const responseBody = readBuffer(chunks, maxBodySize, getResponseContentType(res));
239
+ const responseBody = readBuffer(capture, maxBodySize, getResponseContentType(res), redactKeys);
64
240
  const payload = {
65
- // Top-level → colunas/filtros
241
+ // Top level: becomes columns and filters
66
242
  req: { method: req.method, url, id: req.id },
67
243
  res: { statusCode: status },
68
244
  responseTime,
69
- traceId: req.id || (typeof req.headers["x-trace-id"] === "string" ? req.headers["x-trace-id"] : void 0),
70
- // metadata → seções coloridas no painel lateral
245
+ // A request id set later (pino-http registered after this middleware) still wins, as it always has.
246
+ traceId: req.id || ctx.traceId,
247
+ // The request's span-end entry.
248
+ spanId: ctx.spanId,
249
+ ...ctx.parentSpanId ? { parentSpanId: ctx.parentSpanId } : {},
250
+ spanKind: "server",
251
+ // metadata: the sections of the side panel
71
252
  metadata: trimUndefined({
72
253
  requestBody: redactDeep(req.body, redactKeys),
73
254
  responseBody,
@@ -83,36 +264,75 @@ function pulsarRequestLogger(options) {
83
264
  } catch (err) {
84
265
  if (typeof console !== "undefined") console.error("[pulsarRequestLogger] failed:", err);
85
266
  }
86
- });
87
- next();
267
+ }));
268
+ traceStore.run(ctx, next);
88
269
  };
89
270
  }
271
+ function requestContext(req, url) {
272
+ let parent = extractTrace(req.headers);
273
+ const id = req.id;
274
+ if (id !== void 0 && id !== null && id !== "") {
275
+ const traceId = String(id);
276
+ if (!parent || parent.traceId !== traceId) parent = { traceId, sampled: parent?.sampled ?? true };
277
+ }
278
+ const ctx = childContext(parent);
279
+ ctx.method = req.method;
280
+ ctx.url = url;
281
+ return ctx;
282
+ }
90
283
  function defaultLevel(_req, res) {
91
284
  if (res.statusCode >= 500) return "error";
92
285
  if (res.statusCode >= 400) return "warn";
93
286
  return "info";
94
287
  }
95
- function toBuffer(chunk) {
288
+ function toBuffer(chunk, encoding) {
96
289
  if (Buffer.isBuffer(chunk)) return chunk;
97
- if (typeof chunk === "string") return Buffer.from(chunk);
290
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
291
+ if (typeof chunk === "string") {
292
+ return typeof encoding === "string" && Buffer.isEncoding(encoding) ? Buffer.from(chunk, encoding) : Buffer.from(chunk);
293
+ }
98
294
  try {
99
295
  return Buffer.from(JSON.stringify(chunk));
100
296
  } catch {
101
297
  return Buffer.alloc(0);
102
298
  }
103
299
  }
104
- function readBuffer(chunks, max, contentType) {
105
- if (chunks.length === 0) return void 0;
106
- const buf = Buffer.concat(chunks);
107
- if (buf.length === 0) return void 0;
108
- if (buf.length > max) {
109
- return `[TRUNCATED ${buf.length}B > ${max}B] ${buf.subarray(0, max).toString("utf8")}\u2026`;
300
+ var BodyCapture = class {
301
+ constructor(max) {
302
+ this.max = max;
303
+ }
304
+ max;
305
+ chunks = [];
306
+ kept = 0;
307
+ total = 0;
308
+ add(chunk, encoding) {
309
+ if (!chunk || typeof chunk === "function") return;
310
+ try {
311
+ const buf = toBuffer(chunk, encoding);
312
+ this.total += buf.length;
313
+ const room = this.max + 1 - this.kept;
314
+ if (room <= 0) return;
315
+ const part = Buffer.from(buf.length > room ? buf.subarray(0, room) : buf);
316
+ this.chunks.push(part);
317
+ this.kept += part.length;
318
+ } catch {
319
+ }
320
+ }
321
+ bytes() {
322
+ return Buffer.concat(this.chunks);
323
+ }
324
+ };
325
+ function readBuffer(capture, max, contentType, redactKeys) {
326
+ if (capture.total === 0) return void 0;
327
+ const buf = capture.bytes();
328
+ if (capture.total > max) {
329
+ return `[TRUNCATED ${capture.total}B > ${max}B] ${buf.subarray(0, max).toString("utf8")}\u2026`;
110
330
  }
111
331
  const raw = buf.toString("utf8");
112
332
  const ct = contentType.toLowerCase();
113
333
  if (ct.includes("application/json")) {
114
334
  try {
115
- return JSON.parse(raw);
335
+ return redactDeep(JSON.parse(raw), redactKeys);
116
336
  } catch {
117
337
  return raw;
118
338
  }
@@ -131,19 +351,25 @@ function redactHeaders(headers, redactKeys) {
131
351
  const out = {};
132
352
  for (const [k, v] of Object.entries(headers)) {
133
353
  if (v === void 0 || v === null) continue;
134
- out[k] = redactKeys.includes(k.toLowerCase()) ? "[REDACTED]" : Array.isArray(v) ? v.join(", ") : String(v);
354
+ out[k] = isRedactedKey(k, redactKeys) ? "[REDACTED]" : Array.isArray(v) ? v.join(", ") : String(v);
135
355
  }
136
356
  return out;
137
357
  }
138
- function redactDeep(value, redactKeys) {
358
+ function redactDeep(value, redactKeys, seen = /* @__PURE__ */ new WeakSet()) {
139
359
  if (value === null || value === void 0) return value;
140
360
  if (typeof value !== "object") return value;
141
- if (Array.isArray(value)) return value.map((v) => redactDeep(v, redactKeys));
142
- const out = {};
143
- for (const [k, v] of Object.entries(value)) {
144
- out[k] = redactKeys.includes(k.toLowerCase()) ? "[REDACTED]" : redactDeep(v, redactKeys);
361
+ if (seen.has(value)) return "[Circular]";
362
+ seen.add(value);
363
+ try {
364
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, redactKeys, seen));
365
+ const out = {};
366
+ for (const [k, v] of Object.entries(value)) {
367
+ out[k] = isRedactedKey(k, redactKeys) ? "[REDACTED]" : redactDeep(v, redactKeys, seen);
368
+ }
369
+ return out;
370
+ } finally {
371
+ seen.delete(value);
145
372
  }
146
- return out;
147
373
  }
148
374
  function trimUndefined(obj) {
149
375
  const out = {};
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Express middleware que loga cada request com payload completo no Pulsar.
2
+ * Express middleware that logs every request to Pulsar with its full payload:
3
+ * one log per request, with method, URL, status, duration, headers, bodies,
4
+ * query and params (redacted and size-capped). Each request runs in its own
5
+ * span, continuing an inbound `x-trace-id` or W3C `traceparent`; with
6
+ * `pulsarMixin()` on the logger, every record logged during the request
7
+ * carries the span's ids.
3
8
  *
4
9
  * @example
5
10
  * ```ts
6
11
  * import pino from 'pino'
12
+ * import { pulsarMixin } from '@fabbahiense/pulsar-pino-transport'
7
13
  * import { pulsarRequestLogger } from '@fabbahiense/pulsar-pino-transport/middleware'
8
14
  *
9
15
  * const logger = pino({
16
+ * mixin: pulsarMixin(),
10
17
  * transport: {
11
18
  * target: '@fabbahiense/pulsar-pino-transport',
12
19
  * options: { url: process.env.PULSAR_URL, apiKey: process.env.PULSAR_API_KEY },
@@ -24,17 +31,17 @@ interface PinoLikeLogger {
24
31
  error: (obj: unknown, msg?: string) => void;
25
32
  }
26
33
  interface PulsarRequestLoggerOptions {
27
- /** Your pino logger (com o transport do Pulsar configurado). */
34
+ /** Your pino logger (with the Pulsar transport configured). */
28
35
  logger: PinoLikeLogger;
29
- /** URLs (substring match) que NÃO devem gerar log. Default: /health, /metrics, /favicon */
36
+ /** URL substrings that must NOT produce a log. Default: /health, /metrics, /favicon.ico */
30
37
  skipUrls?: string[];
31
- /** Chaves a redactar em headers e bodies (case-insensitive). */
38
+ /** Extra keys to redact in headers and bodies, on top of the defaults (passwords, tokens, secrets, API keys, cookies, auth headers). Matching ignores case, `-` and `_`, so `apiKey` also matches `api_key` and `api-key`. */
32
39
  redactKeys?: string[];
33
- /** Tamanho máximo em bytes pra request/response body. Bigger = truncado. Default: 50000 (50KB) */
40
+ /** Max bytes kept per request/response body; bigger bodies are truncated. Default: 50000 (50 KB) */
34
41
  maxBodySize?: number;
35
- /** Extrai o id do user do req pra mandar como `metadata.userId`. */
42
+ /** Extracts the user id from the request, sent as `metadata.userId`. */
36
43
  getUserId?: (req: ExpressLikeRequest) => string | undefined;
37
- /** Sobrescreve o cálculo de level (default: 5xx=error, 4xx=warn, resto=info). */
44
+ /** Overrides the level (default: 5xx=error, 4xx=warn, otherwise info). */
38
45
  getLevel?: (req: ExpressLikeRequest, res: ExpressLikeResponse) => 'info' | 'warn' | 'error';
39
46
  }
40
47
  interface ExpressLikeRequest {
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Express middleware que loga cada request com payload completo no Pulsar.
2
+ * Express middleware that logs every request to Pulsar with its full payload:
3
+ * one log per request, with method, URL, status, duration, headers, bodies,
4
+ * query and params (redacted and size-capped). Each request runs in its own
5
+ * span, continuing an inbound `x-trace-id` or W3C `traceparent`; with
6
+ * `pulsarMixin()` on the logger, every record logged during the request
7
+ * carries the span's ids.
3
8
  *
4
9
  * @example
5
10
  * ```ts
6
11
  * import pino from 'pino'
12
+ * import { pulsarMixin } from '@fabbahiense/pulsar-pino-transport'
7
13
  * import { pulsarRequestLogger } from '@fabbahiense/pulsar-pino-transport/middleware'
8
14
  *
9
15
  * const logger = pino({
16
+ * mixin: pulsarMixin(),
10
17
  * transport: {
11
18
  * target: '@fabbahiense/pulsar-pino-transport',
12
19
  * options: { url: process.env.PULSAR_URL, apiKey: process.env.PULSAR_API_KEY },
@@ -24,17 +31,17 @@ interface PinoLikeLogger {
24
31
  error: (obj: unknown, msg?: string) => void;
25
32
  }
26
33
  interface PulsarRequestLoggerOptions {
27
- /** Your pino logger (com o transport do Pulsar configurado). */
34
+ /** Your pino logger (with the Pulsar transport configured). */
28
35
  logger: PinoLikeLogger;
29
- /** URLs (substring match) que NÃO devem gerar log. Default: /health, /metrics, /favicon */
36
+ /** URL substrings that must NOT produce a log. Default: /health, /metrics, /favicon.ico */
30
37
  skipUrls?: string[];
31
- /** Chaves a redactar em headers e bodies (case-insensitive). */
38
+ /** Extra keys to redact in headers and bodies, on top of the defaults (passwords, tokens, secrets, API keys, cookies, auth headers). Matching ignores case, `-` and `_`, so `apiKey` also matches `api_key` and `api-key`. */
32
39
  redactKeys?: string[];
33
- /** Tamanho máximo em bytes pra request/response body. Bigger = truncado. Default: 50000 (50KB) */
40
+ /** Max bytes kept per request/response body; bigger bodies are truncated. Default: 50000 (50 KB) */
34
41
  maxBodySize?: number;
35
- /** Extrai o id do user do req pra mandar como `metadata.userId`. */
42
+ /** Extracts the user id from the request, sent as `metadata.userId`. */
36
43
  getUserId?: (req: ExpressLikeRequest) => string | undefined;
37
- /** Sobrescreve o cálculo de level (default: 5xx=error, 4xx=warn, resto=info). */
44
+ /** Overrides the level (default: 5xx=error, 4xx=warn, otherwise info). */
38
45
  getLevel?: (req: ExpressLikeRequest, res: ExpressLikeResponse) => 'info' | 'warn' | 'error';
39
46
  }
40
47
  interface ExpressLikeRequest {