@ciq-dev/neoiq-foundation-node 1.0.1-beta.1 → 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,
@@ -150,11 +372,14 @@ function createLogger(options) {
150
372
  const traceId = spanContext?.traceId || ctx?.traceId;
151
373
  const spanId = spanContext?.spanId || ctx?.spanId;
152
374
  const correlationId = ctx?.correlationId;
153
- return {
375
+ const contextData = ctx?.contextData;
376
+ const result = {
154
377
  trace_id: traceId,
155
378
  span_id: spanId,
156
379
  correlation_id: correlationId
157
380
  };
381
+ if (contextData && Object.keys(contextData).length > 0) result.context = contextData;
382
+ return result;
158
383
  },
159
384
  formatters: { level: (label) => ({ level: label }) },
160
385
  transport: prettyPrint ? {
@@ -215,12 +440,26 @@ function getGlobalLogger() {
215
440
  //#region src/features/tracing.ts
216
441
  let sdk = null;
217
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
+ }
218
456
  /** Initialize OpenTelemetry tracing */
219
457
  function setupTracing(options) {
220
458
  if (isInitialized$1) {
221
459
  console.warn("[neoiq-foundation] Tracing already initialized");
222
460
  return;
223
461
  }
462
+ warnPreloadedModules();
224
463
  const { serviceName, serviceVersion, environment, endpoint = getDefaultOtelEndpoint(), autoInstrumentation = {} } = options;
225
464
  const resource = (0, __opentelemetry_resources.resourceFromAttributes)({
226
465
  [__opentelemetry_semantic_conventions.ATTR_SERVICE_NAME]: serviceName,
@@ -377,7 +616,6 @@ function createObservabilityPlugin(options) {
377
616
  const maxBodySize = requestLogging.maxBodySize ?? 10 * 1024;
378
617
  const headersToRedact = requestLogging.redactHeaders ?? DEFAULT_REDACT_HEADERS;
379
618
  const runInContext = contextManager ? (ctx, fn) => contextManager.run(ctx, fn) : (_ctx, fn) => fn();
380
- const tracer = tracingEnabled ? __opentelemetry_api.trace.getTracer("neoiq-foundation-node") : null;
381
619
  let requestCounter;
382
620
  let requestDuration;
383
621
  let requestErrors;
@@ -394,22 +632,12 @@ function createObservabilityPlugin(options) {
394
632
  }
395
633
  const correlationId = request.headers["x-request-id"] || (0, crypto.randomUUID)();
396
634
  reply.header("x-request-id", correlationId);
397
- let span;
398
635
  let traceId = "";
399
636
  let spanId = "";
400
- if (tracer) {
401
- const parentContext = __opentelemetry_api.propagation.extract(__opentelemetry_api.context.active(), request.headers);
402
- span = tracer.startSpan(`${request.method} ${request.routeOptions?.url || request.url}`, {
403
- kind: 1,
404
- attributes: {
405
- "http.method": request.method,
406
- "http.url": request.url,
407
- "http.route": request.routeOptions?.url || request.url,
408
- "http.user_agent": request.headers["user-agent"] || "",
409
- "http.correlation_id": correlationId
410
- }
411
- }, parentContext);
412
- 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();
413
641
  traceId = spanContext.traceId;
414
642
  spanId = spanContext.spanId;
415
643
  }
@@ -419,7 +647,6 @@ function createObservabilityPlugin(options) {
419
647
  spanId,
420
648
  startTime: Date.now()
421
649
  };
422
- request.__span = span;
423
650
  request.__requestContext = requestContext;
424
651
  runInContext(requestContext, () => {
425
652
  const logData = {
@@ -445,7 +672,7 @@ function createObservabilityPlugin(options) {
445
672
  runInContext(ctx, () => {
446
673
  logger.debug({
447
674
  correlation_id: ctx.correlationId,
448
- body: truncateBody(request.body, maxBodySize)
675
+ body: sanitizeBody(truncateBody(request.body, maxBodySize))
449
676
  }, "Request body");
450
677
  done();
451
678
  });
@@ -460,14 +687,13 @@ function createObservabilityPlugin(options) {
460
687
  logger.debug({
461
688
  correlation_id: ctx.correlationId,
462
689
  statusCode: reply.statusCode,
463
- body: truncateBody(payload, maxBodySize)
690
+ body: sanitizeBody(truncateBody(payload, maxBodySize))
464
691
  }, "Response body");
465
692
  done(null, payload);
466
693
  });
467
694
  });
468
695
  fastify.addHook("onResponse", (request, reply, done) => {
469
696
  const ctx = request.__requestContext;
470
- const span = request.__span;
471
697
  if (!ctx) {
472
698
  done();
473
699
  return;
@@ -491,18 +717,15 @@ function createObservabilityPlugin(options) {
491
717
  requestDuration.record(durationMs, labels);
492
718
  if (reply.statusCode >= 400) requestErrors.add(1, labels);
493
719
  }
494
- if (span) {
495
- span.setStatus({ code: reply.statusCode < 400 ? __opentelemetry_api.SpanStatusCode.OK : __opentelemetry_api.SpanStatusCode.ERROR });
496
- span.setAttribute("http.status_code", reply.statusCode);
497
- span.setAttribute("http.response_time_ms", durationMs);
498
- span.end();
720
+ if (tracingEnabled) {
721
+ const activeSpan = __opentelemetry_api.trace.getActiveSpan();
722
+ if (activeSpan) activeSpan.setAttribute("http.response_time_ms", durationMs);
499
723
  }
500
724
  done();
501
725
  });
502
726
  });
503
727
  fastify.addHook("onError", (request, _reply, error, done) => {
504
728
  const ctx = request.__requestContext;
505
- const span = request.__span;
506
729
  if (!ctx) {
507
730
  done();
508
731
  return;
@@ -514,12 +737,15 @@ function createObservabilityPlugin(options) {
514
737
  url: request.url,
515
738
  error: error.message
516
739
  }, "Request failed");
517
- if (span) {
518
- span.setStatus({
519
- code: __opentelemetry_api.SpanStatusCode.ERROR,
520
- message: error.message
521
- });
522
- 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
+ }
523
749
  }
524
750
  done();
525
751
  });
@@ -649,17 +875,54 @@ function createHttpClient(options) {
649
875
  breaker.on("open", () => logger.warn({ targetService: serviceName }, "Circuit breaker OPEN"));
650
876
  breaker.on("halfOpen", () => logger.info({ targetService: serviceName }, "Circuit breaker HALF-OPEN"));
651
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;
652
909
  }
653
910
  return client;
654
911
  }
655
912
 
656
913
  //#endregion
657
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
+ }
658
921
  /** Create a fully configured observability foundation */
659
922
  function createFoundation(input) {
660
923
  const startTime = Date.now();
661
924
  const config = parseConfig(input);
662
- 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;
663
926
  const features = {
664
927
  tracing: featuresConfig.tracing ?? true,
665
928
  metrics: featuresConfig.metrics ?? true,
@@ -675,7 +938,8 @@ function createFoundation(input) {
675
938
  environment,
676
939
  level: loggingConfig.level ?? "info",
677
940
  prettyPrint: loggingConfig.prettyPrint ?? environment === "development",
678
- contextManager
941
+ contextManager,
942
+ additionalRedactPaths: redactionConfig.additionalPaths
679
943
  });
680
944
  setGlobalLogger(logger);
681
945
  } catch (err) {
@@ -747,17 +1011,137 @@ function createFoundation(input) {
747
1011
  environment,
748
1012
  features
749
1013
  }, "Foundation initialized");
750
- const foundation = {
751
- 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 = {
752
1062
  logger,
753
- context: contextManager,
754
1063
  tracer: tracerInstance,
755
1064
  meter: meterInstance,
756
- features,
757
1065
  getTracer: (name) => getTracer(name || serviceName),
758
1066
  getMeter: (name, version) => getMeter(name, version),
759
1067
  getTraceContext,
760
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,
761
1145
  fastifyPlugin: createObservabilityPlugin({
762
1146
  serviceName,
763
1147
  logger,
@@ -766,91 +1150,47 @@ function createFoundation(input) {
766
1150
  metricsEnabled: features.metrics && !metricsError,
767
1151
  requestLogging: requestLoggingConfig
768
1152
  }),
769
- createHttpClient: (options) => createHttpClient({
770
- ...options,
771
- foundation
772
- }),
773
- shutdown: async () => {
774
- logger.info({}, "Shutting down foundation...");
775
- const promises = [];
776
- if (features.tracing && isTracingEnabled()) promises.push(shutdownTracing());
777
- if (features.metrics && isMetricsEnabled()) promises.push(shutdownMetrics());
778
- await Promise.all(promises);
779
- 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();
780
1178
  },
781
1179
  isReady: () => {
782
- if (features.tracing && !tracingError && !isTracingEnabled()) return false;
783
- if (features.metrics && !metricsError && !isMetricsEnabled()) return false;
784
- return true;
1180
+ warnDeprecation("isReady", "lifecycle.isReady");
1181
+ return lifecycle.isReady();
785
1182
  },
786
1183
  health: () => {
787
- const tracingUp = !features.tracing || !tracingError && isTracingEnabled();
788
- const metricsUp = !features.metrics || !metricsError && isMetricsEnabled();
789
- const loggingUp = !loggingError;
790
- const allUp = tracingUp && metricsUp && loggingUp;
791
- const anyDown = features.tracing && tracingError || features.metrics && metricsError || loggingError;
792
- return {
793
- status: allUp ? "healthy" : anyDown ? "degraded" : "unhealthy",
794
- timestamp: new Date().toISOString(),
795
- service: serviceName,
796
- version: serviceVersion,
797
- uptime: Math.floor((Date.now() - startTime) / 1e3),
798
- components: {
799
- tracing: {
800
- enabled: features.tracing,
801
- status: !features.tracing ? "disabled" : tracingError ? "down" : "up",
802
- message: tracingError
803
- },
804
- metrics: {
805
- enabled: features.metrics,
806
- status: !features.metrics ? "disabled" : metricsError ? "down" : "up",
807
- message: metricsError
808
- },
809
- logging: {
810
- enabled: features.logging,
811
- status: loggingError ? "down" : "up",
812
- message: loggingError
813
- }
814
- }
815
- };
1184
+ warnDeprecation("health", "lifecycle.health");
1185
+ return lifecycle.health();
816
1186
  },
817
- trace: async (name, fn) => {
818
- const tracer = tracerInstance || getTracer(serviceName);
819
- return new Promise((resolve, reject) => {
820
- tracer.startActiveSpan(name, async (span) => {
821
- try {
822
- const result = await fn();
823
- span.setStatus({ code: __opentelemetry_api.SpanStatusCode.OK });
824
- span.end();
825
- resolve(result);
826
- } catch (err) {
827
- const error = err;
828
- span.setStatus({
829
- code: __opentelemetry_api.SpanStatusCode.ERROR,
830
- message: error.message
831
- });
832
- span.recordException(error);
833
- span.end();
834
- logger.error({
835
- span: name,
836
- error: error.message
837
- }, "Span failed");
838
- reject(error);
839
- }
840
- });
841
- });
1187
+ trace: (name, fn) => {
1188
+ warnDeprecation("trace", "observability.trace");
1189
+ return observability.trace(name, fn);
842
1190
  },
843
- safeRun: async (fn, fallback) => {
844
- try {
845
- return await fn();
846
- } catch (err) {
847
- const error = err;
848
- logger.error({
849
- error: error.message,
850
- stack: error.stack
851
- }, "safeRun caught error");
852
- return fallback;
853
- }
1191
+ safeRun: (fn, fallback) => {
1192
+ warnDeprecation("safeRun", "lifecycle.safeRun");
1193
+ return lifecycle.safeRun(fn, fallback);
854
1194
  }
855
1195
  };
856
1196
  return foundation;
@@ -858,14 +1198,269 @@ function createFoundation(input) {
858
1198
  /** Alias for createFoundation */
859
1199
  const setupObservability = createFoundation;
860
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
+
861
1450
  //#endregion
862
1451
  exports.AutoInstrumentationConfigSchema = AutoInstrumentationConfigSchema
1452
+ exports.AwsS3ObjectStore = AwsS3ObjectStore
863
1453
  exports.FeaturesConfigSchema = FeaturesConfigSchema
864
1454
  exports.FoundationConfigSchema = FoundationConfigSchema
1455
+ exports.InMemoryObjectStore = InMemoryObjectStore
865
1456
  exports.LoggingConfigSchema = LoggingConfigSchema
866
1457
  exports.OtelConfigSchema = OtelConfigSchema
1458
+ exports.REDACT_PATHS = REDACT_PATHS
1459
+ exports.RedactionConfigSchema = RedactionConfigSchema
867
1460
  exports.RequestLoggingConfigSchema = RequestLoggingConfigSchema
1461
+ exports.ShutdownConfigSchema = ShutdownConfigSchema
868
1462
  exports.SpanStatusCode = __opentelemetry_api.SpanStatusCode
1463
+ exports.buildPinoRedactConfig = buildPinoRedactConfig
869
1464
  exports.context = __opentelemetry_api.context
870
1465
  exports.createContextManager = createContextManager
871
1466
  exports.createFallbackLogger = createFallbackLogger
@@ -884,6 +1479,7 @@ exports.isTracingEnabled = isTracingEnabled
884
1479
  exports.metrics = __opentelemetry_api.metrics
885
1480
  exports.parseConfig = parseConfig
886
1481
  exports.propagation = __opentelemetry_api.propagation
1482
+ exports.sanitizeBody = sanitizeBody
887
1483
  exports.setGlobalLogger = setGlobalLogger
888
1484
  exports.setupMetrics = setupMetrics
889
1485
  exports.setupObservability = setupObservability