@ciq-dev/neoiq-foundation-node 1.0.1-beta.0 → 1.0.1-beta.2

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/dist/index.js CHANGED
@@ -24,8 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  //#endregion
25
25
  const zod = __toESM(require("zod"));
26
26
  const async_hooks = __toESM(require("async_hooks"));
27
- const pino = __toESM(require("pino"));
28
27
  const __opentelemetry_api = __toESM(require("@opentelemetry/api"));
28
+ const pino = __toESM(require("pino"));
29
29
  const __opentelemetry_sdk_node = __toESM(require("@opentelemetry/sdk-node"));
30
30
  const __opentelemetry_auto_instrumentations_node = __toESM(require("@opentelemetry/auto-instrumentations-node"));
31
31
  const __opentelemetry_exporter_trace_otlp_grpc = __toESM(require("@opentelemetry/exporter-trace-otlp-grpc"));
@@ -38,6 +38,7 @@ const crypto = __toESM(require("crypto"));
38
38
  const axios = __toESM(require("axios"));
39
39
  const axios_retry = __toESM(require("axios-retry"));
40
40
  const opossum = __toESM(require("opossum"));
41
+ const node_stream = __toESM(require("node:stream"));
41
42
 
42
43
  //#region src/config.ts
43
44
  const AutoInstrumentationConfigSchema = zod.z.object({
@@ -81,6 +82,11 @@ const RequestLoggingConfigSchema = zod.z.object({
81
82
  maxBodySize: zod.z.number().default(10 * 1024),
82
83
  redactHeaders: zod.z.array(zod.z.string()).optional()
83
84
  }).partial();
85
+ const RedactionConfigSchema = zod.z.object({ additionalPaths: zod.z.array(zod.z.string()).default([]) }).partial();
86
+ const ShutdownConfigSchema = zod.z.object({
87
+ flushOnCrash: zod.z.boolean().default(false),
88
+ flushTimeoutMs: zod.z.number().min(100).default(5e3)
89
+ }).partial();
84
90
  const FoundationConfigSchema = zod.z.object({
85
91
  serviceName: zod.z.string().min(1, "serviceName is required"),
86
92
  serviceVersion: zod.z.string().default(process.env.SERVICE_VERSION || "1.0.0"),
@@ -93,7 +99,9 @@ const FoundationConfigSchema = zod.z.object({
93
99
  features: FeaturesConfigSchema.default({}),
94
100
  otel: OtelConfigSchema.default({}),
95
101
  logging: LoggingConfigSchema.default({}),
96
- requestLogging: RequestLoggingConfigSchema.default({})
102
+ requestLogging: RequestLoggingConfigSchema.default({}),
103
+ redaction: RedactionConfigSchema.default({}),
104
+ shutdown: ShutdownConfigSchema.default({})
97
105
  });
98
106
  /** Parse and validate configuration */
99
107
  function parseConfig(input) {
@@ -111,33 +119,247 @@ function getDefaultOtelEndpoint() {
111
119
 
112
120
  //#endregion
113
121
  //#region src/features/context.ts
122
+ const BAGGAGE_CORRELATION_KEY = "correlation.id";
123
+ function setBaggageCorrelationId(correlationId) {
124
+ const currentBaggage = __opentelemetry_api.propagation.getBaggage(__opentelemetry_api.context.active());
125
+ const baggage = (currentBaggage ?? __opentelemetry_api.propagation.createBaggage()).setEntry(BAGGAGE_CORRELATION_KEY, { value: correlationId });
126
+ return __opentelemetry_api.propagation.setBaggage(__opentelemetry_api.context.active(), baggage);
127
+ }
128
+ function getBaggageCorrelationId() {
129
+ const baggage = __opentelemetry_api.propagation.getBaggage(__opentelemetry_api.context.active());
130
+ return baggage?.getEntry(BAGGAGE_CORRELATION_KEY)?.value;
131
+ }
114
132
  /** Create a new context manager instance */
115
133
  function createContextManager() {
116
134
  const als = new async_hooks.AsyncLocalStorage();
117
135
  return {
118
- getContext: () => als.getStore(),
119
- run(context$3, fn) {
120
- return als.run(context$3, fn);
136
+ getContext() {
137
+ const alsCtx = als.getStore();
138
+ if (!alsCtx) return void 0;
139
+ const baggageCorrelationId = getBaggageCorrelationId();
140
+ if (baggageCorrelationId && baggageCorrelationId !== alsCtx.correlationId) return {
141
+ ...alsCtx,
142
+ correlationId: baggageCorrelationId
143
+ };
144
+ return alsCtx;
145
+ },
146
+ run(context$2, fn) {
147
+ if (context$2.correlationId) {
148
+ const otelCtx = setBaggageCorrelationId(context$2.correlationId);
149
+ return __opentelemetry_api.context.with(otelCtx, () => als.run(context$2, fn));
150
+ }
151
+ return als.run(context$2, fn);
121
152
  },
122
153
  get(key) {
154
+ if (key === "correlationId") return getBaggageCorrelationId() ?? als.getStore()?.correlationId;
123
155
  return als.getStore()?.[key];
124
156
  },
125
157
  update(updates) {
126
158
  const current = als.getStore();
127
159
  if (!current) return void 0;
160
+ if (updates.correlationId) setBaggageCorrelationId(updates.correlationId);
128
161
  Object.assign(current, updates);
129
162
  return current;
163
+ },
164
+ setContextValue(key, value) {
165
+ const current = als.getStore();
166
+ if (!current) return;
167
+ if (!current.contextData) current.contextData = {};
168
+ current.contextData[key] = value;
169
+ },
170
+ getContextValue(key) {
171
+ return als.getStore()?.contextData?.[key];
172
+ },
173
+ getContextData() {
174
+ return als.getStore()?.contextData ?? {};
175
+ },
176
+ setContextData(data) {
177
+ const current = als.getStore();
178
+ if (!current) return;
179
+ current.contextData = {
180
+ ...current.contextData ?? {},
181
+ ...data
182
+ };
183
+ },
184
+ clearContextData() {
185
+ const current = als.getStore();
186
+ if (!current) return;
187
+ current.contextData = {};
130
188
  }
131
189
  };
132
190
  }
133
191
 
192
+ //#endregion
193
+ //#region src/features/redaction.ts
194
+ /**
195
+ * Log Redaction & PII Sanitization
196
+ *
197
+ * Two-layer approach:
198
+ * - Layer A: Pino native `redact` (fast-redact) for key-based redaction on every log call.
199
+ * Compiled at init, near-zero runtime cost.
200
+ * - Layer B: Deep-traverse sanitizer for value-pattern detection (JWTs, AWS keys, etc.).
201
+ * Only used for request/response body logging — NOT on every log call.
202
+ */
203
+ const PLACEHOLDER = "[REDACTED]";
204
+ /**
205
+ * Key names that Pino's fast-redact will censor automatically.
206
+ * Supports wildcards: '*.password' matches nested keys one level deep.
207
+ */
208
+ const REDACT_PATHS = [
209
+ "password",
210
+ "passwd",
211
+ "pass",
212
+ "pwd",
213
+ "secret",
214
+ "secretKey",
215
+ "secret_key",
216
+ "token",
217
+ "accessToken",
218
+ "access_token",
219
+ "refreshToken",
220
+ "refresh_token",
221
+ "idToken",
222
+ "id_token",
223
+ "apiKey",
224
+ "api_key",
225
+ "apiSecret",
226
+ "api_secret",
227
+ "authorization",
228
+ "auth",
229
+ "credentials",
230
+ "privateKey",
231
+ "private_key",
232
+ "cookie",
233
+ "setCookie",
234
+ "set_cookie",
235
+ "creditCard",
236
+ "credit_card",
237
+ "cardNumber",
238
+ "card_number",
239
+ "ccNumber",
240
+ "cc_number",
241
+ "cvv",
242
+ "cvc",
243
+ "securityCode",
244
+ "security_code",
245
+ "accountNumber",
246
+ "account_number",
247
+ "ssn",
248
+ "socialSecurity",
249
+ "social_security",
250
+ "dateOfBirth",
251
+ "date_of_birth",
252
+ "dob",
253
+ "*.password",
254
+ "*.secret",
255
+ "*.token",
256
+ "*.apiKey",
257
+ "*.api_key",
258
+ "*.authorization",
259
+ "*.cookie",
260
+ "*.credentials",
261
+ "*.creditCard",
262
+ "*.cardNumber",
263
+ "*.cvv",
264
+ "*.ssn",
265
+ "*.privateKey",
266
+ "*.private_key"
267
+ ];
268
+ /**
269
+ * Value patterns that indicate a secret regardless of the key name.
270
+ * Used only for body sanitization (Layer B).
271
+ */
272
+ const VALUE_PATTERNS = [
273
+ {
274
+ label: "jwt",
275
+ pattern: /^eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+$/
276
+ },
277
+ {
278
+ label: "aws_access_key",
279
+ pattern: /(?:^|[^A-Za-z0-9])AKIA[0-9A-Z]{16}(?:$|[^A-Za-z0-9])/
280
+ },
281
+ {
282
+ label: "stripe_key",
283
+ pattern: /^[sr]k_(live|test)_[A-Za-z0-9]{10,}$/
284
+ },
285
+ {
286
+ label: "openai_key",
287
+ pattern: /^sk-[A-Za-z0-9_-]{20,}$/
288
+ },
289
+ {
290
+ label: "github_token",
291
+ pattern: /^gh[ps]_[A-Za-z0-9]{36,}$/
292
+ },
293
+ {
294
+ label: "pem_private_key",
295
+ pattern: /-----BEGIN\s+(RSA\s+|EC\s+)?PRIVATE\s+KEY-----/
296
+ },
297
+ {
298
+ label: "connection_string",
299
+ pattern: /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^:]+:[^@]+@/
300
+ },
301
+ {
302
+ label: "bearer_token",
303
+ pattern: /^Bearer\s+\S{10,}$/i
304
+ }
305
+ ];
306
+ const SENSITIVE_KEYS = new Set(REDACT_PATHS.filter((p) => !p.includes("*")).map((k) => k.toLowerCase()));
307
+ const MAX_DEPTH = 10;
308
+ const MAX_KEYS = 200;
309
+ function mask(value) {
310
+ if (typeof value !== "string" || value.length <= 8) return PLACEHOLDER;
311
+ return `${value.slice(0, 4)}${"*".repeat(Math.min(value.length - 8, 20))}${value.slice(-4)}`;
312
+ }
313
+ function isSensitiveValue(value) {
314
+ return VALUE_PATTERNS.some((p) => p.pattern.test(value));
315
+ }
316
+ function sanitizeValue(value, depth) {
317
+ if (depth > MAX_DEPTH) return value;
318
+ if (typeof value === "string" && isSensitiveValue(value)) return mask(value);
319
+ if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, depth + 1));
320
+ if (value !== null && typeof value === "object") return sanitizeObject(value, depth + 1);
321
+ return value;
322
+ }
323
+ function sanitizeObject(obj, depth) {
324
+ if (depth > MAX_DEPTH) return obj;
325
+ const keys = Object.keys(obj);
326
+ if (keys.length > MAX_KEYS) return obj;
327
+ const result = {};
328
+ for (const key of keys) {
329
+ const value = obj[key];
330
+ if (SENSITIVE_KEYS.has(key.toLowerCase())) result[key] = typeof value === "string" ? mask(value) : PLACEHOLDER;
331
+ else result[key] = sanitizeValue(value, depth);
332
+ }
333
+ return result;
334
+ }
335
+ /**
336
+ * Deep-traverse sanitizer for request/response bodies.
337
+ * Checks both key names (deny-list) and value patterns (JWT, AWS keys, etc.).
338
+ * NOT intended for every log call — use Pino native `redact` for that.
339
+ */
340
+ function sanitizeBody(body) {
341
+ if (body === null || body === void 0) return body;
342
+ if (typeof body === "string") return isSensitiveValue(body) ? mask(body) : body;
343
+ if (typeof body !== "object") return body;
344
+ if (Array.isArray(body)) return body.map((item) => sanitizeValue(item, 0));
345
+ return sanitizeObject(body, 0);
346
+ }
347
+ /** Build the Pino `redact` config object for fast-redact integration */
348
+ function buildPinoRedactConfig(additionalPaths = []) {
349
+ return {
350
+ paths: [...REDACT_PATHS, ...additionalPaths],
351
+ censor: PLACEHOLDER
352
+ };
353
+ }
354
+
134
355
  //#endregion
135
356
  //#region src/features/logging.ts
136
357
  /** Create a structured logger with automatic trace context injection */
137
358
  function createLogger(options) {
138
- const { serviceName, serviceVersion, environment, level, prettyPrint, contextManager } = options;
359
+ const { serviceName, serviceVersion, environment, level, prettyPrint, contextManager, additionalRedactPaths = [] } = options;
139
360
  const pinoLogger = (0, pino.default)({
140
361
  level,
362
+ redact: buildPinoRedactConfig(additionalRedactPaths),
141
363
  base: {
142
364
  service: serviceName,
143
365
  version: serviceVersion,
@@ -147,11 +369,17 @@ function createLogger(options) {
147
369
  const span = __opentelemetry_api.trace.getActiveSpan();
148
370
  const spanContext = span?.spanContext();
149
371
  const ctx = contextManager?.getContext();
150
- return {
151
- traceId: spanContext?.traceId || ctx?.traceId,
152
- spanId: spanContext?.spanId || ctx?.spanId,
153
- correlationId: ctx?.correlationId
372
+ const traceId = spanContext?.traceId || ctx?.traceId;
373
+ const spanId = spanContext?.spanId || ctx?.spanId;
374
+ const correlationId = ctx?.correlationId;
375
+ const contextData = ctx?.contextData;
376
+ const result = {
377
+ trace_id: traceId,
378
+ span_id: spanId,
379
+ correlation_id: correlationId
154
380
  };
381
+ if (contextData && Object.keys(contextData).length > 0) result.context = contextData;
382
+ return result;
155
383
  },
156
384
  formatters: { level: (label) => ({ level: label }) },
157
385
  transport: prettyPrint ? {
@@ -185,7 +413,10 @@ function createFallbackLogger(serviceName = "unknown") {
185
413
  ...obj,
186
414
  msg
187
415
  };
188
- console[level === "debug" ? "log" : level](JSON.stringify(logObj));
416
+ const payload = JSON.stringify(logObj);
417
+ if (level === "warn") console.warn(payload);
418
+ else if (level === "error") console.error(payload);
419
+ else console.log(payload);
189
420
  };
190
421
  const fallback = {
191
422
  debug: (obj, msg) => log("debug", obj, msg),
@@ -209,12 +440,26 @@ function getGlobalLogger() {
209
440
  //#region src/features/tracing.ts
210
441
  let sdk = null;
211
442
  let isInitialized$1 = false;
443
+ const MONITORED_MODULES = [
444
+ "pg",
445
+ "mongodb",
446
+ "ioredis",
447
+ "mysql2",
448
+ "express",
449
+ "@grpc/grpc-js"
450
+ ];
451
+ function warnPreloadedModules() {
452
+ for (const mod of MONITORED_MODULES) try {
453
+ if (require.resolve(mod) in (require.cache || {})) console.warn(`[neoiq-foundation] "${mod}" was imported before tracing init. Auto-instrumentation may not work for this module. Use node -r @ciq-dev/neoiq-foundation-node/bootstrap or call createFoundation() first.`);
454
+ } catch {}
455
+ }
212
456
  /** Initialize OpenTelemetry tracing */
213
457
  function setupTracing(options) {
214
458
  if (isInitialized$1) {
215
459
  console.warn("[neoiq-foundation] Tracing already initialized");
216
460
  return;
217
461
  }
462
+ warnPreloadedModules();
218
463
  const { serviceName, serviceVersion, environment, endpoint = getDefaultOtelEndpoint(), autoInstrumentation = {} } = options;
219
464
  const resource = (0, __opentelemetry_resources.resourceFromAttributes)({
220
465
  [__opentelemetry_semantic_conventions.ATTR_SERVICE_NAME]: serviceName,
@@ -371,7 +616,6 @@ function createObservabilityPlugin(options) {
371
616
  const maxBodySize = requestLogging.maxBodySize ?? 10 * 1024;
372
617
  const headersToRedact = requestLogging.redactHeaders ?? DEFAULT_REDACT_HEADERS;
373
618
  const runInContext = contextManager ? (ctx, fn) => contextManager.run(ctx, fn) : (_ctx, fn) => fn();
374
- const tracer = tracingEnabled ? __opentelemetry_api.trace.getTracer("neoiq-foundation-node") : null;
375
619
  let requestCounter;
376
620
  let requestDuration;
377
621
  let requestErrors;
@@ -388,22 +632,12 @@ function createObservabilityPlugin(options) {
388
632
  }
389
633
  const correlationId = request.headers["x-request-id"] || (0, crypto.randomUUID)();
390
634
  reply.header("x-request-id", correlationId);
391
- let span;
392
635
  let traceId = "";
393
636
  let spanId = "";
394
- if (tracer) {
395
- const parentContext = __opentelemetry_api.propagation.extract(__opentelemetry_api.context.active(), request.headers);
396
- span = tracer.startSpan(`${request.method} ${request.routeOptions?.url || request.url}`, {
397
- kind: 1,
398
- attributes: {
399
- "http.method": request.method,
400
- "http.url": request.url,
401
- "http.route": request.routeOptions?.url || request.url,
402
- "http.user_agent": request.headers["user-agent"] || "",
403
- "http.correlation_id": correlationId
404
- }
405
- }, parentContext);
406
- const spanContext = span.spanContext();
637
+ const activeSpan = tracingEnabled ? __opentelemetry_api.trace.getActiveSpan() : void 0;
638
+ if (activeSpan) {
639
+ activeSpan.setAttribute("http.correlation_id", correlationId);
640
+ const spanContext = activeSpan.spanContext();
407
641
  traceId = spanContext.traceId;
408
642
  spanId = spanContext.spanId;
409
643
  }
@@ -413,12 +647,12 @@ function createObservabilityPlugin(options) {
413
647
  spanId,
414
648
  startTime: Date.now()
415
649
  };
416
- request.__span = span;
417
650
  request.__requestContext = requestContext;
418
651
  runInContext(requestContext, () => {
419
652
  const logData = {
420
- correlationId,
421
- traceId: traceId || void 0,
653
+ correlation_id: correlationId,
654
+ trace_id: traceId || void 0,
655
+ span_id: spanId || void 0,
422
656
  method: request.method,
423
657
  url: request.url,
424
658
  ip: request.ip,
@@ -437,8 +671,8 @@ function createObservabilityPlugin(options) {
437
671
  }
438
672
  runInContext(ctx, () => {
439
673
  logger.debug({
440
- correlationId: ctx.correlationId,
441
- body: truncateBody(request.body, maxBodySize)
674
+ correlation_id: ctx.correlationId,
675
+ body: sanitizeBody(truncateBody(request.body, maxBodySize))
442
676
  }, "Request body");
443
677
  done();
444
678
  });
@@ -451,16 +685,15 @@ function createObservabilityPlugin(options) {
451
685
  }
452
686
  runInContext(ctx, () => {
453
687
  logger.debug({
454
- correlationId: ctx.correlationId,
688
+ correlation_id: ctx.correlationId,
455
689
  statusCode: reply.statusCode,
456
- body: truncateBody(payload, maxBodySize)
690
+ body: sanitizeBody(truncateBody(payload, maxBodySize))
457
691
  }, "Response body");
458
692
  done(null, payload);
459
693
  });
460
694
  });
461
695
  fastify.addHook("onResponse", (request, reply, done) => {
462
696
  const ctx = request.__requestContext;
463
- const span = request.__span;
464
697
  if (!ctx) {
465
698
  done();
466
699
  return;
@@ -474,7 +707,7 @@ function createObservabilityPlugin(options) {
474
707
  };
475
708
  runInContext(ctx, () => {
476
709
  logger.info({
477
- correlationId: ctx.correlationId,
710
+ correlation_id: ctx.correlationId,
478
711
  method: request.method,
479
712
  statusCode: reply.statusCode,
480
713
  durationMs
@@ -484,35 +717,35 @@ function createObservabilityPlugin(options) {
484
717
  requestDuration.record(durationMs, labels);
485
718
  if (reply.statusCode >= 400) requestErrors.add(1, labels);
486
719
  }
487
- if (span) {
488
- span.setStatus({ code: reply.statusCode < 400 ? __opentelemetry_api.SpanStatusCode.OK : __opentelemetry_api.SpanStatusCode.ERROR });
489
- span.setAttribute("http.status_code", reply.statusCode);
490
- span.setAttribute("http.response_time_ms", durationMs);
491
- span.end();
720
+ if (tracingEnabled) {
721
+ const activeSpan = __opentelemetry_api.trace.getActiveSpan();
722
+ if (activeSpan) activeSpan.setAttribute("http.response_time_ms", durationMs);
492
723
  }
493
724
  done();
494
725
  });
495
726
  });
496
727
  fastify.addHook("onError", (request, _reply, error, done) => {
497
728
  const ctx = request.__requestContext;
498
- const span = request.__span;
499
729
  if (!ctx) {
500
730
  done();
501
731
  return;
502
732
  }
503
733
  runInContext(ctx, () => {
504
734
  logger.error({
505
- correlationId: ctx.correlationId,
735
+ correlation_id: ctx.correlationId,
506
736
  method: request.method,
507
737
  url: request.url,
508
738
  error: error.message
509
739
  }, "Request failed");
510
- if (span) {
511
- span.setStatus({
512
- code: __opentelemetry_api.SpanStatusCode.ERROR,
513
- message: error.message
514
- });
515
- span.recordException(error);
740
+ if (tracingEnabled) {
741
+ const activeSpan = __opentelemetry_api.trace.getActiveSpan();
742
+ if (activeSpan) {
743
+ activeSpan.setStatus({
744
+ code: __opentelemetry_api.SpanStatusCode.ERROR,
745
+ message: error.message
746
+ });
747
+ activeSpan.recordException(error);
748
+ }
516
749
  }
517
750
  done();
518
751
  });
@@ -642,17 +875,54 @@ function createHttpClient(options) {
642
875
  breaker.on("open", () => logger.warn({ targetService: serviceName }, "Circuit breaker OPEN"));
643
876
  breaker.on("halfOpen", () => logger.info({ targetService: serviceName }, "Circuit breaker HALF-OPEN"));
644
877
  breaker.on("close", () => logger.info({ targetService: serviceName }, "Circuit breaker CLOSED"));
878
+ const originalRequest = client.request.bind(client);
879
+ client.request = (config) => breaker.fire(config);
880
+ client.get = (url, config) => breaker.fire({
881
+ ...config,
882
+ method: "GET",
883
+ url
884
+ });
885
+ client.post = (url, data, config) => breaker.fire({
886
+ ...config,
887
+ method: "POST",
888
+ url,
889
+ data
890
+ });
891
+ client.put = (url, data, config) => breaker.fire({
892
+ ...config,
893
+ method: "PUT",
894
+ url,
895
+ data
896
+ });
897
+ client.delete = (url, config) => breaker.fire({
898
+ ...config,
899
+ method: "DELETE",
900
+ url
901
+ });
902
+ client.patch = (url, data, config) => breaker.fire({
903
+ ...config,
904
+ method: "PATCH",
905
+ url,
906
+ data
907
+ });
908
+ client.__originalRequest = originalRequest;
645
909
  }
646
910
  return client;
647
911
  }
648
912
 
649
913
  //#endregion
650
914
  //#region src/foundation.ts
915
+ const deprecationWarnings = new Set();
916
+ function warnDeprecation(oldPath, newPath) {
917
+ if (deprecationWarnings.has(oldPath)) return;
918
+ deprecationWarnings.add(oldPath);
919
+ console.warn(`[neoiq-foundation] DEPRECATED: foundation.${oldPath}() is deprecated. Use foundation.${newPath}() instead. This alias will be removed in the next major version.`);
920
+ }
651
921
  /** Create a fully configured observability foundation */
652
922
  function createFoundation(input) {
653
923
  const startTime = Date.now();
654
924
  const config = parseConfig(input);
655
- const { serviceName, serviceVersion, environment, features: featuresConfig, otel, logging: loggingConfig, requestLogging: requestLoggingConfig } = config;
925
+ const { serviceName, serviceVersion, environment, features: featuresConfig, otel, logging: loggingConfig, requestLogging: requestLoggingConfig, redaction: redactionConfig, shutdown: shutdownConfig } = config;
656
926
  const features = {
657
927
  tracing: featuresConfig.tracing ?? true,
658
928
  metrics: featuresConfig.metrics ?? true,
@@ -668,7 +938,8 @@ function createFoundation(input) {
668
938
  environment,
669
939
  level: loggingConfig.level ?? "info",
670
940
  prettyPrint: loggingConfig.prettyPrint ?? environment === "development",
671
- contextManager
941
+ contextManager,
942
+ additionalRedactPaths: redactionConfig.additionalPaths
672
943
  });
673
944
  setGlobalLogger(logger);
674
945
  } catch (err) {
@@ -740,17 +1011,137 @@ function createFoundation(input) {
740
1011
  environment,
741
1012
  features
742
1013
  }, "Foundation initialized");
743
- const foundation = {
744
- config,
1014
+ if (shutdownConfig.flushOnCrash) {
1015
+ const flushTimeoutMs = shutdownConfig.flushTimeoutMs ?? 5e3;
1016
+ const crashFlush = (origin, err) => {
1017
+ const error = err instanceof Error ? err : new Error(String(err));
1018
+ logger.error({
1019
+ error: error.message,
1020
+ stack: error.stack,
1021
+ origin
1022
+ }, "Process crash detected, flushing telemetry");
1023
+ const flushPromises = [];
1024
+ if (features.tracing && isTracingEnabled()) flushPromises.push(shutdownTracing());
1025
+ if (features.metrics && isMetricsEnabled()) flushPromises.push(shutdownMetrics());
1026
+ const timeout = new Promise((resolve) => setTimeout(resolve, flushTimeoutMs));
1027
+ Promise.race([Promise.allSettled(flushPromises), timeout]).finally(() => {
1028
+ process.exit(1);
1029
+ });
1030
+ };
1031
+ process.on("uncaughtException", (err) => crashFlush("uncaughtException", err));
1032
+ process.on("unhandledRejection", (reason) => crashFlush("unhandledRejection", reason));
1033
+ logger.info({ flushTimeoutMs }, "Crash-flush handlers registered");
1034
+ }
1035
+ const traceInSpan = async (name, fn) => {
1036
+ const tracer = tracerInstance || getTracer(serviceName);
1037
+ return new Promise((resolve, reject) => {
1038
+ tracer.startActiveSpan(name, async (span) => {
1039
+ try {
1040
+ const result = await fn();
1041
+ span.setStatus({ code: __opentelemetry_api.SpanStatusCode.OK });
1042
+ span.end();
1043
+ resolve(result);
1044
+ } catch (err) {
1045
+ const error = err;
1046
+ span.setStatus({
1047
+ code: __opentelemetry_api.SpanStatusCode.ERROR,
1048
+ message: error.message
1049
+ });
1050
+ span.recordException(error);
1051
+ span.end();
1052
+ logger.error({
1053
+ span: name,
1054
+ error: error.message
1055
+ }, "Span failed");
1056
+ reject(error);
1057
+ }
1058
+ });
1059
+ });
1060
+ };
1061
+ const observability = {
745
1062
  logger,
746
- context: contextManager,
747
1063
  tracer: tracerInstance,
748
1064
  meter: meterInstance,
749
- features,
750
1065
  getTracer: (name) => getTracer(name || serviceName),
751
1066
  getMeter: (name, version) => getMeter(name, version),
752
1067
  getTraceContext,
753
1068
  getActiveSpan,
1069
+ trace: traceInSpan
1070
+ };
1071
+ const httpModule = { createClient: (options) => createHttpClient({
1072
+ ...options,
1073
+ foundation
1074
+ }) };
1075
+ const buildHealthStatus = () => {
1076
+ const tracingUp = !features.tracing || !tracingError && isTracingEnabled();
1077
+ const metricsUp = !features.metrics || !metricsError && isMetricsEnabled();
1078
+ const loggingUp = !loggingError;
1079
+ const allUp = tracingUp && metricsUp && loggingUp;
1080
+ const anyDown = features.tracing && tracingError || features.metrics && metricsError || loggingError;
1081
+ return {
1082
+ status: allUp ? "healthy" : anyDown ? "degraded" : "unhealthy",
1083
+ timestamp: new Date().toISOString(),
1084
+ service: serviceName,
1085
+ version: serviceVersion,
1086
+ uptime: Math.floor((Date.now() - startTime) / 1e3),
1087
+ components: {
1088
+ tracing: {
1089
+ enabled: features.tracing,
1090
+ status: !features.tracing ? "disabled" : tracingError ? "down" : "up",
1091
+ message: tracingError
1092
+ },
1093
+ metrics: {
1094
+ enabled: features.metrics,
1095
+ status: !features.metrics ? "disabled" : metricsError ? "down" : "up",
1096
+ message: metricsError
1097
+ },
1098
+ logging: {
1099
+ enabled: features.logging,
1100
+ status: loggingError ? "down" : "up",
1101
+ message: loggingError
1102
+ }
1103
+ }
1104
+ };
1105
+ };
1106
+ const shutdownFn = async () => {
1107
+ logger.info({}, "Shutting down foundation...");
1108
+ const promises = [];
1109
+ if (features.tracing && isTracingEnabled()) promises.push(shutdownTracing());
1110
+ if (features.metrics && isMetricsEnabled()) promises.push(shutdownMetrics());
1111
+ await Promise.all(promises);
1112
+ logger.info({}, "Foundation shutdown complete");
1113
+ };
1114
+ const isReadyFn = () => {
1115
+ if (features.tracing && !tracingError && !isTracingEnabled()) return false;
1116
+ if (features.metrics && !metricsError && !isMetricsEnabled()) return false;
1117
+ return true;
1118
+ };
1119
+ const safeRunFn = async (fn, fallback) => {
1120
+ try {
1121
+ return await fn();
1122
+ } catch (err) {
1123
+ const error = err;
1124
+ logger.error({
1125
+ error: error.message,
1126
+ stack: error.stack
1127
+ }, "safeRun caught error");
1128
+ return fallback;
1129
+ }
1130
+ };
1131
+ const lifecycle = {
1132
+ health: buildHealthStatus,
1133
+ isReady: isReadyFn,
1134
+ shutdown: shutdownFn,
1135
+ safeRun: safeRunFn
1136
+ };
1137
+ const foundation = {
1138
+ config,
1139
+ features,
1140
+ observability,
1141
+ http: httpModule,
1142
+ lifecycle,
1143
+ logger,
1144
+ context: contextManager,
754
1145
  fastifyPlugin: createObservabilityPlugin({
755
1146
  serviceName,
756
1147
  logger,
@@ -759,91 +1150,47 @@ function createFoundation(input) {
759
1150
  metricsEnabled: features.metrics && !metricsError,
760
1151
  requestLogging: requestLoggingConfig
761
1152
  }),
762
- createHttpClient: (options) => createHttpClient({
763
- ...options,
764
- foundation
765
- }),
766
- shutdown: async () => {
767
- logger.info({}, "Shutting down foundation...");
768
- const promises = [];
769
- if (features.tracing && isTracingEnabled()) promises.push(shutdownTracing());
770
- if (features.metrics && isMetricsEnabled()) promises.push(shutdownMetrics());
771
- await Promise.all(promises);
772
- logger.info({}, "Foundation shutdown complete");
1153
+ tracer: tracerInstance,
1154
+ meter: meterInstance,
1155
+ getTracer: (name) => {
1156
+ warnDeprecation("getTracer", "observability.getTracer");
1157
+ return observability.getTracer(name);
1158
+ },
1159
+ getMeter: (name, version) => {
1160
+ warnDeprecation("getMeter", "observability.getMeter");
1161
+ return observability.getMeter(name, version);
1162
+ },
1163
+ getTraceContext: () => {
1164
+ warnDeprecation("getTraceContext", "observability.getTraceContext");
1165
+ return observability.getTraceContext();
1166
+ },
1167
+ getActiveSpan: () => {
1168
+ warnDeprecation("getActiveSpan", "observability.getActiveSpan");
1169
+ return observability.getActiveSpan();
1170
+ },
1171
+ createHttpClient: (options) => {
1172
+ warnDeprecation("createHttpClient", "http.createClient");
1173
+ return httpModule.createClient(options);
1174
+ },
1175
+ shutdown: () => {
1176
+ warnDeprecation("shutdown", "lifecycle.shutdown");
1177
+ return lifecycle.shutdown();
773
1178
  },
774
1179
  isReady: () => {
775
- if (features.tracing && !tracingError && !isTracingEnabled()) return false;
776
- if (features.metrics && !metricsError && !isMetricsEnabled()) return false;
777
- return true;
1180
+ warnDeprecation("isReady", "lifecycle.isReady");
1181
+ return lifecycle.isReady();
778
1182
  },
779
1183
  health: () => {
780
- const tracingUp = !features.tracing || !tracingError && isTracingEnabled();
781
- const metricsUp = !features.metrics || !metricsError && isMetricsEnabled();
782
- const loggingUp = !loggingError;
783
- const allUp = tracingUp && metricsUp && loggingUp;
784
- const anyDown = features.tracing && tracingError || features.metrics && metricsError || loggingError;
785
- return {
786
- status: allUp ? "healthy" : anyDown ? "degraded" : "unhealthy",
787
- timestamp: new Date().toISOString(),
788
- service: serviceName,
789
- version: serviceVersion,
790
- uptime: Math.floor((Date.now() - startTime) / 1e3),
791
- components: {
792
- tracing: {
793
- enabled: features.tracing,
794
- status: !features.tracing ? "disabled" : tracingError ? "down" : "up",
795
- message: tracingError
796
- },
797
- metrics: {
798
- enabled: features.metrics,
799
- status: !features.metrics ? "disabled" : metricsError ? "down" : "up",
800
- message: metricsError
801
- },
802
- logging: {
803
- enabled: features.logging,
804
- status: loggingError ? "down" : "up",
805
- message: loggingError
806
- }
807
- }
808
- };
1184
+ warnDeprecation("health", "lifecycle.health");
1185
+ return lifecycle.health();
809
1186
  },
810
- trace: async (name, fn) => {
811
- const tracer = tracerInstance || getTracer(serviceName);
812
- return new Promise((resolve, reject) => {
813
- tracer.startActiveSpan(name, async (span) => {
814
- try {
815
- const result = await fn();
816
- span.setStatus({ code: __opentelemetry_api.SpanStatusCode.OK });
817
- span.end();
818
- resolve(result);
819
- } catch (err) {
820
- const error = err;
821
- span.setStatus({
822
- code: __opentelemetry_api.SpanStatusCode.ERROR,
823
- message: error.message
824
- });
825
- span.recordException(error);
826
- span.end();
827
- logger.error({
828
- span: name,
829
- error: error.message
830
- }, "Span failed");
831
- reject(error);
832
- }
833
- });
834
- });
1187
+ trace: (name, fn) => {
1188
+ warnDeprecation("trace", "observability.trace");
1189
+ return observability.trace(name, fn);
835
1190
  },
836
- safeRun: async (fn, fallback) => {
837
- try {
838
- return await fn();
839
- } catch (err) {
840
- const error = err;
841
- logger.error({
842
- error: error.message,
843
- stack: error.stack
844
- }, "safeRun caught error");
845
- return fallback;
846
- }
1191
+ safeRun: (fn, fallback) => {
1192
+ warnDeprecation("safeRun", "lifecycle.safeRun");
1193
+ return lifecycle.safeRun(fn, fallback);
847
1194
  }
848
1195
  };
849
1196
  return foundation;
@@ -851,14 +1198,269 @@ function createFoundation(input) {
851
1198
  /** Alias for createFoundation */
852
1199
  const setupObservability = createFoundation;
853
1200
 
1201
+ //#endregion
1202
+ //#region src/integrations/object-store/aws-s3.ts
1203
+ async function loadAwsS3() {
1204
+ try {
1205
+ const clientMod = await import("@aws-sdk/client-s3");
1206
+ const presignerMod = await import("@aws-sdk/s3-request-presigner");
1207
+ return {
1208
+ S3Client: clientMod.S3Client,
1209
+ PutObjectCommand: clientMod.PutObjectCommand,
1210
+ GetObjectCommand: clientMod.GetObjectCommand,
1211
+ HeadObjectCommand: clientMod.HeadObjectCommand,
1212
+ DeleteObjectCommand: clientMod.DeleteObjectCommand,
1213
+ ListObjectsV2Command: clientMod.ListObjectsV2Command,
1214
+ getSignedUrl: presignerMod.getSignedUrl
1215
+ };
1216
+ } catch (err) {
1217
+ const e = err;
1218
+ throw new Error(`AWS SDK not available. Install optional peer deps: @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner. Original error: ${e.message}`);
1219
+ }
1220
+ }
1221
+ function normalizeBody(body) {
1222
+ return body;
1223
+ }
1224
+ /**
1225
+ * AwsS3ObjectStore - wraps AWS S3 behind the provider-agnostic `ObjectStore` interface.
1226
+ *
1227
+ * This implementation uses dynamic imports so `neoiq-foundation-node` can be used without AWS SDK.
1228
+ */
1229
+ var AwsS3ObjectStore = class {
1230
+ provider = "aws-s3";
1231
+ clientPromise;
1232
+ awsPromise = loadAwsS3();
1233
+ constructor(options = {}) {
1234
+ if (options.client) {
1235
+ this.clientPromise = Promise.resolve(options.client);
1236
+ return;
1237
+ }
1238
+ this.clientPromise = (async () => {
1239
+ const aws = await this.awsPromise;
1240
+ return new aws.S3Client(options.clientOptions ?? {});
1241
+ })();
1242
+ }
1243
+ async client() {
1244
+ return this.clientPromise;
1245
+ }
1246
+ async putObject(ref, body, options = {}) {
1247
+ const aws = await this.awsPromise;
1248
+ const s3 = await this.client();
1249
+ const res = await s3.send(new aws.PutObjectCommand({
1250
+ Bucket: ref.bucket,
1251
+ Key: ref.key,
1252
+ Body: normalizeBody(body),
1253
+ ContentType: options.contentType,
1254
+ CacheControl: options.cacheControl,
1255
+ Metadata: options.metadata
1256
+ }));
1257
+ return {
1258
+ etag: res?.ETag,
1259
+ versionId: res?.VersionId
1260
+ };
1261
+ }
1262
+ async getObject(ref) {
1263
+ const aws = await this.awsPromise;
1264
+ const s3 = await this.client();
1265
+ const res = await s3.send(new aws.GetObjectCommand({
1266
+ Bucket: ref.bucket,
1267
+ Key: ref.key
1268
+ }));
1269
+ if (!res?.Body) {
1270
+ const err = new Error(`S3 GetObject returned empty body: ${ref.bucket}/${ref.key}`);
1271
+ err.code = "EMPTY_BODY";
1272
+ throw err;
1273
+ }
1274
+ return {
1275
+ body: res.Body,
1276
+ contentType: res.ContentType,
1277
+ contentLength: res.ContentLength,
1278
+ etag: res.ETag,
1279
+ versionId: res.VersionId,
1280
+ metadata: res.Metadata,
1281
+ lastModified: res.LastModified
1282
+ };
1283
+ }
1284
+ async headObject(ref) {
1285
+ const aws = await this.awsPromise;
1286
+ const s3 = await this.client();
1287
+ try {
1288
+ const res = await s3.send(new aws.HeadObjectCommand({
1289
+ Bucket: ref.bucket,
1290
+ Key: ref.key
1291
+ }));
1292
+ return {
1293
+ exists: true,
1294
+ contentType: res.ContentType,
1295
+ contentLength: res.ContentLength,
1296
+ etag: res.ETag,
1297
+ versionId: res.VersionId,
1298
+ metadata: res.Metadata,
1299
+ lastModified: res.LastModified
1300
+ };
1301
+ } catch (err) {
1302
+ const e = err;
1303
+ const name = String(e?.name || "");
1304
+ const httpStatus = e?.$metadata?.httpStatusCode;
1305
+ if (httpStatus === 404 || name.includes("NotFound") || name.includes("NoSuchKey")) return { exists: false };
1306
+ throw err;
1307
+ }
1308
+ }
1309
+ async deleteObject(ref) {
1310
+ const aws = await this.awsPromise;
1311
+ const s3 = await this.client();
1312
+ await s3.send(new aws.DeleteObjectCommand({
1313
+ Bucket: ref.bucket,
1314
+ Key: ref.key
1315
+ }));
1316
+ }
1317
+ async listObjects(options) {
1318
+ const aws = await this.awsPromise;
1319
+ const s3 = await this.client();
1320
+ const res = await s3.send(new aws.ListObjectsV2Command({
1321
+ Bucket: options.bucket,
1322
+ Prefix: options.prefix,
1323
+ ContinuationToken: options.continuationToken,
1324
+ MaxKeys: options.maxKeys
1325
+ }));
1326
+ return {
1327
+ objects: (res?.Contents ?? []).map((o) => ({
1328
+ key: o.Key,
1329
+ size: o.Size,
1330
+ etag: o.ETag,
1331
+ lastModified: o.LastModified
1332
+ })),
1333
+ nextContinuationToken: res?.NextContinuationToken
1334
+ };
1335
+ }
1336
+ async presignGetObject(ref, options) {
1337
+ const aws = await this.awsPromise;
1338
+ const s3 = await this.client();
1339
+ return aws.getSignedUrl(s3, new aws.GetObjectCommand({
1340
+ Bucket: ref.bucket,
1341
+ Key: ref.key,
1342
+ ResponseContentType: options.responseContentType
1343
+ }), { expiresIn: options.expiresInSeconds });
1344
+ }
1345
+ async presignPutObject(ref, options) {
1346
+ const aws = await this.awsPromise;
1347
+ const s3 = await this.client();
1348
+ return aws.getSignedUrl(s3, new aws.PutObjectCommand({
1349
+ Bucket: ref.bucket,
1350
+ Key: ref.key,
1351
+ ContentType: options.contentType
1352
+ }), { expiresIn: options.expiresInSeconds });
1353
+ }
1354
+ };
1355
+
1356
+ //#endregion
1357
+ //#region src/integrations/object-store/in-memory.ts
1358
+ function toBuffer(body) {
1359
+ if (typeof body === "string") return Buffer.from(body);
1360
+ if (Buffer.isBuffer(body)) return body;
1361
+ if (body instanceof Uint8Array) return Buffer.from(body);
1362
+ return new Promise((resolve, reject) => {
1363
+ const chunks = [];
1364
+ body.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
1365
+ body.on("end", () => resolve(Buffer.concat(chunks)));
1366
+ body.on("error", reject);
1367
+ });
1368
+ }
1369
+ function simpleEtag(buf) {
1370
+ return `mem-${buf.length}-${buf.subarray(0, 8).toString("hex")}`;
1371
+ }
1372
+ /**
1373
+ * InMemoryObjectStore - useful for local dev, unit tests, and as a safe default.
1374
+ * NOTE: presign* methods are not meaningful here and will throw.
1375
+ */
1376
+ var InMemoryObjectStore = class {
1377
+ provider = "in-memory";
1378
+ buckets = new Map();
1379
+ bucketMap(bucket) {
1380
+ let m = this.buckets.get(bucket);
1381
+ if (!m) {
1382
+ m = new Map();
1383
+ this.buckets.set(bucket, m);
1384
+ }
1385
+ return m;
1386
+ }
1387
+ async putObject(ref, body, options = {}) {
1388
+ const buf = await toBuffer(body);
1389
+ const obj = {
1390
+ body: buf,
1391
+ contentType: options.contentType,
1392
+ cacheControl: options.cacheControl,
1393
+ metadata: options.metadata,
1394
+ etag: simpleEtag(buf),
1395
+ lastModified: new Date()
1396
+ };
1397
+ this.bucketMap(ref.bucket).set(ref.key, obj);
1398
+ return { etag: obj.etag };
1399
+ }
1400
+ async getObject(ref) {
1401
+ const obj = this.bucketMap(ref.bucket).get(ref.key);
1402
+ if (!obj) {
1403
+ const err = new Error(`Object not found: ${ref.bucket}/${ref.key}`);
1404
+ err.code = "OBJECT_NOT_FOUND";
1405
+ throw err;
1406
+ }
1407
+ return {
1408
+ body: node_stream.Readable.from(obj.body),
1409
+ contentType: obj.contentType,
1410
+ contentLength: obj.body.length,
1411
+ etag: obj.etag,
1412
+ metadata: obj.metadata,
1413
+ lastModified: obj.lastModified
1414
+ };
1415
+ }
1416
+ async headObject(ref) {
1417
+ const obj = this.bucketMap(ref.bucket).get(ref.key);
1418
+ if (!obj) return { exists: false };
1419
+ return {
1420
+ exists: true,
1421
+ contentType: obj.contentType,
1422
+ contentLength: obj.body.length,
1423
+ etag: obj.etag,
1424
+ metadata: obj.metadata,
1425
+ lastModified: obj.lastModified
1426
+ };
1427
+ }
1428
+ async deleteObject(ref) {
1429
+ this.bucketMap(ref.bucket).delete(ref.key);
1430
+ }
1431
+ async listObjects(options) {
1432
+ const { bucket, prefix = "" } = options;
1433
+ const map = this.bucketMap(bucket);
1434
+ const objects = [...map.entries()].filter(([key]) => key.startsWith(prefix)).map(([key, obj]) => ({
1435
+ key,
1436
+ size: obj.body.length,
1437
+ etag: obj.etag,
1438
+ lastModified: obj.lastModified
1439
+ })).sort((a, b) => a.key.localeCompare(b.key));
1440
+ return { objects };
1441
+ }
1442
+ async presignGetObject(_ref, _options) {
1443
+ throw new Error("InMemoryObjectStore does not support presigned URLs");
1444
+ }
1445
+ async presignPutObject(_ref, _options) {
1446
+ throw new Error("InMemoryObjectStore does not support presigned URLs");
1447
+ }
1448
+ };
1449
+
854
1450
  //#endregion
855
1451
  exports.AutoInstrumentationConfigSchema = AutoInstrumentationConfigSchema
1452
+ exports.AwsS3ObjectStore = AwsS3ObjectStore
856
1453
  exports.FeaturesConfigSchema = FeaturesConfigSchema
857
1454
  exports.FoundationConfigSchema = FoundationConfigSchema
1455
+ exports.InMemoryObjectStore = InMemoryObjectStore
858
1456
  exports.LoggingConfigSchema = LoggingConfigSchema
859
1457
  exports.OtelConfigSchema = OtelConfigSchema
1458
+ exports.REDACT_PATHS = REDACT_PATHS
1459
+ exports.RedactionConfigSchema = RedactionConfigSchema
860
1460
  exports.RequestLoggingConfigSchema = RequestLoggingConfigSchema
1461
+ exports.ShutdownConfigSchema = ShutdownConfigSchema
861
1462
  exports.SpanStatusCode = __opentelemetry_api.SpanStatusCode
1463
+ exports.buildPinoRedactConfig = buildPinoRedactConfig
862
1464
  exports.context = __opentelemetry_api.context
863
1465
  exports.createContextManager = createContextManager
864
1466
  exports.createFallbackLogger = createFallbackLogger
@@ -877,6 +1479,7 @@ exports.isTracingEnabled = isTracingEnabled
877
1479
  exports.metrics = __opentelemetry_api.metrics
878
1480
  exports.parseConfig = parseConfig
879
1481
  exports.propagation = __opentelemetry_api.propagation
1482
+ exports.sanitizeBody = sanitizeBody
880
1483
  exports.setGlobalLogger = setGlobalLogger
881
1484
  exports.setupMetrics = setupMetrics
882
1485
  exports.setupObservability = setupObservability