@ciq-dev/neoiq-foundation-node 1.0.0 → 1.0.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -68
- package/dist/index.d.mts +573 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +570 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +885 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +832 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +27 -21
- package/dist/http-client.d.ts +0 -80
- package/dist/http-client.d.ts.map +0 -1
- package/dist/http-client.js +0 -188
- package/dist/http-client.js.map +0 -1
- package/dist/observability.d.ts +0 -132
- package/dist/observability.d.ts.map +0 -1
- package/dist/observability.js +0 -246
- package/dist/observability.js.map +0 -1
- package/dist/plugin.d.ts +0 -40
- package/dist/plugin.d.ts.map +0 -1
- package/dist/plugin.js +0 -176
- package/dist/plugin.js.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
3
|
+
import pino from "pino";
|
|
4
|
+
import { SpanStatusCode, SpanStatusCode as SpanStatusCode$1, context, context as context$1, metrics, propagation, propagation as propagation$1, trace, trace as trace$1 } from "@opentelemetry/api";
|
|
5
|
+
import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
6
|
+
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
7
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
|
|
8
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
9
|
+
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
10
|
+
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc";
|
|
11
|
+
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
12
|
+
import fp from "fastify-plugin";
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
import axios from "axios";
|
|
15
|
+
import axiosRetry from "axios-retry";
|
|
16
|
+
import CircuitBreaker from "opossum";
|
|
17
|
+
|
|
18
|
+
//#region src/config.ts
|
|
19
|
+
const AutoInstrumentationConfigSchema = z.object({
|
|
20
|
+
http: z.boolean().default(true),
|
|
21
|
+
fastify: z.boolean().default(true),
|
|
22
|
+
express: z.boolean().default(true),
|
|
23
|
+
mongodb: z.boolean().default(true),
|
|
24
|
+
pg: z.boolean().default(true),
|
|
25
|
+
mysql: z.boolean().default(true),
|
|
26
|
+
redis: z.boolean().default(true),
|
|
27
|
+
ioredis: z.boolean().default(true),
|
|
28
|
+
grpc: z.boolean().default(true),
|
|
29
|
+
fs: z.boolean().default(false),
|
|
30
|
+
dns: z.boolean().default(false)
|
|
31
|
+
}).partial();
|
|
32
|
+
const FeaturesConfigSchema = z.object({
|
|
33
|
+
tracing: z.boolean().default(true),
|
|
34
|
+
metrics: z.boolean().default(true),
|
|
35
|
+
logging: z.boolean().default(true),
|
|
36
|
+
autoInstrumentation: AutoInstrumentationConfigSchema.default({})
|
|
37
|
+
}).partial();
|
|
38
|
+
const DEFAULT_OTEL_ENDPOINT = "http://otel-stack-deployment-collector.observability.svc.cluster.local:4317";
|
|
39
|
+
const OtelConfigSchema = z.object({
|
|
40
|
+
endpoint: z.string().default(DEFAULT_OTEL_ENDPOINT),
|
|
41
|
+
metricsIntervalMs: z.number().min(1e3).default(5e3),
|
|
42
|
+
traceSampleRate: z.number().min(0).max(1).default(1)
|
|
43
|
+
}).partial();
|
|
44
|
+
const LoggingConfigSchema = z.object({
|
|
45
|
+
level: z.enum([
|
|
46
|
+
"debug",
|
|
47
|
+
"info",
|
|
48
|
+
"warn",
|
|
49
|
+
"error"
|
|
50
|
+
]).default("info"),
|
|
51
|
+
prettyPrint: z.boolean().optional()
|
|
52
|
+
}).partial();
|
|
53
|
+
const RequestLoggingConfigSchema = z.object({
|
|
54
|
+
logHeaders: z.boolean().default(true),
|
|
55
|
+
logBody: z.boolean().default(false),
|
|
56
|
+
logResponseBody: z.boolean().default(false),
|
|
57
|
+
maxBodySize: z.number().default(10 * 1024),
|
|
58
|
+
redactHeaders: z.array(z.string()).optional()
|
|
59
|
+
}).partial();
|
|
60
|
+
const FoundationConfigSchema = z.object({
|
|
61
|
+
serviceName: z.string().min(1, "serviceName is required"),
|
|
62
|
+
serviceVersion: z.string().default(process.env.SERVICE_VERSION || "1.0.0"),
|
|
63
|
+
environment: z.enum([
|
|
64
|
+
"development",
|
|
65
|
+
"staging",
|
|
66
|
+
"qa",
|
|
67
|
+
"production"
|
|
68
|
+
]).default(process.env.NODE_ENV || "development"),
|
|
69
|
+
features: FeaturesConfigSchema.default({}),
|
|
70
|
+
otel: OtelConfigSchema.default({}),
|
|
71
|
+
logging: LoggingConfigSchema.default({}),
|
|
72
|
+
requestLogging: RequestLoggingConfigSchema.default({})
|
|
73
|
+
});
|
|
74
|
+
/** Parse and validate configuration */
|
|
75
|
+
function parseConfig(input) {
|
|
76
|
+
const result = FoundationConfigSchema.safeParse(input);
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
const errors = result.error.errors.map((e) => ` - ${e.path.join(".")}: ${e.message}`).join("\n");
|
|
79
|
+
throw new Error(`Invalid foundation configuration:\n${errors}`);
|
|
80
|
+
}
|
|
81
|
+
return result.data;
|
|
82
|
+
}
|
|
83
|
+
/** Get default OTEL endpoint */
|
|
84
|
+
function getDefaultOtelEndpoint() {
|
|
85
|
+
return process.env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_OTEL_ENDPOINT;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/features/context.ts
|
|
90
|
+
/** Create a new context manager instance */
|
|
91
|
+
function createContextManager() {
|
|
92
|
+
const als = new AsyncLocalStorage();
|
|
93
|
+
return {
|
|
94
|
+
getContext: () => als.getStore(),
|
|
95
|
+
run(context$2, fn) {
|
|
96
|
+
return als.run(context$2, fn);
|
|
97
|
+
},
|
|
98
|
+
get(key) {
|
|
99
|
+
return als.getStore()?.[key];
|
|
100
|
+
},
|
|
101
|
+
update(updates) {
|
|
102
|
+
const current = als.getStore();
|
|
103
|
+
if (!current) return void 0;
|
|
104
|
+
Object.assign(current, updates);
|
|
105
|
+
return current;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/features/logging.ts
|
|
112
|
+
/** Create a structured logger with automatic trace context injection */
|
|
113
|
+
function createLogger(options) {
|
|
114
|
+
const { serviceName, serviceVersion, environment, level, prettyPrint, contextManager } = options;
|
|
115
|
+
const pinoLogger = pino({
|
|
116
|
+
level,
|
|
117
|
+
base: {
|
|
118
|
+
service: serviceName,
|
|
119
|
+
version: serviceVersion,
|
|
120
|
+
env: environment
|
|
121
|
+
},
|
|
122
|
+
mixin: () => {
|
|
123
|
+
const span = trace$1.getActiveSpan();
|
|
124
|
+
const spanContext = span?.spanContext();
|
|
125
|
+
const ctx = contextManager?.getContext();
|
|
126
|
+
return {
|
|
127
|
+
traceId: spanContext?.traceId || ctx?.traceId,
|
|
128
|
+
spanId: spanContext?.spanId || ctx?.spanId,
|
|
129
|
+
correlationId: ctx?.correlationId
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
formatters: { level: (label) => ({ level: label }) },
|
|
133
|
+
transport: prettyPrint ? {
|
|
134
|
+
target: "pino-pretty",
|
|
135
|
+
options: {
|
|
136
|
+
colorize: true,
|
|
137
|
+
translateTime: "SYS:standard",
|
|
138
|
+
ignore: "pid,hostname"
|
|
139
|
+
}
|
|
140
|
+
} : void 0
|
|
141
|
+
});
|
|
142
|
+
return wrapPinoLogger(pinoLogger);
|
|
143
|
+
}
|
|
144
|
+
function wrapPinoLogger(pinoLogger) {
|
|
145
|
+
return {
|
|
146
|
+
debug: (obj, msg) => pinoLogger.debug(obj, msg),
|
|
147
|
+
info: (obj, msg) => pinoLogger.info(obj, msg),
|
|
148
|
+
warn: (obj, msg) => pinoLogger.warn(obj, msg),
|
|
149
|
+
error: (obj, msg) => pinoLogger.error(obj, msg),
|
|
150
|
+
child: (bindings) => wrapPinoLogger(pinoLogger.child(bindings)),
|
|
151
|
+
pino: pinoLogger
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Fallback logger when Pino is not available */
|
|
155
|
+
function createFallbackLogger(serviceName = "unknown") {
|
|
156
|
+
const log = (level, obj, msg) => {
|
|
157
|
+
const logObj = {
|
|
158
|
+
timestamp: new Date().toISOString(),
|
|
159
|
+
level,
|
|
160
|
+
service: serviceName,
|
|
161
|
+
...obj,
|
|
162
|
+
msg
|
|
163
|
+
};
|
|
164
|
+
console[level === "debug" ? "log" : level](JSON.stringify(logObj));
|
|
165
|
+
};
|
|
166
|
+
const fallback = {
|
|
167
|
+
debug: (obj, msg) => log("debug", obj, msg),
|
|
168
|
+
info: (obj, msg) => log("info", obj, msg),
|
|
169
|
+
warn: (obj, msg) => log("warn", obj, msg),
|
|
170
|
+
error: (obj, msg) => log("error", obj, msg),
|
|
171
|
+
child: () => fallback,
|
|
172
|
+
pino: null
|
|
173
|
+
};
|
|
174
|
+
return fallback;
|
|
175
|
+
}
|
|
176
|
+
let globalLogger = null;
|
|
177
|
+
function setGlobalLogger(logger) {
|
|
178
|
+
globalLogger = logger;
|
|
179
|
+
}
|
|
180
|
+
function getGlobalLogger() {
|
|
181
|
+
return globalLogger || createFallbackLogger();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/features/tracing.ts
|
|
186
|
+
let sdk = null;
|
|
187
|
+
let isInitialized$1 = false;
|
|
188
|
+
/** Initialize OpenTelemetry tracing */
|
|
189
|
+
function setupTracing(options) {
|
|
190
|
+
if (isInitialized$1) {
|
|
191
|
+
console.warn("[neoiq-foundation] Tracing already initialized");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const { serviceName, serviceVersion, environment, endpoint = getDefaultOtelEndpoint(), autoInstrumentation = {} } = options;
|
|
195
|
+
const resource = resourceFromAttributes({
|
|
196
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
197
|
+
[ATTR_SERVICE_VERSION]: serviceVersion,
|
|
198
|
+
"deployment.environment": environment
|
|
199
|
+
});
|
|
200
|
+
const traceExporter = new OTLPTraceExporter({ url: endpoint });
|
|
201
|
+
const instrumentationConfig = buildInstrumentationConfig(autoInstrumentation);
|
|
202
|
+
sdk = new NodeSDK({
|
|
203
|
+
resource,
|
|
204
|
+
traceExporter,
|
|
205
|
+
instrumentations: [getNodeAutoInstrumentations(instrumentationConfig)]
|
|
206
|
+
});
|
|
207
|
+
sdk.start();
|
|
208
|
+
isInitialized$1 = true;
|
|
209
|
+
}
|
|
210
|
+
function buildInstrumentationConfig(config) {
|
|
211
|
+
const mapping = {
|
|
212
|
+
http: "@opentelemetry/instrumentation-http",
|
|
213
|
+
fastify: "@opentelemetry/instrumentation-fastify",
|
|
214
|
+
express: "@opentelemetry/instrumentation-express",
|
|
215
|
+
mongodb: "@opentelemetry/instrumentation-mongodb",
|
|
216
|
+
pg: "@opentelemetry/instrumentation-pg",
|
|
217
|
+
mysql: "@opentelemetry/instrumentation-mysql",
|
|
218
|
+
redis: "@opentelemetry/instrumentation-redis",
|
|
219
|
+
ioredis: "@opentelemetry/instrumentation-ioredis",
|
|
220
|
+
grpc: "@opentelemetry/instrumentation-grpc",
|
|
221
|
+
fs: "@opentelemetry/instrumentation-fs",
|
|
222
|
+
dns: "@opentelemetry/instrumentation-dns"
|
|
223
|
+
};
|
|
224
|
+
const result = {};
|
|
225
|
+
for (const [key, instrumentationName] of Object.entries(mapping)) {
|
|
226
|
+
const userSetting = config[key];
|
|
227
|
+
const defaultValue = key !== "fs" && key !== "dns";
|
|
228
|
+
result[instrumentationName] = { enabled: userSetting ?? defaultValue };
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
/** Shutdown tracing gracefully */
|
|
233
|
+
async function shutdownTracing() {
|
|
234
|
+
if (!sdk) return;
|
|
235
|
+
try {
|
|
236
|
+
await sdk.shutdown();
|
|
237
|
+
isInitialized$1 = false;
|
|
238
|
+
sdk = null;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error("[neoiq-foundation] Error shutting down tracing:", error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function getTracer(name) {
|
|
244
|
+
return trace.getTracer(name);
|
|
245
|
+
}
|
|
246
|
+
function getActiveSpan() {
|
|
247
|
+
return trace.getActiveSpan();
|
|
248
|
+
}
|
|
249
|
+
function getTraceContext() {
|
|
250
|
+
const span = trace.getActiveSpan();
|
|
251
|
+
if (!span) return {};
|
|
252
|
+
const ctx = span.spanContext();
|
|
253
|
+
return {
|
|
254
|
+
traceId: ctx.traceId,
|
|
255
|
+
spanId: ctx.spanId
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function isTracingEnabled() {
|
|
259
|
+
return isInitialized$1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/features/metrics.ts
|
|
264
|
+
let meterProvider = null;
|
|
265
|
+
let isInitialized = false;
|
|
266
|
+
/** Initialize OpenTelemetry metrics */
|
|
267
|
+
function setupMetrics(options) {
|
|
268
|
+
if (isInitialized) {
|
|
269
|
+
console.warn("[neoiq-foundation] Metrics already initialized");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const { serviceName, serviceVersion, environment, endpoint = getDefaultOtelEndpoint(), intervalMs = 5e3 } = options;
|
|
273
|
+
const resource = resourceFromAttributes({
|
|
274
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
275
|
+
[ATTR_SERVICE_VERSION]: serviceVersion,
|
|
276
|
+
"deployment.environment": environment
|
|
277
|
+
});
|
|
278
|
+
const metricExporter = new OTLPMetricExporter({ url: endpoint });
|
|
279
|
+
const metricReader = new PeriodicExportingMetricReader({
|
|
280
|
+
exporter: metricExporter,
|
|
281
|
+
exportIntervalMillis: intervalMs
|
|
282
|
+
});
|
|
283
|
+
meterProvider = new MeterProvider({
|
|
284
|
+
resource,
|
|
285
|
+
readers: [metricReader]
|
|
286
|
+
});
|
|
287
|
+
metrics.setGlobalMeterProvider(meterProvider);
|
|
288
|
+
isInitialized = true;
|
|
289
|
+
}
|
|
290
|
+
/** Shutdown metrics gracefully */
|
|
291
|
+
async function shutdownMetrics() {
|
|
292
|
+
if (!meterProvider) return;
|
|
293
|
+
try {
|
|
294
|
+
await meterProvider.shutdown();
|
|
295
|
+
isInitialized = false;
|
|
296
|
+
meterProvider = null;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error("[neoiq-foundation] Error shutting down metrics:", error);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function getMeter(name, version = "1.0.0") {
|
|
302
|
+
return metrics.getMeter(name, version);
|
|
303
|
+
}
|
|
304
|
+
function isMetricsEnabled() {
|
|
305
|
+
return isInitialized;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/integrations/fastify-plugin.ts
|
|
310
|
+
const DEFAULT_REDACT_HEADERS = [
|
|
311
|
+
"authorization",
|
|
312
|
+
"cookie",
|
|
313
|
+
"x-api-key",
|
|
314
|
+
"x-auth-token",
|
|
315
|
+
"set-cookie"
|
|
316
|
+
];
|
|
317
|
+
/** Redact sensitive headers */
|
|
318
|
+
function redactHeaders(headers, redactList) {
|
|
319
|
+
const redacted = {};
|
|
320
|
+
for (const [key, value] of Object.entries(headers)) if (redactList.includes(key.toLowerCase())) redacted[key] = "[REDACTED]";
|
|
321
|
+
else redacted[key] = value;
|
|
322
|
+
return redacted;
|
|
323
|
+
}
|
|
324
|
+
/** Truncate body if too large */
|
|
325
|
+
function truncateBody(body, maxSize) {
|
|
326
|
+
if (body === void 0 || body === null) return void 0;
|
|
327
|
+
const str = typeof body === "string" ? body : JSON.stringify(body);
|
|
328
|
+
if (str.length > maxSize) return `[TRUNCATED - ${str.length} bytes]`;
|
|
329
|
+
return body;
|
|
330
|
+
}
|
|
331
|
+
/** Create a configured Fastify observability plugin */
|
|
332
|
+
function createObservabilityPlugin(options) {
|
|
333
|
+
const plugin = async (fastify, pluginOpts) => {
|
|
334
|
+
const { serviceName, logger = getGlobalLogger(), contextManager, tracingEnabled = true, metricsEnabled = true, excludeRoutes = [
|
|
335
|
+
"/health",
|
|
336
|
+
"/health/",
|
|
337
|
+
"/healthz",
|
|
338
|
+
"/ready",
|
|
339
|
+
"/live"
|
|
340
|
+
], requestLogging = {} } = {
|
|
341
|
+
...options,
|
|
342
|
+
...pluginOpts
|
|
343
|
+
};
|
|
344
|
+
const logHeaders = requestLogging.logHeaders ?? true;
|
|
345
|
+
const logBody = requestLogging.logBody ?? false;
|
|
346
|
+
const logResponseBody = requestLogging.logResponseBody ?? false;
|
|
347
|
+
const maxBodySize = requestLogging.maxBodySize ?? 10 * 1024;
|
|
348
|
+
const headersToRedact = requestLogging.redactHeaders ?? DEFAULT_REDACT_HEADERS;
|
|
349
|
+
const runInContext = contextManager ? (ctx, fn) => contextManager.run(ctx, fn) : (_ctx, fn) => fn();
|
|
350
|
+
const tracer = tracingEnabled ? trace$1.getTracer("neoiq-foundation-node") : null;
|
|
351
|
+
let requestCounter;
|
|
352
|
+
let requestDuration;
|
|
353
|
+
let requestErrors;
|
|
354
|
+
if (metricsEnabled) {
|
|
355
|
+
const meter = getMeter(serviceName);
|
|
356
|
+
requestCounter = meter.createCounter("http.server.requests.total");
|
|
357
|
+
requestDuration = meter.createHistogram("http.server.request.duration", { unit: "ms" });
|
|
358
|
+
requestErrors = meter.createCounter("http.server.requests.errors");
|
|
359
|
+
}
|
|
360
|
+
fastify.addHook("onRequest", (request, reply, done) => {
|
|
361
|
+
if (excludeRoutes.some((route) => request.url.startsWith(route))) {
|
|
362
|
+
done();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const correlationId = request.headers["x-request-id"] || randomUUID();
|
|
366
|
+
reply.header("x-request-id", correlationId);
|
|
367
|
+
let span;
|
|
368
|
+
let traceId = "";
|
|
369
|
+
let spanId = "";
|
|
370
|
+
if (tracer) {
|
|
371
|
+
const parentContext = propagation$1.extract(context$1.active(), request.headers);
|
|
372
|
+
span = tracer.startSpan(`${request.method} ${request.routeOptions?.url || request.url}`, {
|
|
373
|
+
kind: 1,
|
|
374
|
+
attributes: {
|
|
375
|
+
"http.method": request.method,
|
|
376
|
+
"http.url": request.url,
|
|
377
|
+
"http.route": request.routeOptions?.url || request.url,
|
|
378
|
+
"http.user_agent": request.headers["user-agent"] || "",
|
|
379
|
+
"http.correlation_id": correlationId
|
|
380
|
+
}
|
|
381
|
+
}, parentContext);
|
|
382
|
+
const spanContext = span.spanContext();
|
|
383
|
+
traceId = spanContext.traceId;
|
|
384
|
+
spanId = spanContext.spanId;
|
|
385
|
+
}
|
|
386
|
+
const requestContext = {
|
|
387
|
+
correlationId,
|
|
388
|
+
traceId,
|
|
389
|
+
spanId,
|
|
390
|
+
startTime: Date.now()
|
|
391
|
+
};
|
|
392
|
+
request.__span = span;
|
|
393
|
+
request.__requestContext = requestContext;
|
|
394
|
+
runInContext(requestContext, () => {
|
|
395
|
+
const logData = {
|
|
396
|
+
correlationId,
|
|
397
|
+
traceId: traceId || void 0,
|
|
398
|
+
method: request.method,
|
|
399
|
+
url: request.url,
|
|
400
|
+
ip: request.ip,
|
|
401
|
+
userAgent: request.headers["user-agent"]
|
|
402
|
+
};
|
|
403
|
+
if (logHeaders) logData.headers = redactHeaders(request.headers, headersToRedact);
|
|
404
|
+
logger.info(logData, "Request received");
|
|
405
|
+
done();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
if (logBody) fastify.addHook("preHandler", (request, _reply, done) => {
|
|
409
|
+
const ctx = request.__requestContext;
|
|
410
|
+
if (!ctx || !request.body) {
|
|
411
|
+
done();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
runInContext(ctx, () => {
|
|
415
|
+
logger.debug({
|
|
416
|
+
correlationId: ctx.correlationId,
|
|
417
|
+
body: truncateBody(request.body, maxBodySize)
|
|
418
|
+
}, "Request body");
|
|
419
|
+
done();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
if (logResponseBody) fastify.addHook("onSend", (request, reply, payload, done) => {
|
|
423
|
+
const ctx = request.__requestContext;
|
|
424
|
+
if (!ctx) {
|
|
425
|
+
done(null, payload);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
runInContext(ctx, () => {
|
|
429
|
+
logger.debug({
|
|
430
|
+
correlationId: ctx.correlationId,
|
|
431
|
+
statusCode: reply.statusCode,
|
|
432
|
+
body: truncateBody(payload, maxBodySize)
|
|
433
|
+
}, "Response body");
|
|
434
|
+
done(null, payload);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
fastify.addHook("onResponse", (request, reply, done) => {
|
|
438
|
+
const ctx = request.__requestContext;
|
|
439
|
+
const span = request.__span;
|
|
440
|
+
if (!ctx) {
|
|
441
|
+
done();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const durationMs = Date.now() - ctx.startTime;
|
|
445
|
+
const route = request.routeOptions?.url || request.url;
|
|
446
|
+
const labels = {
|
|
447
|
+
method: request.method,
|
|
448
|
+
route,
|
|
449
|
+
status_code: String(reply.statusCode)
|
|
450
|
+
};
|
|
451
|
+
runInContext(ctx, () => {
|
|
452
|
+
logger.info({
|
|
453
|
+
correlationId: ctx.correlationId,
|
|
454
|
+
method: request.method,
|
|
455
|
+
statusCode: reply.statusCode,
|
|
456
|
+
durationMs
|
|
457
|
+
}, "Request completed");
|
|
458
|
+
if (metricsEnabled) {
|
|
459
|
+
requestCounter.add(1, labels);
|
|
460
|
+
requestDuration.record(durationMs, labels);
|
|
461
|
+
if (reply.statusCode >= 400) requestErrors.add(1, labels);
|
|
462
|
+
}
|
|
463
|
+
if (span) {
|
|
464
|
+
span.setStatus({ code: reply.statusCode < 400 ? SpanStatusCode$1.OK : SpanStatusCode$1.ERROR });
|
|
465
|
+
span.setAttribute("http.status_code", reply.statusCode);
|
|
466
|
+
span.setAttribute("http.response_time_ms", durationMs);
|
|
467
|
+
span.end();
|
|
468
|
+
}
|
|
469
|
+
done();
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
fastify.addHook("onError", (request, _reply, error, done) => {
|
|
473
|
+
const ctx = request.__requestContext;
|
|
474
|
+
const span = request.__span;
|
|
475
|
+
if (!ctx) {
|
|
476
|
+
done();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
runInContext(ctx, () => {
|
|
480
|
+
logger.error({
|
|
481
|
+
correlationId: ctx.correlationId,
|
|
482
|
+
method: request.method,
|
|
483
|
+
url: request.url,
|
|
484
|
+
error: error.message
|
|
485
|
+
}, "Request failed");
|
|
486
|
+
if (span) {
|
|
487
|
+
span.setStatus({
|
|
488
|
+
code: SpanStatusCode$1.ERROR,
|
|
489
|
+
message: error.message
|
|
490
|
+
});
|
|
491
|
+
span.recordException(error);
|
|
492
|
+
}
|
|
493
|
+
done();
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
return fp(plugin, {
|
|
498
|
+
name: "neoiq-observability",
|
|
499
|
+
fastify: ">=4.x"
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/integrations/http-client.ts
|
|
505
|
+
/** Create a configured HTTP client with full observability */
|
|
506
|
+
function createHttpClient(options) {
|
|
507
|
+
const { baseURL, serviceName, timeout = 3e4, retry = {}, circuitBreaker: cbOptions = {}, headers = {}, foundation } = options;
|
|
508
|
+
const logger = foundation?.logger || getGlobalLogger();
|
|
509
|
+
const getContext = () => foundation?.context.getContext();
|
|
510
|
+
const metricsEnabled = foundation?.features.metrics ?? true;
|
|
511
|
+
const tracingEnabled = foundation?.features.tracing ?? true;
|
|
512
|
+
const client = axios.create({
|
|
513
|
+
baseURL,
|
|
514
|
+
timeout,
|
|
515
|
+
headers: {
|
|
516
|
+
"Content-Type": "application/json",
|
|
517
|
+
...headers
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
let requestCounter = null;
|
|
521
|
+
let requestDuration = null;
|
|
522
|
+
let requestErrors = null;
|
|
523
|
+
if (metricsEnabled) {
|
|
524
|
+
const meter = getMeter(`http-client-${serviceName}`);
|
|
525
|
+
requestCounter = meter.createCounter("http.client.requests.total");
|
|
526
|
+
requestDuration = meter.createHistogram("http.client.request.duration", { unit: "ms" });
|
|
527
|
+
requestErrors = meter.createCounter("http.client.requests.errors");
|
|
528
|
+
}
|
|
529
|
+
client.interceptors.request.use((config) => {
|
|
530
|
+
if (tracingEnabled) {
|
|
531
|
+
const carrier = {};
|
|
532
|
+
propagation$1.inject(context$1.active(), carrier);
|
|
533
|
+
if (carrier.traceparent) config.headers.set("traceparent", carrier.traceparent);
|
|
534
|
+
if (carrier.tracestate) config.headers.set("tracestate", carrier.tracestate);
|
|
535
|
+
}
|
|
536
|
+
const reqCtx = getContext();
|
|
537
|
+
if (reqCtx?.correlationId) config.headers.set("x-request-id", reqCtx.correlationId);
|
|
538
|
+
config.__startTime = Date.now();
|
|
539
|
+
logger.debug({
|
|
540
|
+
method: config.method?.toUpperCase(),
|
|
541
|
+
url: `${config.baseURL || ""}${config.url}`,
|
|
542
|
+
targetService: serviceName
|
|
543
|
+
}, "Outbound HTTP request");
|
|
544
|
+
return config;
|
|
545
|
+
});
|
|
546
|
+
client.interceptors.response.use((response) => {
|
|
547
|
+
const config = response.config;
|
|
548
|
+
const durationMs = Date.now() - (config.__startTime || Date.now());
|
|
549
|
+
const labels = {
|
|
550
|
+
target_service: serviceName,
|
|
551
|
+
method: config.method?.toUpperCase() || "GET",
|
|
552
|
+
status_code: String(response.status)
|
|
553
|
+
};
|
|
554
|
+
logger.debug({
|
|
555
|
+
method: config.method?.toUpperCase(),
|
|
556
|
+
statusCode: response.status,
|
|
557
|
+
durationMs,
|
|
558
|
+
targetService: serviceName
|
|
559
|
+
}, "Outbound HTTP response");
|
|
560
|
+
if (metricsEnabled) {
|
|
561
|
+
requestCounter?.add(1, labels);
|
|
562
|
+
requestDuration?.record(durationMs, labels);
|
|
563
|
+
}
|
|
564
|
+
return response;
|
|
565
|
+
}, (error) => {
|
|
566
|
+
const config = error.config;
|
|
567
|
+
const durationMs = config ? Date.now() - (config.__startTime || Date.now()) : 0;
|
|
568
|
+
const statusCode = error.response?.status || 0;
|
|
569
|
+
const labels = {
|
|
570
|
+
target_service: serviceName,
|
|
571
|
+
method: config?.method?.toUpperCase() || "GET",
|
|
572
|
+
status_code: String(statusCode)
|
|
573
|
+
};
|
|
574
|
+
logger.error({
|
|
575
|
+
method: config?.method?.toUpperCase(),
|
|
576
|
+
statusCode,
|
|
577
|
+
durationMs,
|
|
578
|
+
error: error.message,
|
|
579
|
+
targetService: serviceName
|
|
580
|
+
}, "Outbound HTTP error");
|
|
581
|
+
if (metricsEnabled) {
|
|
582
|
+
requestCounter?.add(1, labels);
|
|
583
|
+
requestDuration?.record(durationMs, labels);
|
|
584
|
+
requestErrors?.add(1, labels);
|
|
585
|
+
}
|
|
586
|
+
return Promise.reject(error);
|
|
587
|
+
});
|
|
588
|
+
const retryConfig = {
|
|
589
|
+
retries: retry.retries ?? 3,
|
|
590
|
+
retryDelay: (retryCount) => (retry.retryDelay ?? 1e3) * Math.pow(2, retryCount - 1),
|
|
591
|
+
retryCondition: (error) => {
|
|
592
|
+
const retryStatusCodes = retry.retryStatusCodes ?? [
|
|
593
|
+
408,
|
|
594
|
+
429,
|
|
595
|
+
500,
|
|
596
|
+
502,
|
|
597
|
+
503,
|
|
598
|
+
504
|
|
599
|
+
];
|
|
600
|
+
return !error.response || retryStatusCodes.includes(error.response?.status || 0);
|
|
601
|
+
},
|
|
602
|
+
onRetry: (retryCount, error, requestConfig) => {
|
|
603
|
+
logger.warn({
|
|
604
|
+
retryCount,
|
|
605
|
+
url: `${requestConfig.baseURL || ""}${requestConfig.url}`,
|
|
606
|
+
error: error.message
|
|
607
|
+
}, "Retrying request");
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
axiosRetry(client, retryConfig);
|
|
611
|
+
if (cbOptions.enabled !== false) {
|
|
612
|
+
const breaker = new CircuitBreaker(async (config) => client.request(config), {
|
|
613
|
+
timeout,
|
|
614
|
+
resetTimeout: cbOptions.resetTimeout ?? 3e4,
|
|
615
|
+
errorThresholdPercentage: cbOptions.errorThresholdPercentage ?? 50,
|
|
616
|
+
volumeThreshold: 10
|
|
617
|
+
});
|
|
618
|
+
breaker.on("open", () => logger.warn({ targetService: serviceName }, "Circuit breaker OPEN"));
|
|
619
|
+
breaker.on("halfOpen", () => logger.info({ targetService: serviceName }, "Circuit breaker HALF-OPEN"));
|
|
620
|
+
breaker.on("close", () => logger.info({ targetService: serviceName }, "Circuit breaker CLOSED"));
|
|
621
|
+
}
|
|
622
|
+
return client;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/foundation.ts
|
|
627
|
+
/** Create a fully configured observability foundation */
|
|
628
|
+
function createFoundation(input) {
|
|
629
|
+
const startTime = Date.now();
|
|
630
|
+
const config = parseConfig(input);
|
|
631
|
+
const { serviceName, serviceVersion, environment, features: featuresConfig, otel, logging: loggingConfig, requestLogging: requestLoggingConfig } = config;
|
|
632
|
+
const features = {
|
|
633
|
+
tracing: featuresConfig.tracing ?? true,
|
|
634
|
+
metrics: featuresConfig.metrics ?? true,
|
|
635
|
+
logging: featuresConfig.logging ?? true
|
|
636
|
+
};
|
|
637
|
+
const contextManager = createContextManager();
|
|
638
|
+
let logger;
|
|
639
|
+
let loggingError;
|
|
640
|
+
try {
|
|
641
|
+
logger = createLogger({
|
|
642
|
+
serviceName,
|
|
643
|
+
serviceVersion,
|
|
644
|
+
environment,
|
|
645
|
+
level: loggingConfig.level ?? "info",
|
|
646
|
+
prettyPrint: loggingConfig.prettyPrint ?? environment === "development",
|
|
647
|
+
contextManager
|
|
648
|
+
});
|
|
649
|
+
setGlobalLogger(logger);
|
|
650
|
+
} catch (err) {
|
|
651
|
+
loggingError = err.message;
|
|
652
|
+
logger = {
|
|
653
|
+
debug: (obj, msg) => console.debug(JSON.stringify({
|
|
654
|
+
...obj,
|
|
655
|
+
msg
|
|
656
|
+
})),
|
|
657
|
+
info: (obj, msg) => console.info(JSON.stringify({
|
|
658
|
+
...obj,
|
|
659
|
+
msg
|
|
660
|
+
})),
|
|
661
|
+
warn: (obj, msg) => console.warn(JSON.stringify({
|
|
662
|
+
...obj,
|
|
663
|
+
msg
|
|
664
|
+
})),
|
|
665
|
+
error: (obj, msg) => console.error(JSON.stringify({
|
|
666
|
+
...obj,
|
|
667
|
+
msg
|
|
668
|
+
})),
|
|
669
|
+
child: () => logger,
|
|
670
|
+
pino: null
|
|
671
|
+
};
|
|
672
|
+
console.error("[neoiq-foundation] Logger setup failed, using console:", loggingError);
|
|
673
|
+
}
|
|
674
|
+
let tracerInstance = null;
|
|
675
|
+
let tracingError;
|
|
676
|
+
if (features.tracing) try {
|
|
677
|
+
setupTracing({
|
|
678
|
+
serviceName,
|
|
679
|
+
serviceVersion,
|
|
680
|
+
environment,
|
|
681
|
+
endpoint: otel.endpoint,
|
|
682
|
+
sampleRate: otel.traceSampleRate,
|
|
683
|
+
autoInstrumentation: featuresConfig.autoInstrumentation
|
|
684
|
+
});
|
|
685
|
+
tracerInstance = getTracer(serviceName);
|
|
686
|
+
logger.info({
|
|
687
|
+
feature: "tracing",
|
|
688
|
+
endpoint: otel.endpoint
|
|
689
|
+
}, "Tracing enabled");
|
|
690
|
+
} catch (err) {
|
|
691
|
+
tracingError = err.message;
|
|
692
|
+
logger.error({ error: tracingError }, "Tracing setup failed - continuing without tracing");
|
|
693
|
+
}
|
|
694
|
+
let meterInstance = null;
|
|
695
|
+
let metricsError;
|
|
696
|
+
if (features.metrics) try {
|
|
697
|
+
setupMetrics({
|
|
698
|
+
serviceName,
|
|
699
|
+
serviceVersion,
|
|
700
|
+
environment,
|
|
701
|
+
endpoint: otel.endpoint,
|
|
702
|
+
intervalMs: otel.metricsIntervalMs
|
|
703
|
+
});
|
|
704
|
+
meterInstance = getMeter(serviceName);
|
|
705
|
+
logger.info({
|
|
706
|
+
feature: "metrics",
|
|
707
|
+
interval: `${otel.metricsIntervalMs}ms`
|
|
708
|
+
}, "Metrics enabled");
|
|
709
|
+
} catch (err) {
|
|
710
|
+
metricsError = err.message;
|
|
711
|
+
logger.error({ error: metricsError }, "Metrics setup failed - continuing without metrics");
|
|
712
|
+
}
|
|
713
|
+
logger.info({
|
|
714
|
+
serviceName,
|
|
715
|
+
serviceVersion,
|
|
716
|
+
environment,
|
|
717
|
+
features
|
|
718
|
+
}, "Foundation initialized");
|
|
719
|
+
const foundation = {
|
|
720
|
+
config,
|
|
721
|
+
logger,
|
|
722
|
+
context: contextManager,
|
|
723
|
+
tracer: tracerInstance,
|
|
724
|
+
meter: meterInstance,
|
|
725
|
+
features,
|
|
726
|
+
getTracer: (name) => getTracer(name || serviceName),
|
|
727
|
+
getMeter: (name, version) => getMeter(name, version),
|
|
728
|
+
getTraceContext,
|
|
729
|
+
getActiveSpan,
|
|
730
|
+
fastifyPlugin: createObservabilityPlugin({
|
|
731
|
+
serviceName,
|
|
732
|
+
logger,
|
|
733
|
+
contextManager,
|
|
734
|
+
tracingEnabled: features.tracing && !tracingError,
|
|
735
|
+
metricsEnabled: features.metrics && !metricsError,
|
|
736
|
+
requestLogging: requestLoggingConfig
|
|
737
|
+
}),
|
|
738
|
+
createHttpClient: (options) => createHttpClient({
|
|
739
|
+
...options,
|
|
740
|
+
foundation
|
|
741
|
+
}),
|
|
742
|
+
shutdown: async () => {
|
|
743
|
+
logger.info({}, "Shutting down foundation...");
|
|
744
|
+
const promises = [];
|
|
745
|
+
if (features.tracing && isTracingEnabled()) promises.push(shutdownTracing());
|
|
746
|
+
if (features.metrics && isMetricsEnabled()) promises.push(shutdownMetrics());
|
|
747
|
+
await Promise.all(promises);
|
|
748
|
+
logger.info({}, "Foundation shutdown complete");
|
|
749
|
+
},
|
|
750
|
+
isReady: () => {
|
|
751
|
+
if (features.tracing && !tracingError && !isTracingEnabled()) return false;
|
|
752
|
+
if (features.metrics && !metricsError && !isMetricsEnabled()) return false;
|
|
753
|
+
return true;
|
|
754
|
+
},
|
|
755
|
+
health: () => {
|
|
756
|
+
const tracingUp = !features.tracing || !tracingError && isTracingEnabled();
|
|
757
|
+
const metricsUp = !features.metrics || !metricsError && isMetricsEnabled();
|
|
758
|
+
const loggingUp = !loggingError;
|
|
759
|
+
const allUp = tracingUp && metricsUp && loggingUp;
|
|
760
|
+
const anyDown = features.tracing && tracingError || features.metrics && metricsError || loggingError;
|
|
761
|
+
return {
|
|
762
|
+
status: allUp ? "healthy" : anyDown ? "degraded" : "unhealthy",
|
|
763
|
+
timestamp: new Date().toISOString(),
|
|
764
|
+
service: serviceName,
|
|
765
|
+
version: serviceVersion,
|
|
766
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
767
|
+
components: {
|
|
768
|
+
tracing: {
|
|
769
|
+
enabled: features.tracing,
|
|
770
|
+
status: !features.tracing ? "disabled" : tracingError ? "down" : "up",
|
|
771
|
+
message: tracingError
|
|
772
|
+
},
|
|
773
|
+
metrics: {
|
|
774
|
+
enabled: features.metrics,
|
|
775
|
+
status: !features.metrics ? "disabled" : metricsError ? "down" : "up",
|
|
776
|
+
message: metricsError
|
|
777
|
+
},
|
|
778
|
+
logging: {
|
|
779
|
+
enabled: features.logging,
|
|
780
|
+
status: loggingError ? "down" : "up",
|
|
781
|
+
message: loggingError
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
},
|
|
786
|
+
trace: async (name, fn) => {
|
|
787
|
+
const tracer = tracerInstance || getTracer(serviceName);
|
|
788
|
+
return new Promise((resolve, reject) => {
|
|
789
|
+
tracer.startActiveSpan(name, async (span) => {
|
|
790
|
+
try {
|
|
791
|
+
const result = await fn();
|
|
792
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
793
|
+
span.end();
|
|
794
|
+
resolve(result);
|
|
795
|
+
} catch (err) {
|
|
796
|
+
const error = err;
|
|
797
|
+
span.setStatus({
|
|
798
|
+
code: SpanStatusCode.ERROR,
|
|
799
|
+
message: error.message
|
|
800
|
+
});
|
|
801
|
+
span.recordException(error);
|
|
802
|
+
span.end();
|
|
803
|
+
logger.error({
|
|
804
|
+
span: name,
|
|
805
|
+
error: error.message
|
|
806
|
+
}, "Span failed");
|
|
807
|
+
reject(error);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
},
|
|
812
|
+
safeRun: async (fn, fallback) => {
|
|
813
|
+
try {
|
|
814
|
+
return await fn();
|
|
815
|
+
} catch (err) {
|
|
816
|
+
const error = err;
|
|
817
|
+
logger.error({
|
|
818
|
+
error: error.message,
|
|
819
|
+
stack: error.stack
|
|
820
|
+
}, "safeRun caught error");
|
|
821
|
+
return fallback;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
return foundation;
|
|
826
|
+
}
|
|
827
|
+
/** Alias for createFoundation */
|
|
828
|
+
const setupObservability = createFoundation;
|
|
829
|
+
|
|
830
|
+
//#endregion
|
|
831
|
+
export { AutoInstrumentationConfigSchema, FeaturesConfigSchema, FoundationConfigSchema, LoggingConfigSchema, OtelConfigSchema, RequestLoggingConfigSchema, SpanStatusCode, context, createContextManager, createFallbackLogger, createFoundation, createHttpClient, createLogger, createObservabilityPlugin, getActiveSpan, getDefaultOtelEndpoint, getGlobalLogger, getMeter, getTraceContext, getTracer, isMetricsEnabled, isTracingEnabled, metrics, parseConfig, propagation, setGlobalLogger, setupMetrics, setupObservability, setupTracing, shutdownMetrics, shutdownTracing, trace };
|
|
832
|
+
//# sourceMappingURL=index.mjs.map
|