@littlebearapps/platform-consumer-sdk 1.0.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/src/logging.ts ADDED
@@ -0,0 +1,543 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Platform SDK Logging
5
+ *
6
+ * Structured JSON logging for Workers Observability.
7
+ * Provides correlation IDs, error categorisation, and timed operations.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { createLoggerFromEnv } from './lib/platform-sdk';
12
+ *
13
+ * const log = createLoggerFromEnv(env, 'stripe-connector', 'platform:connector:stripe');
14
+ * log.info('Starting sync', { customerId: 'cus_123' });
15
+ *
16
+ * try {
17
+ * const duration = await log.timed('fetch_customers', async () => {
18
+ * return await stripe.customers.list();
19
+ * });
20
+ * } catch (error) {
21
+ * log.error('Sync failed', error);
22
+ * }
23
+ * ```
24
+ */
25
+
26
+ import { CircuitBreakerError } from './types';
27
+ import { extractTraceContext, setTraceContext } from './tracing';
28
+
29
+ // =============================================================================
30
+ // TYPES
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Log severity levels.
35
+ * Maps to Workers Observability log levels.
36
+ */
37
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
38
+
39
+ /**
40
+ * Error category for classification.
41
+ * Used for alerting priority and deduplication.
42
+ */
43
+ export type ErrorCategory =
44
+ | 'VALIDATION' // Input/schema validation errors
45
+ | 'NETWORK' // Network/timeout errors
46
+ | 'CIRCUIT_BREAKER' // Circuit breaker tripped
47
+ | 'INTERNAL' // Internal/unexpected errors
48
+ | 'AUTH' // Authentication/authorisation errors
49
+ | 'RATE_LIMIT' // Rate limiting errors
50
+ | 'D1_ERROR' // D1 database errors
51
+ | 'KV_ERROR' // KV namespace errors
52
+ | 'QUEUE_ERROR' // Queue errors
53
+ | 'EXTERNAL_API'; // External API errors
54
+
55
+ /**
56
+ * Structured log entry.
57
+ * JSON format for Workers Observability.
58
+ */
59
+ export interface StructuredLog {
60
+ /** Log level */
61
+ level: LogLevel;
62
+ /** Human-readable message */
63
+ message: string;
64
+ /** ISO 8601 timestamp */
65
+ timestamp: string;
66
+ /** Correlation ID for request tracing */
67
+ correlationId?: string;
68
+ /** W3C Trace ID (32 hex chars) for distributed tracing */
69
+ traceId?: string;
70
+ /** W3C Span ID (16 hex chars) for distributed tracing */
71
+ spanId?: string;
72
+ /** Feature ID (project:category:feature) */
73
+ featureId?: string;
74
+ /** Worker name */
75
+ worker: string;
76
+ /** Error category (for error/warn levels) */
77
+ category?: ErrorCategory;
78
+ /** Error details */
79
+ error?: {
80
+ name: string;
81
+ message: string;
82
+ stack?: string;
83
+ code?: string;
84
+ };
85
+ /** Additional context */
86
+ context?: Record<string, unknown>;
87
+ /** Operation duration in milliseconds */
88
+ durationMs?: number;
89
+ }
90
+
91
+ /**
92
+ * Logger configuration options.
93
+ */
94
+ export interface LoggerOptions {
95
+ /** Worker name for identification */
96
+ worker: string;
97
+ /** Feature ID for budget tracking */
98
+ featureId?: string;
99
+ /** Correlation ID for request tracing */
100
+ correlationId?: string;
101
+ /** W3C Trace ID for distributed tracing */
102
+ traceId?: string;
103
+ /** W3C Span ID for distributed tracing */
104
+ spanId?: string;
105
+ /** Minimum log level (default: 'info') */
106
+ minLevel?: LogLevel;
107
+ /** Additional default context */
108
+ defaultContext?: Record<string, unknown>;
109
+ }
110
+
111
+ /**
112
+ * Logger interface.
113
+ */
114
+ export interface Logger {
115
+ /** Log debug message */
116
+ debug(message: string, context?: Record<string, unknown>): void;
117
+ /** Log info message */
118
+ info(message: string, context?: Record<string, unknown>): void;
119
+ /** Log warning message (with optional error) */
120
+ warn(message: string, error?: unknown, context?: Record<string, unknown>): void;
121
+ /** Log error message */
122
+ error(message: string, error?: unknown, context?: Record<string, unknown>): void;
123
+ /** Time an async operation and log its duration */
124
+ timed<T>(operation: string, fn: () => Promise<T>, context?: Record<string, unknown>): Promise<T>;
125
+ /** Create a child logger with additional context */
126
+ child(context: Record<string, unknown>): Logger;
127
+ /** Get the correlation ID */
128
+ readonly correlationId: string;
129
+ }
130
+
131
+ // =============================================================================
132
+ // CORRELATION ID MANAGEMENT
133
+ // =============================================================================
134
+
135
+ /**
136
+ * WeakMap to store correlation IDs per environment.
137
+ * Same pattern as telemetry context.
138
+ */
139
+ const correlationIds = new WeakMap<object, string>();
140
+
141
+ /**
142
+ * Generate a new correlation ID.
143
+ * Uses crypto.randomUUID() for uniqueness.
144
+ */
145
+ export function generateCorrelationId(): string {
146
+ return crypto.randomUUID();
147
+ }
148
+
149
+ /**
150
+ * Get or create a correlation ID for an environment.
151
+ */
152
+ export function getCorrelationId(env: object): string {
153
+ let id = correlationIds.get(env);
154
+ if (!id) {
155
+ id = generateCorrelationId();
156
+ correlationIds.set(env, id);
157
+ }
158
+ return id;
159
+ }
160
+
161
+ /**
162
+ * Set a specific correlation ID for an environment.
163
+ * Useful for propagating IDs from incoming requests.
164
+ */
165
+ export function setCorrelationId(env: object, correlationId: string): void {
166
+ correlationIds.set(env, correlationId);
167
+ }
168
+
169
+ // =============================================================================
170
+ // ERROR CATEGORISATION
171
+ // =============================================================================
172
+
173
+ /**
174
+ * Categorise an error based on its type and message.
175
+ * Uses error name, message patterns, and known error types.
176
+ */
177
+ export function categoriseError(error: unknown): ErrorCategory {
178
+ if (error instanceof CircuitBreakerError) {
179
+ return 'CIRCUIT_BREAKER';
180
+ }
181
+
182
+ if (error instanceof Error) {
183
+ const name = error.name.toLowerCase();
184
+ const message = error.message.toLowerCase();
185
+
186
+ // Auth errors
187
+ if (
188
+ name.includes('auth') ||
189
+ name.includes('unauthorized') ||
190
+ name.includes('forbidden') ||
191
+ message.includes('unauthorized') ||
192
+ message.includes('forbidden') ||
193
+ message.includes('401') ||
194
+ message.includes('403')
195
+ ) {
196
+ return 'AUTH';
197
+ }
198
+
199
+ // Rate limit errors
200
+ if (
201
+ name.includes('ratelimit') ||
202
+ message.includes('rate limit') ||
203
+ message.includes('too many requests') ||
204
+ message.includes('429')
205
+ ) {
206
+ return 'RATE_LIMIT';
207
+ }
208
+
209
+ // Network/timeout errors
210
+ if (
211
+ name.includes('timeout') ||
212
+ name.includes('network') ||
213
+ name.includes('fetch') ||
214
+ message.includes('timeout') ||
215
+ message.includes('network') ||
216
+ message.includes('econnrefused') ||
217
+ message.includes('enotfound') ||
218
+ message.includes('socket hang up')
219
+ ) {
220
+ return 'NETWORK';
221
+ }
222
+
223
+ // Validation errors
224
+ if (
225
+ name.includes('validation') ||
226
+ name.includes('schema') ||
227
+ name.includes('parse') ||
228
+ message.includes('invalid') ||
229
+ message.includes('required') ||
230
+ message.includes('expected')
231
+ ) {
232
+ return 'VALIDATION';
233
+ }
234
+
235
+ // D1 errors
236
+ if (name.includes('d1') || message.includes('d1_error') || message.includes('sqlite')) {
237
+ return 'D1_ERROR';
238
+ }
239
+
240
+ // KV errors
241
+ if (name.includes('kv') || message.includes('kv_error') || message.includes('namespace')) {
242
+ return 'KV_ERROR';
243
+ }
244
+
245
+ // Queue errors
246
+ if (name.includes('queue') || message.includes('queue_error')) {
247
+ return 'QUEUE_ERROR';
248
+ }
249
+
250
+ // External API errors (by common status codes)
251
+ if (
252
+ message.includes('500') ||
253
+ message.includes('502') ||
254
+ message.includes('503') ||
255
+ message.includes('504')
256
+ ) {
257
+ return 'EXTERNAL_API';
258
+ }
259
+ }
260
+
261
+ return 'INTERNAL';
262
+ }
263
+
264
+ /**
265
+ * Extract error code from an error if available.
266
+ */
267
+ export function extractErrorCode(error: unknown): string | undefined {
268
+ if (error && typeof error === 'object') {
269
+ const errorObj = error as Record<string, unknown>;
270
+ if (typeof errorObj.code === 'string') {
271
+ return errorObj.code;
272
+ }
273
+ if (typeof errorObj.errno === 'string') {
274
+ return errorObj.errno;
275
+ }
276
+ if (typeof errorObj.status === 'number') {
277
+ return `HTTP_${errorObj.status}`;
278
+ }
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ // =============================================================================
284
+ // LOG LEVEL FILTERING
285
+ // =============================================================================
286
+
287
+ const LOG_LEVELS: Record<LogLevel, number> = {
288
+ debug: 0,
289
+ info: 1,
290
+ warn: 2,
291
+ error: 3,
292
+ };
293
+
294
+ /**
295
+ * Check if a log level should be output based on minimum level.
296
+ */
297
+ function shouldLog(level: LogLevel, minLevel: LogLevel): boolean {
298
+ return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
299
+ }
300
+
301
+ // =============================================================================
302
+ // LOGGER IMPLEMENTATION
303
+ // =============================================================================
304
+
305
+ /**
306
+ * Create a structured logger.
307
+ */
308
+ export function createLogger(options: LoggerOptions): Logger {
309
+ const {
310
+ worker,
311
+ featureId,
312
+ correlationId,
313
+ traceId,
314
+ spanId,
315
+ minLevel = 'info',
316
+ defaultContext = {},
317
+ } = options;
318
+
319
+ const logCorrelationId = correlationId ?? generateCorrelationId();
320
+
321
+ function formatLog(
322
+ level: LogLevel,
323
+ message: string,
324
+ error?: unknown,
325
+ context?: Record<string, unknown>,
326
+ durationMs?: number
327
+ ): StructuredLog {
328
+ const log: StructuredLog = {
329
+ level,
330
+ message,
331
+ timestamp: new Date().toISOString(),
332
+ worker,
333
+ };
334
+
335
+ if (logCorrelationId) {
336
+ log.correlationId = logCorrelationId;
337
+ }
338
+
339
+ // Add distributed tracing context
340
+ if (traceId) {
341
+ log.traceId = traceId;
342
+ }
343
+ if (spanId) {
344
+ log.spanId = spanId;
345
+ }
346
+
347
+ if (featureId) {
348
+ log.featureId = featureId;
349
+ }
350
+
351
+ if (error) {
352
+ log.category = categoriseError(error);
353
+ log.error = {
354
+ name: error instanceof Error ? error.name : 'Error',
355
+ message: error instanceof Error ? error.message : String(error),
356
+ code: extractErrorCode(error),
357
+ };
358
+ // Only include stack in debug mode or for errors
359
+ if (error instanceof Error && error.stack && level === 'error') {
360
+ log.error.stack = error.stack;
361
+ }
362
+ }
363
+
364
+ if (durationMs !== undefined) {
365
+ log.durationMs = durationMs;
366
+ }
367
+
368
+ // Merge default context with provided context
369
+ const mergedContext = { ...defaultContext, ...context };
370
+ if (Object.keys(mergedContext).length > 0) {
371
+ log.context = mergedContext;
372
+ }
373
+
374
+ return log;
375
+ }
376
+
377
+ function output(log: StructuredLog): void {
378
+ // Output as JSON for Workers Observability
379
+ const json = JSON.stringify(log);
380
+
381
+ switch (log.level) {
382
+ case 'debug':
383
+ console.debug(json);
384
+ break;
385
+ case 'info':
386
+ console.log(json);
387
+ break;
388
+ case 'warn':
389
+ console.warn(json);
390
+ break;
391
+ case 'error':
392
+ console.error(json);
393
+ break;
394
+ }
395
+ }
396
+
397
+ const logger: Logger = {
398
+ get correlationId() {
399
+ return logCorrelationId;
400
+ },
401
+
402
+ debug(message: string, context?: Record<string, unknown>): void {
403
+ if (shouldLog('debug', minLevel)) {
404
+ output(formatLog('debug', message, undefined, context));
405
+ }
406
+ },
407
+
408
+ info(message: string, context?: Record<string, unknown>): void {
409
+ if (shouldLog('info', minLevel)) {
410
+ output(formatLog('info', message, undefined, context));
411
+ }
412
+ },
413
+
414
+ warn(message: string, error?: unknown, context?: Record<string, unknown>): void {
415
+ if (shouldLog('warn', minLevel)) {
416
+ output(formatLog('warn', message, error, context));
417
+ }
418
+ },
419
+
420
+ error(message: string, error?: unknown, context?: Record<string, unknown>): void {
421
+ if (shouldLog('error', minLevel)) {
422
+ output(formatLog('error', message, error, context));
423
+ }
424
+ },
425
+
426
+ async timed<T>(
427
+ operation: string,
428
+ fn: () => Promise<T>,
429
+ context?: Record<string, unknown>
430
+ ): Promise<T> {
431
+ const start = Date.now();
432
+ try {
433
+ const result = await fn();
434
+ const durationMs = Date.now() - start;
435
+ if (shouldLog('info', minLevel)) {
436
+ output(formatLog('info', `${operation} completed`, undefined, context, durationMs));
437
+ }
438
+ return result;
439
+ } catch (error) {
440
+ const durationMs = Date.now() - start;
441
+ if (shouldLog('error', minLevel)) {
442
+ output(formatLog('error', `${operation} failed`, error, context, durationMs));
443
+ }
444
+ throw error;
445
+ }
446
+ },
447
+
448
+ child(context: Record<string, unknown>): Logger {
449
+ return createLogger({
450
+ worker,
451
+ featureId,
452
+ correlationId: logCorrelationId,
453
+ traceId,
454
+ spanId,
455
+ minLevel,
456
+ defaultContext: { ...defaultContext, ...context },
457
+ });
458
+ },
459
+ };
460
+
461
+ return logger;
462
+ }
463
+
464
+ /**
465
+ * Create a logger from a tracked environment.
466
+ * Automatically extracts or creates correlation ID.
467
+ *
468
+ * @param env - Worker environment (tracked or raw)
469
+ * @param worker - Worker name
470
+ * @param featureId - Feature ID for budget tracking
471
+ * @param minLevel - Minimum log level (default: 'info')
472
+ */
473
+ export function createLoggerFromEnv(
474
+ env: object,
475
+ worker: string,
476
+ featureId?: string,
477
+ minLevel: LogLevel = 'info'
478
+ ): Logger {
479
+ const correlationId = getCorrelationId(env);
480
+
481
+ return createLogger({
482
+ worker,
483
+ featureId,
484
+ correlationId,
485
+ minLevel,
486
+ });
487
+ }
488
+
489
+ // =============================================================================
490
+ // REQUEST CONTEXT HELPERS
491
+ // =============================================================================
492
+
493
+ /**
494
+ * Extract correlation ID from request headers.
495
+ * Looks for common correlation ID headers.
496
+ */
497
+ export function extractCorrelationIdFromRequest(request: Request): string | undefined {
498
+ const headers = [
499
+ 'x-correlation-id',
500
+ 'x-request-id',
501
+ 'x-trace-id',
502
+ 'cf-ray', // Cloudflare Ray ID as fallback
503
+ ];
504
+
505
+ for (const header of headers) {
506
+ const value = request.headers.get(header);
507
+ if (value) {
508
+ return value;
509
+ }
510
+ }
511
+
512
+ return undefined;
513
+ }
514
+
515
+ /**
516
+ * Create a logger from an incoming request.
517
+ * Extracts correlation ID and trace context from headers if present.
518
+ */
519
+ export function createLoggerFromRequest(
520
+ request: Request,
521
+ env: object,
522
+ worker: string,
523
+ featureId?: string,
524
+ minLevel: LogLevel = 'info'
525
+ ): Logger {
526
+ const correlationId = extractCorrelationIdFromRequest(request) ?? getCorrelationId(env);
527
+
528
+ // Also set on env for downstream use
529
+ setCorrelationId(env, correlationId);
530
+
531
+ // Extract W3C Trace Context from request
532
+ const traceContext = extractTraceContext(request);
533
+ setTraceContext(env, traceContext);
534
+
535
+ return createLogger({
536
+ worker,
537
+ featureId,
538
+ correlationId,
539
+ traceId: traceContext.traceId,
540
+ spanId: traceContext.spanId,
541
+ minLevel,
542
+ });
543
+ }