dspx 0.1.1-alpha.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/.github/workflows/ci.yml +185 -0
- package/.vscode/c_cpp_properties.json +17 -0
- package/.vscode/settings.json +68 -0
- package/.vscode/tasks.json +28 -0
- package/DISCLAIMER.md +32 -0
- package/LICENSE +21 -0
- package/README.md +1803 -0
- package/ROADMAP.md +192 -0
- package/TECHNICAL_DEBT.md +165 -0
- package/binding.gyp +65 -0
- package/docs/ADVANCED_LOGGER_FEATURES.md +598 -0
- package/docs/AUTHENTICATION_SECURITY.md +396 -0
- package/docs/BACKEND_IMPROVEMENTS.md +399 -0
- package/docs/CHEBYSHEV_BIQUAD_EQ_IMPLEMENTATION.md +405 -0
- package/docs/FFT_IMPLEMENTATION.md +490 -0
- package/docs/FFT_IMPROVEMENTS_SUMMARY.md +387 -0
- package/docs/FFT_USER_GUIDE.md +494 -0
- package/docs/FILTERS_IMPLEMENTATION.md +260 -0
- package/docs/FILTER_API_GUIDE.md +418 -0
- package/docs/FIR_SIMD_OPTIMIZATION.md +175 -0
- package/docs/LOGGER_API_REFERENCE.md +350 -0
- package/docs/NOTCH_FILTER_QUICK_REF.md +121 -0
- package/docs/PHASE2_TESTS_AND_NOTCH_FILTER.md +341 -0
- package/docs/PHASES_5_7_SUMMARY.md +403 -0
- package/docs/PIPELINE_FILTER_INTEGRATION.md +446 -0
- package/docs/SIMD_OPTIMIZATIONS.md +211 -0
- package/docs/TEST_MIGRATION_SUMMARY.md +173 -0
- package/docs/TIMESERIES_IMPLEMENTATION_SUMMARY.md +322 -0
- package/docs/TIMESERIES_QUICK_REF.md +85 -0
- package/docs/advanced.md +559 -0
- package/docs/time-series-guide.md +617 -0
- package/docs/time-series-migration.md +376 -0
- package/jest.config.js +37 -0
- package/package.json +42 -0
- package/prebuilds/linux-x64/dsp-ts-redis.node +0 -0
- package/prebuilds/win32-x64/dsp-ts-redis.node +0 -0
- package/scripts/test.js +24 -0
- package/src/build/dsp-ts-redis.node +0 -0
- package/src/native/DspPipeline.cc +675 -0
- package/src/native/DspPipeline.h +44 -0
- package/src/native/FftBindings.cc +817 -0
- package/src/native/FilterBindings.cc +1001 -0
- package/src/native/IDspStage.h +53 -0
- package/src/native/adapters/InterpolatorStage.h +201 -0
- package/src/native/adapters/MeanAbsoluteValueStage.h +289 -0
- package/src/native/adapters/MovingAverageStage.h +306 -0
- package/src/native/adapters/RectifyStage.h +88 -0
- package/src/native/adapters/ResamplerStage.h +238 -0
- package/src/native/adapters/RmsStage.h +299 -0
- package/src/native/adapters/SscStage.h +121 -0
- package/src/native/adapters/VarianceStage.h +307 -0
- package/src/native/adapters/WampStage.h +114 -0
- package/src/native/adapters/WaveformLengthStage.h +115 -0
- package/src/native/adapters/ZScoreNormalizeStage.h +326 -0
- package/src/native/core/FftEngine.cc +441 -0
- package/src/native/core/FftEngine.h +224 -0
- package/src/native/core/FirFilter.cc +324 -0
- package/src/native/core/FirFilter.h +149 -0
- package/src/native/core/IirFilter.cc +576 -0
- package/src/native/core/IirFilter.h +210 -0
- package/src/native/core/MovingAbsoluteValueFilter.cc +17 -0
- package/src/native/core/MovingAbsoluteValueFilter.h +135 -0
- package/src/native/core/MovingAverageFilter.cc +18 -0
- package/src/native/core/MovingAverageFilter.h +135 -0
- package/src/native/core/MovingFftFilter.cc +291 -0
- package/src/native/core/MovingFftFilter.h +203 -0
- package/src/native/core/MovingVarianceFilter.cc +194 -0
- package/src/native/core/MovingVarianceFilter.h +114 -0
- package/src/native/core/MovingZScoreFilter.cc +215 -0
- package/src/native/core/MovingZScoreFilter.h +113 -0
- package/src/native/core/Policies.h +352 -0
- package/src/native/core/RmsFilter.cc +18 -0
- package/src/native/core/RmsFilter.h +131 -0
- package/src/native/core/SscFilter.cc +16 -0
- package/src/native/core/SscFilter.h +137 -0
- package/src/native/core/WampFilter.cc +16 -0
- package/src/native/core/WampFilter.h +101 -0
- package/src/native/core/WaveformLengthFilter.cc +17 -0
- package/src/native/core/WaveformLengthFilter.h +98 -0
- package/src/native/utils/CircularBufferArray.cc +336 -0
- package/src/native/utils/CircularBufferArray.h +62 -0
- package/src/native/utils/CircularBufferVector.cc +145 -0
- package/src/native/utils/CircularBufferVector.h +45 -0
- package/src/native/utils/NapiUtils.cc +53 -0
- package/src/native/utils/NapiUtils.h +21 -0
- package/src/native/utils/SimdOps.h +870 -0
- package/src/native/utils/SlidingWindowFilter.cc +239 -0
- package/src/native/utils/SlidingWindowFilter.h +159 -0
- package/src/native/utils/TimeSeriesBuffer.cc +205 -0
- package/src/native/utils/TimeSeriesBuffer.h +140 -0
- package/src/ts/CircularLogBuffer.ts +87 -0
- package/src/ts/DriftDetector.ts +331 -0
- package/src/ts/TopicRouter.ts +428 -0
- package/src/ts/__tests__/AdvancedDsp.test.ts +585 -0
- package/src/ts/__tests__/AuthAndEdgeCases.test.ts +241 -0
- package/src/ts/__tests__/Chaining.test.ts +387 -0
- package/src/ts/__tests__/ChebyshevBiquad.test.ts +229 -0
- package/src/ts/__tests__/CircularLogBuffer.test.ts +158 -0
- package/src/ts/__tests__/DriftDetector.test.ts +389 -0
- package/src/ts/__tests__/Fft.test.ts +484 -0
- package/src/ts/__tests__/ListState.test.ts +153 -0
- package/src/ts/__tests__/Logger.test.ts +208 -0
- package/src/ts/__tests__/LoggerAdvanced.test.ts +319 -0
- package/src/ts/__tests__/LoggerMinor.test.ts +247 -0
- package/src/ts/__tests__/MeanAbsoluteValue.test.ts +398 -0
- package/src/ts/__tests__/MovingAverage.test.ts +322 -0
- package/src/ts/__tests__/RMS.test.ts +315 -0
- package/src/ts/__tests__/Rectify.test.ts +272 -0
- package/src/ts/__tests__/Redis.test.ts +456 -0
- package/src/ts/__tests__/SlopeSignChange.test.ts +166 -0
- package/src/ts/__tests__/Tap.test.ts +164 -0
- package/src/ts/__tests__/TimeBasedExpiration.test.ts +124 -0
- package/src/ts/__tests__/TimeBasedRmsAndMav.test.ts +231 -0
- package/src/ts/__tests__/TimeBasedVarianceAndZScore.test.ts +284 -0
- package/src/ts/__tests__/TimeSeries.test.ts +254 -0
- package/src/ts/__tests__/TopicRouter.test.ts +332 -0
- package/src/ts/__tests__/TopicRouterAdvanced.test.ts +483 -0
- package/src/ts/__tests__/TopicRouterPriority.test.ts +487 -0
- package/src/ts/__tests__/Variance.test.ts +509 -0
- package/src/ts/__tests__/WaveformLength.test.ts +147 -0
- package/src/ts/__tests__/WillisonAmplitude.test.ts +197 -0
- package/src/ts/__tests__/ZScoreNormalize.test.ts +459 -0
- package/src/ts/advanced-dsp.ts +566 -0
- package/src/ts/backends.ts +1137 -0
- package/src/ts/bindings.ts +1225 -0
- package/src/ts/easter-egg.ts +42 -0
- package/src/ts/examples/MeanAbsoluteValue/test-state.ts +99 -0
- package/src/ts/examples/MeanAbsoluteValue/test-streaming.ts +269 -0
- package/src/ts/examples/MovingAverage/test-state.ts +85 -0
- package/src/ts/examples/MovingAverage/test-streaming.ts +188 -0
- package/src/ts/examples/RMS/test-state.ts +97 -0
- package/src/ts/examples/RMS/test-streaming.ts +253 -0
- package/src/ts/examples/Rectify/test-state.ts +107 -0
- package/src/ts/examples/Rectify/test-streaming.ts +242 -0
- package/src/ts/examples/Variance/test-state.ts +195 -0
- package/src/ts/examples/Variance/test-streaming.ts +260 -0
- package/src/ts/examples/ZScoreNormalize/test-state.ts +277 -0
- package/src/ts/examples/ZScoreNormalize/test-streaming.ts +306 -0
- package/src/ts/examples/advanced-dsp-examples.ts +397 -0
- package/src/ts/examples/callbacks/advanced-router-features.ts +326 -0
- package/src/ts/examples/callbacks/benchmark-circular-buffer.ts +109 -0
- package/src/ts/examples/callbacks/monitoring-example.ts +265 -0
- package/src/ts/examples/callbacks/pipeline-callbacks-example.ts +137 -0
- package/src/ts/examples/callbacks/pooled-callbacks-example.ts +274 -0
- package/src/ts/examples/callbacks/priority-routing-example.ts +277 -0
- package/src/ts/examples/callbacks/production-topic-router.ts +214 -0
- package/src/ts/examples/callbacks/topic-based-logging.ts +161 -0
- package/src/ts/examples/chaining/test-chaining-redis.ts +113 -0
- package/src/ts/examples/chaining/test-chaining.ts +52 -0
- package/src/ts/examples/emg-features-example.ts +284 -0
- package/src/ts/examples/fft-example.ts +309 -0
- package/src/ts/examples/fft-examples.ts +349 -0
- package/src/ts/examples/filter-examples.ts +320 -0
- package/src/ts/examples/list-state-example.ts +131 -0
- package/src/ts/examples/logger-example.ts +91 -0
- package/src/ts/examples/notch-filter-examples.ts +243 -0
- package/src/ts/examples/phase5/drift-detection-example.ts +290 -0
- package/src/ts/examples/phase6-7/production-observability.ts +476 -0
- package/src/ts/examples/phase6-7/redis-timeseries-integration.ts +446 -0
- package/src/ts/examples/redis/redis-example.ts +202 -0
- package/src/ts/examples/redis-example.ts +202 -0
- package/src/ts/examples/simd-benchmark.ts +126 -0
- package/src/ts/examples/tap-debugging.ts +230 -0
- package/src/ts/examples/timeseries/comparison-example.ts +290 -0
- package/src/ts/examples/timeseries/iot-sensor-example.ts +143 -0
- package/src/ts/examples/timeseries/redis-streaming-example.ts +233 -0
- package/src/ts/examples/waveform-length-example.ts +139 -0
- package/src/ts/fft.ts +722 -0
- package/src/ts/filters.ts +1078 -0
- package/src/ts/index.ts +120 -0
- package/src/ts/types.ts +589 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable backend handlers for common observability systems
|
|
3
|
+
*
|
|
4
|
+
* Production-ready integrations for:
|
|
5
|
+
* - PagerDuty (alerting)
|
|
6
|
+
* - Prometheus (metrics)
|
|
7
|
+
* - Loki (centralized logging)
|
|
8
|
+
* - CloudWatch (AWS)
|
|
9
|
+
* - Datadog (observability platform)
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - Pluggable formatters (JSON, text, custom)
|
|
13
|
+
* - Distributed tracing support (trace/span/correlation IDs)
|
|
14
|
+
* - Graceful shutdown with flush hooks
|
|
15
|
+
* - Extended log levels (trace -> fatal)
|
|
16
|
+
* - Internal performance metrics
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { LogEntry, LogLevel } from "./types.js";
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Formatter interface for encoding log entries
|
|
24
|
+
*/
|
|
25
|
+
export interface Formatter {
|
|
26
|
+
format(log: LogEntry): any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* JSON formatter (default)
|
|
31
|
+
*/
|
|
32
|
+
export class JSONFormatter implements Formatter {
|
|
33
|
+
format(log: LogEntry): any {
|
|
34
|
+
return log;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Text formatter for human-readable output
|
|
40
|
+
*/
|
|
41
|
+
export class TextFormatter implements Formatter {
|
|
42
|
+
format(log: LogEntry): string {
|
|
43
|
+
const timestamp = new Date(log.timestamp).toISOString();
|
|
44
|
+
const level = log.level.toUpperCase().padEnd(5);
|
|
45
|
+
const topic = log.topic || "default";
|
|
46
|
+
const traceInfo = log.traceId ? ` [trace:${log.traceId.slice(0, 8)}]` : "";
|
|
47
|
+
|
|
48
|
+
let output = `[${timestamp}] ${level} [${topic}]${traceInfo} ${log.message}`;
|
|
49
|
+
|
|
50
|
+
if (log.context && Object.keys(log.context).length > 0) {
|
|
51
|
+
try {
|
|
52
|
+
output += `\n Context: ${JSON.stringify(log.context)}`;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Handle circular references or non-serializable values
|
|
55
|
+
output += `\n Context: [Unable to stringify: ${
|
|
56
|
+
error instanceof Error ? error.message : "unknown error"
|
|
57
|
+
}]`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return output;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Severity mapping for different observability systems
|
|
67
|
+
*/
|
|
68
|
+
export interface SeverityMapping {
|
|
69
|
+
trace?: string;
|
|
70
|
+
debug?: string;
|
|
71
|
+
info?: string;
|
|
72
|
+
warn?: string;
|
|
73
|
+
error?: string;
|
|
74
|
+
fatal?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Default severity mappings for common systems
|
|
79
|
+
*/
|
|
80
|
+
export const SEVERITY_MAPPINGS = {
|
|
81
|
+
pagerduty: {
|
|
82
|
+
trace: "info",
|
|
83
|
+
debug: "info",
|
|
84
|
+
info: "info",
|
|
85
|
+
warn: "warning",
|
|
86
|
+
error: "error",
|
|
87
|
+
fatal: "critical",
|
|
88
|
+
},
|
|
89
|
+
datadog: {
|
|
90
|
+
trace: "debug",
|
|
91
|
+
debug: "debug",
|
|
92
|
+
info: "info",
|
|
93
|
+
warn: "warn",
|
|
94
|
+
error: "error",
|
|
95
|
+
fatal: "emergency",
|
|
96
|
+
},
|
|
97
|
+
syslog: {
|
|
98
|
+
trace: "7", // Debug
|
|
99
|
+
debug: "7", // Debug
|
|
100
|
+
info: "6", // Informational
|
|
101
|
+
warn: "4", // Warning
|
|
102
|
+
error: "3", // Error
|
|
103
|
+
fatal: "0", // Emergency
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Async context for distributed tracing
|
|
109
|
+
*/
|
|
110
|
+
export const tracingContext = new AsyncLocalStorage<{
|
|
111
|
+
traceId?: string;
|
|
112
|
+
spanId?: string;
|
|
113
|
+
correlationId?: string;
|
|
114
|
+
}>();
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Helper to get current tracing context
|
|
118
|
+
*/
|
|
119
|
+
export function getTracingContext() {
|
|
120
|
+
return tracingContext.getStore() || {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Helper to run code with tracing context
|
|
125
|
+
*/
|
|
126
|
+
export function withTracingContext<T>(
|
|
127
|
+
context: { traceId?: string; spanId?: string; correlationId?: string },
|
|
128
|
+
fn: () => T
|
|
129
|
+
): T {
|
|
130
|
+
return tracingContext.run(context, fn);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Performance metrics for internal instrumentation
|
|
135
|
+
*/
|
|
136
|
+
export interface LoggerMetrics {
|
|
137
|
+
logsProcessed: number;
|
|
138
|
+
logsFailed: number;
|
|
139
|
+
totalRetries: number;
|
|
140
|
+
flushCount: number;
|
|
141
|
+
averageFlushTimeMs: number;
|
|
142
|
+
queueSize: number;
|
|
143
|
+
handlerErrors: Map<string, number>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Handler with optional flush capability
|
|
148
|
+
*/
|
|
149
|
+
export interface HandlerWithFlush {
|
|
150
|
+
(log: LogEntry): Promise<void> | void;
|
|
151
|
+
flush?: () => Promise<void>;
|
|
152
|
+
metrics?: () => LoggerMetrics;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Schema version for log payloads
|
|
157
|
+
*/
|
|
158
|
+
const SCHEMA_VERSION = "dspx/log/v1";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Normalize timestamps for different observability systems
|
|
162
|
+
* @param timestamp Unix timestamp (milliseconds or seconds)
|
|
163
|
+
* @param format Target format: 'ms' | 's' | 'ns'
|
|
164
|
+
* @returns Normalized timestamp
|
|
165
|
+
*/
|
|
166
|
+
function normalizeTimestamp(
|
|
167
|
+
timestamp: number,
|
|
168
|
+
format: "ms" | "s" | "ns"
|
|
169
|
+
): number {
|
|
170
|
+
// Assume input is milliseconds if > 1e12, otherwise seconds
|
|
171
|
+
const ms = timestamp > 1e12 ? timestamp : timestamp * 1000;
|
|
172
|
+
|
|
173
|
+
switch (format) {
|
|
174
|
+
case "s":
|
|
175
|
+
return Math.floor(ms / 1000);
|
|
176
|
+
case "ms":
|
|
177
|
+
return Math.floor(ms);
|
|
178
|
+
case "ns":
|
|
179
|
+
return Math.floor(ms * 1000000);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sleep utility for retry backoff
|
|
185
|
+
*/
|
|
186
|
+
function sleep(ms: number): Promise<void> {
|
|
187
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Retry an async operation with exponential backoff
|
|
192
|
+
* @param fn Function to retry
|
|
193
|
+
* @param maxAttempts Maximum number of attempts (default: 3)
|
|
194
|
+
* @param onRetry Optional callback on each retry
|
|
195
|
+
* @returns Result of successful attempt
|
|
196
|
+
*/
|
|
197
|
+
async function retryWithBackoff<T>(
|
|
198
|
+
fn: () => Promise<T>,
|
|
199
|
+
maxAttempts: number = 3,
|
|
200
|
+
onRetry?: () => void
|
|
201
|
+
): Promise<T> {
|
|
202
|
+
let lastError: Error | undefined;
|
|
203
|
+
|
|
204
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
205
|
+
try {
|
|
206
|
+
return await fn();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
209
|
+
|
|
210
|
+
if (attempt < maxAttempts - 1) {
|
|
211
|
+
// Call retry callback if provided
|
|
212
|
+
if (onRetry) {
|
|
213
|
+
onRetry();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Exponential backoff with jitter: 100ms, 400ms, 1600ms
|
|
217
|
+
const delay = Math.pow(2, attempt) * 100 + Math.random() * 50;
|
|
218
|
+
await sleep(delay);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw lastError || new Error("Retry failed");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generate W3C traceparent header from trace/span IDs
|
|
228
|
+
* Format: 00-{trace_id}-{span_id}-01
|
|
229
|
+
*/
|
|
230
|
+
export function generateTraceparent(
|
|
231
|
+
traceId?: string,
|
|
232
|
+
spanId?: string
|
|
233
|
+
): string | undefined {
|
|
234
|
+
if (!traceId || !spanId) return undefined;
|
|
235
|
+
return `00-${traceId}-${spanId}-01`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Shared HTTP transport utility
|
|
240
|
+
* @param url Request URL
|
|
241
|
+
* @param body Request body (will be JSON stringified)
|
|
242
|
+
* @param headers Optional headers
|
|
243
|
+
* @param method HTTP method (default: POST)
|
|
244
|
+
* @returns Response
|
|
245
|
+
*/
|
|
246
|
+
/**
|
|
247
|
+
* Shared HTTP transport utility
|
|
248
|
+
* @param url Request URL
|
|
249
|
+
* @param body Request body (will be JSON stringified)
|
|
250
|
+
* @param headers Optional headers
|
|
251
|
+
* @param method HTTP method (default: POST)
|
|
252
|
+
* @param log Optional log entry for trace context
|
|
253
|
+
* @returns Response
|
|
254
|
+
*/
|
|
255
|
+
async function postJSON(
|
|
256
|
+
url: string,
|
|
257
|
+
body: any,
|
|
258
|
+
headers?: Record<string, string>,
|
|
259
|
+
method: string = "POST",
|
|
260
|
+
log?: LogEntry
|
|
261
|
+
): Promise<Response> {
|
|
262
|
+
const finalHeaders: Record<string, string> = {
|
|
263
|
+
"Content-Type": "application/json",
|
|
264
|
+
...headers,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Add W3C traceparent header if trace context available
|
|
268
|
+
if (log?.traceId && log?.spanId) {
|
|
269
|
+
const traceparent = generateTraceparent(log.traceId, log.spanId);
|
|
270
|
+
if (traceparent) {
|
|
271
|
+
finalHeaders["traceparent"] = traceparent;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const response = await fetch(url, {
|
|
276
|
+
method,
|
|
277
|
+
headers: finalHeaders,
|
|
278
|
+
body: JSON.stringify(body),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
throw new Error(`HTTP ${response.status} - ${response.statusText}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return response;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Concurrency limiter using p-limit pattern
|
|
290
|
+
*/
|
|
291
|
+
class ConcurrencyLimiter {
|
|
292
|
+
private queue: Array<() => void> = [];
|
|
293
|
+
private active = 0;
|
|
294
|
+
|
|
295
|
+
constructor(private limit: number) {}
|
|
296
|
+
|
|
297
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
298
|
+
while (this.active >= this.limit) {
|
|
299
|
+
await new Promise<void>((resolve) => this.queue.push(resolve));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this.active++;
|
|
303
|
+
try {
|
|
304
|
+
return await fn();
|
|
305
|
+
} finally {
|
|
306
|
+
this.active--;
|
|
307
|
+
const next = this.queue.shift();
|
|
308
|
+
if (next) next();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Global concurrency limiter (max 5 concurrent network requests)
|
|
315
|
+
*/
|
|
316
|
+
const concurrencyLimiter = new ConcurrencyLimiter(5);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Backend handler configuration
|
|
320
|
+
*/
|
|
321
|
+
export interface BackendConfig {
|
|
322
|
+
/** Endpoint URL */
|
|
323
|
+
endpoint?: string;
|
|
324
|
+
/** API key or token */
|
|
325
|
+
apiKey?: string;
|
|
326
|
+
/** Additional headers */
|
|
327
|
+
headers?: Record<string, string>;
|
|
328
|
+
/** Batch size for buffering */
|
|
329
|
+
batchSize?: number;
|
|
330
|
+
/** Flush interval in milliseconds */
|
|
331
|
+
flushInterval?: number;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* PagerDuty alert handler
|
|
336
|
+
* Sends critical errors to PagerDuty for incident management
|
|
337
|
+
*
|
|
338
|
+
* Authentication: Requires a routing_key (Integration Key) from PagerDuty
|
|
339
|
+
* Regional endpoints: US (default), EU requires custom endpoint
|
|
340
|
+
*
|
|
341
|
+
* @throws Error if routing_key is invalid or endpoint is unreachable
|
|
342
|
+
*/
|
|
343
|
+
export function createPagerDutyHandler(config: BackendConfig) {
|
|
344
|
+
const { endpoint, apiKey } = config;
|
|
345
|
+
const severityMap = SEVERITY_MAPPINGS.pagerduty;
|
|
346
|
+
|
|
347
|
+
if (!endpoint || !apiKey) {
|
|
348
|
+
console.warn(
|
|
349
|
+
"PagerDuty handler: endpoint or apiKey (routing_key) not configured. Logs will be dropped."
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return async (log: LogEntry): Promise<void> => {
|
|
354
|
+
if (!endpoint || !apiKey) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
await concurrencyLimiter.run(async () => {
|
|
359
|
+
await retryWithBackoff(
|
|
360
|
+
async () => {
|
|
361
|
+
const payload = {
|
|
362
|
+
schema: SCHEMA_VERSION,
|
|
363
|
+
routing_key: apiKey,
|
|
364
|
+
event_action: "trigger",
|
|
365
|
+
payload: {
|
|
366
|
+
summary: log.message,
|
|
367
|
+
severity: severityMap[log.level] || "warning",
|
|
368
|
+
source: log.topic || "dsp-pipeline",
|
|
369
|
+
timestamp: new Date(
|
|
370
|
+
normalizeTimestamp(log.timestamp, "ms")
|
|
371
|
+
).toISOString(),
|
|
372
|
+
custom_details: {
|
|
373
|
+
...log.context,
|
|
374
|
+
traceId: log.traceId,
|
|
375
|
+
spanId: log.spanId,
|
|
376
|
+
correlationId: log.correlationId,
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await postJSON(endpoint, payload, config.headers, "POST", log);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof Error && error.message.includes("401")) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`PagerDuty authentication failed: Invalid routing_key. Check your Integration Key.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
3,
|
|
393
|
+
// Retry callback (unused here, but could increment logger metrics)
|
|
394
|
+
undefined
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Prometheus metrics handler
|
|
402
|
+
* Exports metrics to Prometheus Pushgateway
|
|
403
|
+
*
|
|
404
|
+
* Authentication: Varies by setup
|
|
405
|
+
* - May require HTTP Basic Auth (pass via config.headers)
|
|
406
|
+
* - Or rely on network-level controls (VPN, firewall)
|
|
407
|
+
*
|
|
408
|
+
* @throws Error if authentication fails or endpoint is unreachable
|
|
409
|
+
*/
|
|
410
|
+
export function createPrometheusHandler(config: BackendConfig) {
|
|
411
|
+
const { endpoint } = config;
|
|
412
|
+
|
|
413
|
+
if (!endpoint) {
|
|
414
|
+
console.warn(
|
|
415
|
+
"Prometheus handler: endpoint not configured. Metrics will be dropped."
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return async (log: LogEntry): Promise<void> => {
|
|
420
|
+
if (!endpoint) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await concurrencyLimiter.run(async () => {
|
|
425
|
+
await retryWithBackoff(async () => {
|
|
426
|
+
// Convert log to Prometheus metrics format
|
|
427
|
+
const metricName = log.topic?.replace(/\./g, "_") || "pipeline_metric";
|
|
428
|
+
const labels = Object.entries(log.context || {})
|
|
429
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
430
|
+
.join(",");
|
|
431
|
+
|
|
432
|
+
const timestamp = normalizeTimestamp(log.timestamp, "ms");
|
|
433
|
+
const metric = `${metricName}{${labels},schema="${SCHEMA_VERSION}"} ${
|
|
434
|
+
log.context?.value || 1
|
|
435
|
+
} ${timestamp}`;
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const response = await fetch(`${endpoint}/metrics/job/dsp_pipeline`, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
headers: {
|
|
441
|
+
"Content-Type": "text/plain",
|
|
442
|
+
...config.headers,
|
|
443
|
+
},
|
|
444
|
+
body: metric,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
if (!response.ok) {
|
|
448
|
+
if (response.status === 401 || response.status === 403) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Prometheus authentication failed (${response.status}). Check config.headers for HTTP Basic Auth credentials.`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
throw new Error(`HTTP ${response.status} - ${response.statusText}`);
|
|
454
|
+
}
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (
|
|
457
|
+
error instanceof Error &&
|
|
458
|
+
error.message.includes("ECONNREFUSED")
|
|
459
|
+
) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`Prometheus Pushgateway unreachable at ${endpoint}. Check network and endpoint.`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Grafana Loki log handler
|
|
473
|
+
* Sends logs to Loki for centralized logging
|
|
474
|
+
*
|
|
475
|
+
* Authentication: Varies by setup
|
|
476
|
+
* - Bearer token (most common, passed as apiKey)
|
|
477
|
+
* - HTTP Basic Auth (pass via config.headers)
|
|
478
|
+
* - Multi-tenant ID in X-Scope-OrgID header (pass via config.headers)
|
|
479
|
+
*
|
|
480
|
+
* @throws Error if authentication fails or endpoint is unreachable
|
|
481
|
+
*/
|
|
482
|
+
export function createLokiHandler(config: BackendConfig): HandlerWithFlush {
|
|
483
|
+
const { endpoint, apiKey } = config;
|
|
484
|
+
const buffer: LogEntry[] = [];
|
|
485
|
+
let flushTimer: NodeJS.Timeout | null = null;
|
|
486
|
+
|
|
487
|
+
if (!endpoint) {
|
|
488
|
+
console.warn(
|
|
489
|
+
"Loki handler: endpoint not configured. Logs will be buffered but never sent."
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const flush = async (): Promise<void> => {
|
|
494
|
+
if (buffer.length === 0) return;
|
|
495
|
+
|
|
496
|
+
const logs = buffer.splice(0, buffer.length);
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
await retryWithBackoff(async () => {
|
|
500
|
+
const streams = logs.map((log) => ({
|
|
501
|
+
stream: {
|
|
502
|
+
job: "dsp-pipeline",
|
|
503
|
+
level: log.level,
|
|
504
|
+
topic: log.topic || "unknown",
|
|
505
|
+
schema: SCHEMA_VERSION,
|
|
506
|
+
...log.context,
|
|
507
|
+
},
|
|
508
|
+
values: [
|
|
509
|
+
[String(normalizeTimestamp(log.timestamp, "ns")), log.message],
|
|
510
|
+
],
|
|
511
|
+
}));
|
|
512
|
+
|
|
513
|
+
const payload = { streams };
|
|
514
|
+
|
|
515
|
+
const headers: Record<string, string> = {
|
|
516
|
+
...config.headers,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
if (apiKey) {
|
|
520
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
await postJSON(`${endpoint}/loki/api/v1/push`, payload, headers);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (error instanceof Error) {
|
|
527
|
+
if (
|
|
528
|
+
error.message.includes("401") ||
|
|
529
|
+
error.message.includes("403")
|
|
530
|
+
) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`Loki authentication failed. Check apiKey (Bearer token) or config.headers for Basic Auth / X-Scope-OrgID.`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
} catch (error) {
|
|
540
|
+
// Requeue failed logs for next flush attempt
|
|
541
|
+
buffer.unshift(...logs);
|
|
542
|
+
console.error("Loki handler error (logs requeued):", error);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const handler: HandlerWithFlush = async (log: LogEntry): Promise<void> => {
|
|
547
|
+
if (!endpoint) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
buffer.push(log);
|
|
552
|
+
|
|
553
|
+
// Auto-flush when batch size reached
|
|
554
|
+
if (buffer.length >= (config.batchSize || 100)) {
|
|
555
|
+
if (flushTimer) {
|
|
556
|
+
clearTimeout(flushTimer);
|
|
557
|
+
flushTimer = null;
|
|
558
|
+
}
|
|
559
|
+
await flush();
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Schedule flush
|
|
564
|
+
if (!flushTimer) {
|
|
565
|
+
flushTimer = setTimeout(() => {
|
|
566
|
+
flushTimer = null;
|
|
567
|
+
flush().catch(console.error);
|
|
568
|
+
}, config.flushInterval || 5000);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// Attach flush method for graceful shutdown
|
|
573
|
+
handler.flush = flush;
|
|
574
|
+
|
|
575
|
+
return handler;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* AWS CloudWatch Logs handler
|
|
580
|
+
* Sends logs to CloudWatch for AWS-native logging
|
|
581
|
+
*
|
|
582
|
+
* ⚠️ WARNING: Manual AWS authentication is error-prone!
|
|
583
|
+
* This implementation uses simple API key passing, which may not work
|
|
584
|
+
* in many AWS environments (IAM roles, instance profiles, temporary credentials).
|
|
585
|
+
*
|
|
586
|
+
* RECOMMENDED: Use @aws-sdk/client-cloudwatch-logs for automatic credential
|
|
587
|
+
* discovery and request signing instead of this manual approach.
|
|
588
|
+
*
|
|
589
|
+
* Authentication requirements:
|
|
590
|
+
* - AWS Access Key ID and Secret Access Key
|
|
591
|
+
* - Proper IAM permissions (logs:PutLogEvents, logs:CreateLogStream, etc.)
|
|
592
|
+
* - Regional endpoint configuration
|
|
593
|
+
*
|
|
594
|
+
* @deprecated Consider using AWS SDK instead for production use
|
|
595
|
+
* @throws Error if credentials are invalid or permissions are insufficient
|
|
596
|
+
*/
|
|
597
|
+
export function createCloudWatchHandler(config: BackendConfig) {
|
|
598
|
+
const { endpoint, apiKey } = config;
|
|
599
|
+
|
|
600
|
+
if (!endpoint || !apiKey) {
|
|
601
|
+
console.warn(
|
|
602
|
+
"CloudWatch handler: endpoint or apiKey not configured. " +
|
|
603
|
+
"Note: AWS authentication is complex - consider using @aws-sdk/client-cloudwatch-logs " +
|
|
604
|
+
"for automatic credential discovery. Logs will be dropped."
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return async (log: LogEntry): Promise<void> => {
|
|
609
|
+
if (!endpoint) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
await concurrencyLimiter.run(async () => {
|
|
614
|
+
await retryWithBackoff(async () => {
|
|
615
|
+
const payload = {
|
|
616
|
+
schema: SCHEMA_VERSION,
|
|
617
|
+
logGroupName: "/dsp-pipeline/logs",
|
|
618
|
+
logStreamName: log.topic || "default",
|
|
619
|
+
logEvents: [
|
|
620
|
+
{
|
|
621
|
+
message: JSON.stringify({
|
|
622
|
+
level: log.level,
|
|
623
|
+
message: log.message,
|
|
624
|
+
...log.context,
|
|
625
|
+
}),
|
|
626
|
+
timestamp: normalizeTimestamp(log.timestamp, "ms"),
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
await postJSON(endpoint, payload, {
|
|
633
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
634
|
+
"X-Amz-Target": "Logs_20140328.PutLogEvents",
|
|
635
|
+
Authorization: apiKey || "",
|
|
636
|
+
...config.headers,
|
|
637
|
+
});
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (error instanceof Error) {
|
|
640
|
+
if (
|
|
641
|
+
error.message.includes("403") ||
|
|
642
|
+
error.message.includes("UnauthorizedOperation") ||
|
|
643
|
+
error.message.includes("InvalidClientTokenId")
|
|
644
|
+
) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
`CloudWatch authentication failed. AWS requires proper IAM credentials and request signing. ` +
|
|
647
|
+
`Consider using @aws-sdk/client-cloudwatch-logs instead of manual fetch. Error: ${error.message}`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Datadog log handler
|
|
660
|
+
* Sends logs to Datadog for unified observability
|
|
661
|
+
*
|
|
662
|
+
* Authentication: API key embedded in URL path
|
|
663
|
+
* Regional endpoints: US (default), EU requires custom endpoint
|
|
664
|
+
*
|
|
665
|
+
* @throws Error if API key is invalid or endpoint is incorrect
|
|
666
|
+
*/
|
|
667
|
+
export function createDatadogHandler(config: BackendConfig) {
|
|
668
|
+
const { endpoint = "https://http-intake.logs.datadoghq.com", apiKey } =
|
|
669
|
+
config;
|
|
670
|
+
const severityMap = SEVERITY_MAPPINGS.datadog;
|
|
671
|
+
|
|
672
|
+
if (!apiKey) {
|
|
673
|
+
console.warn(
|
|
674
|
+
"Datadog handler: apiKey not configured. Logs will be dropped."
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return async (log: LogEntry): Promise<void> => {
|
|
679
|
+
if (!apiKey) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
await concurrencyLimiter.run(async () => {
|
|
684
|
+
await retryWithBackoff(async () => {
|
|
685
|
+
const payload = {
|
|
686
|
+
schema: SCHEMA_VERSION,
|
|
687
|
+
ddsource: "dsp-pipeline",
|
|
688
|
+
ddtags: `topic:${log.topic},level:${log.level}`,
|
|
689
|
+
hostname: "localhost",
|
|
690
|
+
message: log.message,
|
|
691
|
+
service: "dsp-pipeline",
|
|
692
|
+
status: severityMap[log.level] || "info",
|
|
693
|
+
timestamp: normalizeTimestamp(log.timestamp, "ms"),
|
|
694
|
+
dd: {
|
|
695
|
+
trace_id: log.traceId,
|
|
696
|
+
span_id: log.spanId,
|
|
697
|
+
},
|
|
698
|
+
...log.context,
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
await postJSON(
|
|
703
|
+
`${endpoint}/v1/input/${apiKey}`,
|
|
704
|
+
payload,
|
|
705
|
+
config.headers
|
|
706
|
+
);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (error instanceof Error) {
|
|
709
|
+
if (
|
|
710
|
+
error.message.includes("403") ||
|
|
711
|
+
error.message.includes("401")
|
|
712
|
+
) {
|
|
713
|
+
throw new Error(
|
|
714
|
+
`Datadog authentication failed. Check apiKey validity and regional endpoint (US vs EU). Error: ${error.message}`
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Console handler (for development/debugging)
|
|
727
|
+
* Pretty-prints logs to console with colors
|
|
728
|
+
*/
|
|
729
|
+
export function createConsoleHandler(config: Partial<BackendConfig> = {}) {
|
|
730
|
+
const colors = {
|
|
731
|
+
trace: "\x1b[90m", // Gray
|
|
732
|
+
debug: "\x1b[36m", // Cyan
|
|
733
|
+
info: "\x1b[32m", // Green
|
|
734
|
+
warn: "\x1b[33m", // Yellow
|
|
735
|
+
error: "\x1b[31m", // Red
|
|
736
|
+
fatal: "\x1b[35;1m", // Bright Magenta
|
|
737
|
+
reset: "\x1b[0m",
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
return (log: LogEntry): void => {
|
|
741
|
+
const color = colors[log.level] || colors.reset;
|
|
742
|
+
const timestamp = new Date(log.timestamp).toISOString();
|
|
743
|
+
const traceInfo = log.traceId ? ` [trace:${log.traceId.slice(0, 8)}]` : "";
|
|
744
|
+
|
|
745
|
+
console.log(
|
|
746
|
+
`${color}[${timestamp}] [${log.level.toUpperCase()}] [${
|
|
747
|
+
log.topic || "unknown"
|
|
748
|
+
}]${traceInfo}${colors.reset} ${log.message}`
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
if (log.context && Object.keys(log.context).length > 0) {
|
|
752
|
+
console.log(`${color} Context:${colors.reset}`, log.context);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Create a mock handler for testing
|
|
759
|
+
*/
|
|
760
|
+
export function createMockHandler(onLog?: (log: LogEntry) => void) {
|
|
761
|
+
const logs: LogEntry[] = [];
|
|
762
|
+
|
|
763
|
+
const handler = (log: LogEntry): void => {
|
|
764
|
+
logs.push(log);
|
|
765
|
+
if (onLog) {
|
|
766
|
+
onLog(log);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
handler,
|
|
772
|
+
getLogs: () => [...logs],
|
|
773
|
+
clear: () => logs.splice(0, logs.length),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Sampling configuration for high-volume logs
|
|
779
|
+
*/
|
|
780
|
+
export interface SamplingConfig {
|
|
781
|
+
trace?: number; // Sampling rate 0-1 (e.g., 0.01 = 1%)
|
|
782
|
+
debug?: number;
|
|
783
|
+
info?: number;
|
|
784
|
+
warn?: number;
|
|
785
|
+
error?: number;
|
|
786
|
+
fatal?: number;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Logger options
|
|
791
|
+
*/
|
|
792
|
+
export interface LoggerOptions {
|
|
793
|
+
fallbackHandler?: (log: LogEntry) => void;
|
|
794
|
+
severityMapping?: SeverityMapping;
|
|
795
|
+
enableMetrics?: boolean;
|
|
796
|
+
formatter?: Formatter;
|
|
797
|
+
sampling?: SamplingConfig;
|
|
798
|
+
minLevel?: LogLevel; // Minimum level to log (dynamic control)
|
|
799
|
+
autoShutdown?: boolean; // Auto-register SIGTERM/SIGINT handlers
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Unified Logger class - orchestrates multiple backend handlers
|
|
804
|
+
*
|
|
805
|
+
* Features:
|
|
806
|
+
* - Multiple handler dispatch with Promise.all
|
|
807
|
+
* - Error isolation (one handler failure doesn't affect others)
|
|
808
|
+
* - Structured error logging to fallback console handler
|
|
809
|
+
* - Extended log levels (trace -> fatal)
|
|
810
|
+
* - Distributed tracing support (auto-inject trace/span IDs)
|
|
811
|
+
* - Graceful shutdown with flushAll()
|
|
812
|
+
* - Internal performance metrics
|
|
813
|
+
* - Custom severity mappings
|
|
814
|
+
*
|
|
815
|
+
* @example
|
|
816
|
+
* ```ts
|
|
817
|
+
* const logger = new Logger([
|
|
818
|
+
* createConsoleHandler(),
|
|
819
|
+
* createLokiHandler({ endpoint: "...", apiKey: "..." }),
|
|
820
|
+
* ], {
|
|
821
|
+
* severityMapping: SEVERITY_MAPPINGS.datadog,
|
|
822
|
+
* enableMetrics: true,
|
|
823
|
+
* });
|
|
824
|
+
*
|
|
825
|
+
* await logger.info("Pipeline initialized");
|
|
826
|
+
* await logger.error("Connection failed", "redis.connection", { host: "localhost" });
|
|
827
|
+
*
|
|
828
|
+
* // Graceful shutdown
|
|
829
|
+
* process.on("SIGTERM", async () => {
|
|
830
|
+
* await logger.flushAll();
|
|
831
|
+
* process.exit(0);
|
|
832
|
+
* });
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
export class Logger {
|
|
836
|
+
private fallbackHandler: (log: LogEntry) => void;
|
|
837
|
+
private severityMapping?: SeverityMapping;
|
|
838
|
+
private enableMetrics: boolean;
|
|
839
|
+
private formatter: Formatter;
|
|
840
|
+
private sampling?: SamplingConfig;
|
|
841
|
+
private minLevel: LogLevel;
|
|
842
|
+
private readonly levelOrder: Record<LogLevel, number> = {
|
|
843
|
+
trace: 0,
|
|
844
|
+
debug: 1,
|
|
845
|
+
info: 2,
|
|
846
|
+
warn: 3,
|
|
847
|
+
error: 4,
|
|
848
|
+
fatal: 5,
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// Internal metrics
|
|
852
|
+
private metrics: LoggerMetrics = {
|
|
853
|
+
logsProcessed: 0,
|
|
854
|
+
logsFailed: 0,
|
|
855
|
+
totalRetries: 0,
|
|
856
|
+
flushCount: 0,
|
|
857
|
+
averageFlushTimeMs: 0,
|
|
858
|
+
queueSize: 0,
|
|
859
|
+
handlerErrors: new Map(),
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
constructor(
|
|
863
|
+
private handlers: Array<HandlerWithFlush>,
|
|
864
|
+
options?: LoggerOptions
|
|
865
|
+
) {
|
|
866
|
+
this.fallbackHandler = options?.fallbackHandler || createConsoleHandler();
|
|
867
|
+
this.severityMapping = options?.severityMapping;
|
|
868
|
+
this.enableMetrics = options?.enableMetrics ?? false;
|
|
869
|
+
this.formatter = options?.formatter || new JSONFormatter();
|
|
870
|
+
this.sampling = options?.sampling;
|
|
871
|
+
this.minLevel = options?.minLevel || "trace";
|
|
872
|
+
|
|
873
|
+
// Auto-register shutdown handlers if requested
|
|
874
|
+
if (options?.autoShutdown) {
|
|
875
|
+
["SIGINT", "SIGTERM"].forEach((sig) => {
|
|
876
|
+
process.on(sig, async () => {
|
|
877
|
+
await this.flushAll();
|
|
878
|
+
process.exit(0);
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Set minimum log level dynamically
|
|
886
|
+
*/
|
|
887
|
+
setMinLevel(level: LogLevel): void {
|
|
888
|
+
this.minLevel = level;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Get current minimum log level
|
|
893
|
+
*/
|
|
894
|
+
getMinLevel(): LogLevel {
|
|
895
|
+
return this.minLevel;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Check if a log should be sampled (returns true to log, false to skip)
|
|
900
|
+
*/
|
|
901
|
+
private shouldSample(level: LogLevel): boolean {
|
|
902
|
+
if (!this.sampling) return true;
|
|
903
|
+
|
|
904
|
+
const rate = this.sampling[level];
|
|
905
|
+
if (rate === undefined) return true;
|
|
906
|
+
|
|
907
|
+
return Math.random() < rate;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Check if a log level meets the minimum threshold
|
|
912
|
+
*/
|
|
913
|
+
private meetsMinLevel(level: LogLevel): boolean {
|
|
914
|
+
return this.levelOrder[level] >= this.levelOrder[this.minLevel];
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Increment retry counter (called by handlers)
|
|
919
|
+
*/
|
|
920
|
+
incrementRetries(): void {
|
|
921
|
+
if (this.enableMetrics) {
|
|
922
|
+
this.metrics.totalRetries++;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Log a message at the specified level
|
|
928
|
+
*/
|
|
929
|
+
async log(
|
|
930
|
+
level: LogLevel,
|
|
931
|
+
message: string,
|
|
932
|
+
topic?: string,
|
|
933
|
+
context?: any
|
|
934
|
+
): Promise<void> {
|
|
935
|
+
// Check minimum level threshold
|
|
936
|
+
if (!this.meetsMinLevel(level)) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Apply sampling
|
|
941
|
+
if (!this.shouldSample(level)) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Auto-inject tracing context if available
|
|
946
|
+
const tracingCtx = getTracingContext();
|
|
947
|
+
|
|
948
|
+
const entry: LogEntry = {
|
|
949
|
+
level,
|
|
950
|
+
message,
|
|
951
|
+
topic: topic || "default",
|
|
952
|
+
context,
|
|
953
|
+
timestamp: Date.now(),
|
|
954
|
+
traceId: tracingCtx.traceId,
|
|
955
|
+
spanId: tracingCtx.spanId,
|
|
956
|
+
correlationId: tracingCtx.correlationId,
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
if (this.enableMetrics) {
|
|
960
|
+
this.metrics.logsProcessed++;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Apply formatter
|
|
964
|
+
const formattedEntry = this.formatter.format(entry);
|
|
965
|
+
|
|
966
|
+
// Dispatch to all handlers in parallel, isolating errors
|
|
967
|
+
await Promise.all(
|
|
968
|
+
this.handlers.map(async (handler) => {
|
|
969
|
+
try {
|
|
970
|
+
await handler(formattedEntry);
|
|
971
|
+
} catch (error) {
|
|
972
|
+
if (this.enableMetrics) {
|
|
973
|
+
this.metrics.logsFailed++;
|
|
974
|
+
const handlerName = handler.name || "anonymous";
|
|
975
|
+
this.metrics.handlerErrors.set(
|
|
976
|
+
handlerName,
|
|
977
|
+
(this.metrics.handlerErrors.get(handlerName) || 0) + 1
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Emit handler errors as structured LogEntry to fallback
|
|
982
|
+
const errorEntry: LogEntry = {
|
|
983
|
+
level: "error",
|
|
984
|
+
message: `Handler error: ${
|
|
985
|
+
error instanceof Error ? error.message : String(error)
|
|
986
|
+
}`,
|
|
987
|
+
topic: "logger.handler.error",
|
|
988
|
+
context: {
|
|
989
|
+
originalLog: entry,
|
|
990
|
+
error:
|
|
991
|
+
error instanceof Error
|
|
992
|
+
? {
|
|
993
|
+
name: error.name,
|
|
994
|
+
message: error.message,
|
|
995
|
+
stack: error.stack,
|
|
996
|
+
}
|
|
997
|
+
: String(error),
|
|
998
|
+
},
|
|
999
|
+
timestamp: Date.now(),
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
try {
|
|
1003
|
+
this.fallbackHandler(errorEntry);
|
|
1004
|
+
} catch (fallbackError) {
|
|
1005
|
+
console.error("Fallback handler failed:", fallbackError);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
})
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Log trace message (most verbose)
|
|
1014
|
+
*/
|
|
1015
|
+
async trace(message: string, topic?: string, context?: any): Promise<void> {
|
|
1016
|
+
await this.log("trace", message, topic, context);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Log debug message
|
|
1021
|
+
*/
|
|
1022
|
+
async debug(message: string, topic?: string, context?: any): Promise<void> {
|
|
1023
|
+
await this.log("debug", message, topic, context);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Log info message
|
|
1028
|
+
*/
|
|
1029
|
+
async info(message: string, topic?: string, context?: any): Promise<void> {
|
|
1030
|
+
await this.log("info", message, topic, context);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Log warning message
|
|
1035
|
+
*/
|
|
1036
|
+
async warn(message: string, topic?: string, context?: any): Promise<void> {
|
|
1037
|
+
await this.log("warn", message, topic, context);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Log error message
|
|
1042
|
+
*/
|
|
1043
|
+
async error(message: string, topic?: string, context?: any): Promise<void> {
|
|
1044
|
+
await this.log("error", message, topic, context);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Log fatal message (most critical)
|
|
1049
|
+
*/
|
|
1050
|
+
async fatal(message: string, topic?: string, context?: any): Promise<void> {
|
|
1051
|
+
await this.log("fatal", message, topic, context);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Flush all handlers (for graceful shutdown)
|
|
1056
|
+
*/
|
|
1057
|
+
async flushAll(): Promise<void> {
|
|
1058
|
+
const startTime = Date.now();
|
|
1059
|
+
|
|
1060
|
+
await Promise.all(
|
|
1061
|
+
this.handlers.map(async (handler) => {
|
|
1062
|
+
if (handler.flush) {
|
|
1063
|
+
try {
|
|
1064
|
+
await handler.flush();
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
console.error("Handler flush error:", error);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
})
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
if (this.enableMetrics) {
|
|
1073
|
+
this.metrics.flushCount++;
|
|
1074
|
+
const flushTime = Date.now() - startTime;
|
|
1075
|
+
this.metrics.averageFlushTimeMs =
|
|
1076
|
+
(this.metrics.averageFlushTimeMs * (this.metrics.flushCount - 1) +
|
|
1077
|
+
flushTime) /
|
|
1078
|
+
this.metrics.flushCount;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Get internal performance metrics
|
|
1084
|
+
*/
|
|
1085
|
+
getMetrics(): LoggerMetrics {
|
|
1086
|
+
return { ...this.metrics };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Reset internal metrics
|
|
1091
|
+
*/
|
|
1092
|
+
resetMetrics(): void {
|
|
1093
|
+
this.metrics = {
|
|
1094
|
+
logsProcessed: 0,
|
|
1095
|
+
logsFailed: 0,
|
|
1096
|
+
totalRetries: 0,
|
|
1097
|
+
flushCount: 0,
|
|
1098
|
+
averageFlushTimeMs: 0,
|
|
1099
|
+
queueSize: 0,
|
|
1100
|
+
handlerErrors: new Map(),
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Get severity mapping for current logger
|
|
1106
|
+
*/
|
|
1107
|
+
getSeverityMapping(): SeverityMapping | undefined {
|
|
1108
|
+
return this.severityMapping;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Create a child logger with a default topic prefix
|
|
1113
|
+
*/
|
|
1114
|
+
child(topicPrefix: string): Logger {
|
|
1115
|
+
// Create a new logger that shares handlers but prefixes topics
|
|
1116
|
+
const childLogger = new Logger(this.handlers, {
|
|
1117
|
+
fallbackHandler: this.fallbackHandler,
|
|
1118
|
+
severityMapping: this.severityMapping,
|
|
1119
|
+
enableMetrics: false, // Don't double-count metrics
|
|
1120
|
+
formatter: this.formatter,
|
|
1121
|
+
sampling: this.sampling,
|
|
1122
|
+
minLevel: this.minLevel,
|
|
1123
|
+
autoShutdown: false, // Don't re-register shutdown handlers
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// Override the log method to prefix topics
|
|
1127
|
+
const originalLog = childLogger.log.bind(childLogger);
|
|
1128
|
+
childLogger.log = async (level, message, topic, context) => {
|
|
1129
|
+
const prefixedTopic = topic
|
|
1130
|
+
? `${topicPrefix}.${topic}`
|
|
1131
|
+
: `${topicPrefix}.default`;
|
|
1132
|
+
return originalLog(level, message, prefixedTopic, context);
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
return childLogger;
|
|
1136
|
+
}
|
|
1137
|
+
}
|