@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/timeout.ts ADDED
@@ -0,0 +1,212 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Platform SDK Timeout Middleware
5
+ *
6
+ * Provides standardised timeout handling for async operations.
7
+ * Returns 504 Gateway Timeout when operations exceed time limits.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { withTimeout, TimeoutError } from './lib/platform-sdk';
12
+ *
13
+ * try {
14
+ * const result = await withTimeout(
15
+ * async () => await slowOperation(),
16
+ * 5000, // 5 seconds
17
+ * 'slow_operation'
18
+ * );
19
+ * } catch (error) {
20
+ * if (error instanceof TimeoutError) {
21
+ * return new Response('Gateway Timeout', { status: 504 });
22
+ * }
23
+ * throw error;
24
+ * }
25
+ * ```
26
+ */
27
+
28
+ import { reportErrorExplicit } from './errors';
29
+
30
+ // =============================================================================
31
+ // TIMEOUT ERROR
32
+ // =============================================================================
33
+
34
+ /**
35
+ * Error thrown when an operation exceeds its timeout.
36
+ */
37
+ export class TimeoutError extends Error {
38
+ /** The operation that timed out */
39
+ readonly operation: string;
40
+ /** Timeout duration in milliseconds */
41
+ readonly timeoutMs: number;
42
+ /** Actual duration before timeout (may be slightly less than timeoutMs) */
43
+ readonly actualMs: number;
44
+
45
+ constructor(operation: string, timeoutMs: number, actualMs: number) {
46
+ super(`Operation '${operation}' timed out after ${timeoutMs}ms`);
47
+ this.name = 'TimeoutError';
48
+ this.operation = operation;
49
+ this.timeoutMs = timeoutMs;
50
+ this.actualMs = actualMs;
51
+ }
52
+ }
53
+
54
+ // =============================================================================
55
+ // TIMEOUT CONFIGURATION
56
+ // =============================================================================
57
+
58
+ /**
59
+ * Default timeout values for different operation types.
60
+ */
61
+ export const DEFAULT_TIMEOUTS = {
62
+ /** Short operations (KV reads, simple D1 queries) */
63
+ short: 5000, // 5 seconds
64
+ /** Medium operations (API calls, complex queries) */
65
+ medium: 15000, // 15 seconds
66
+ /** Long operations (batch processing, AI inference) */
67
+ long: 30000, // 30 seconds
68
+ /** Maximum for any operation */
69
+ max: 60000, // 60 seconds
70
+ } as const;
71
+
72
+ // =============================================================================
73
+ // TIMEOUT WRAPPER
74
+ // =============================================================================
75
+
76
+ /**
77
+ * Execute an async function with a timeout.
78
+ * Throws TimeoutError if the operation exceeds the specified duration.
79
+ *
80
+ * @param fn - Async function to execute
81
+ * @param timeoutMs - Timeout in milliseconds (default: 30000)
82
+ * @param operation - Operation name for error messages (default: 'operation')
83
+ * @returns The result of the async function
84
+ * @throws TimeoutError if the operation times out
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * const data = await withTimeout(
89
+ * () => fetch('https://api.example.com/slow'),
90
+ * 10000,
91
+ * 'fetch_external_api'
92
+ * );
93
+ * ```
94
+ */
95
+ export async function withTimeout<T>(
96
+ fn: () => Promise<T>,
97
+ timeoutMs: number = DEFAULT_TIMEOUTS.long,
98
+ operation: string = 'operation'
99
+ ): Promise<T> {
100
+ const startTime = Date.now();
101
+
102
+ const timeoutPromise = new Promise<never>((_, reject) => {
103
+ setTimeout(() => {
104
+ const actualMs = Date.now() - startTime;
105
+ reject(new TimeoutError(operation, timeoutMs, actualMs));
106
+ }, timeoutMs);
107
+ });
108
+
109
+ return Promise.race([fn(), timeoutPromise]);
110
+ }
111
+
112
+ /**
113
+ * Execute an async function with timeout and automatic error reporting.
114
+ * Reports timeout as TIMEOUT category in telemetry.
115
+ *
116
+ * @param env - Tracked environment for error reporting
117
+ * @param fn - Async function to execute
118
+ * @param timeoutMs - Timeout in milliseconds
119
+ * @param operation - Operation name for error messages
120
+ * @returns The result of the async function
121
+ * @throws TimeoutError if the operation times out
122
+ */
123
+ export async function withTrackedTimeout<T>(
124
+ env: object,
125
+ fn: () => Promise<T>,
126
+ timeoutMs: number = DEFAULT_TIMEOUTS.long,
127
+ operation: string = 'operation'
128
+ ): Promise<T> {
129
+ try {
130
+ return await withTimeout(fn, timeoutMs, operation);
131
+ } catch (error) {
132
+ if (error instanceof TimeoutError) {
133
+ reportErrorExplicit(env, 'TIMEOUT', `TIMEOUT_${operation.toUpperCase()}`);
134
+ }
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ // =============================================================================
140
+ // HTTP RESPONSE HELPERS
141
+ // =============================================================================
142
+
143
+ /**
144
+ * Create a 504 Gateway Timeout response.
145
+ *
146
+ * @param error - Optional TimeoutError for details
147
+ * @returns 504 Response with JSON body
148
+ */
149
+ export function timeoutResponse(error?: TimeoutError): Response {
150
+ const body = {
151
+ error: 'Gateway Timeout',
152
+ code: 'TIMEOUT',
153
+ operation: error?.operation,
154
+ timeout_ms: error?.timeoutMs,
155
+ };
156
+
157
+ return new Response(JSON.stringify(body), {
158
+ status: 504,
159
+ headers: { 'Content-Type': 'application/json' },
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Check if an error is a TimeoutError.
165
+ */
166
+ export function isTimeoutError(error: unknown): error is TimeoutError {
167
+ return error instanceof TimeoutError;
168
+ }
169
+
170
+ // =============================================================================
171
+ // REQUEST HANDLER WRAPPER
172
+ // =============================================================================
173
+
174
+ /**
175
+ * Wrap a request handler with timeout handling.
176
+ * Returns 504 Gateway Timeout if the handler exceeds the timeout.
177
+ *
178
+ * @param handler - Request handler function
179
+ * @param timeoutMs - Timeout in milliseconds (default: 30000)
180
+ * @param operation - Operation name for logging
181
+ * @returns Wrapped handler that returns 504 on timeout
182
+ *
183
+ * @example
184
+ * ```typescript
185
+ * export default {
186
+ * fetch: withRequestTimeout(
187
+ * async (request, env, ctx) => {
188
+ * // Handler logic
189
+ * return new Response('OK');
190
+ * },
191
+ * 30000,
192
+ * 'api_handler'
193
+ * )
194
+ * };
195
+ * ```
196
+ */
197
+ export function withRequestTimeout<Env>(
198
+ handler: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>,
199
+ timeoutMs: number = DEFAULT_TIMEOUTS.long,
200
+ operation: string = 'request_handler'
201
+ ): (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response> {
202
+ return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
203
+ try {
204
+ return await withTimeout(() => handler(request, env, ctx), timeoutMs, operation);
205
+ } catch (error) {
206
+ if (isTimeoutError(error)) {
207
+ return timeoutResponse(error);
208
+ }
209
+ throw error;
210
+ }
211
+ };
212
+ }
package/src/tracing.ts ADDED
@@ -0,0 +1,403 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Platform SDK Distributed Tracing
5
+ *
6
+ * W3C Trace Context compliant distributed tracing for cross-service correlation.
7
+ * Implements trace propagation via standard headers (traceparent, tracestate).
8
+ *
9
+ * @see https://www.w3.org/TR/trace-context/
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { createTraceContext, propagateTraceContext } from './lib/platform-sdk';
14
+ *
15
+ * // Extract from incoming request
16
+ * const trace = createTraceContext(request);
17
+ *
18
+ * // Propagate to outgoing request
19
+ * const headers = propagateTraceContext(trace);
20
+ * await fetch('https://api.example.com', { headers });
21
+ * ```
22
+ */
23
+
24
+ // =============================================================================
25
+ // TYPES
26
+ // =============================================================================
27
+
28
+ /**
29
+ * W3C Trace Context representation.
30
+ */
31
+ export interface TraceContext {
32
+ /** Trace ID - 16 bytes hex (32 chars) */
33
+ traceId: string;
34
+ /** Parent span ID - 8 bytes hex (16 chars) */
35
+ spanId: string;
36
+ /** Trace flags (sampled = 01) */
37
+ traceFlags: number;
38
+ /** Optional tracestate for vendor-specific data */
39
+ traceState?: string;
40
+ /** Indicates if this is a new trace (not extracted from request) */
41
+ isNewTrace: boolean;
42
+ }
43
+
44
+ /**
45
+ * Span representation for nested operations.
46
+ */
47
+ export interface Span {
48
+ /** Span ID - 8 bytes hex (16 chars) */
49
+ spanId: string;
50
+ /** Parent span ID */
51
+ parentSpanId: string;
52
+ /** Trace context */
53
+ traceContext: TraceContext;
54
+ /** Operation name */
55
+ name: string;
56
+ /** Start timestamp (ms) */
57
+ startTime: number;
58
+ /** End timestamp (ms) */
59
+ endTime?: number;
60
+ /** Span status */
61
+ status: 'ok' | 'error' | 'unset';
62
+ /** Span attributes */
63
+ attributes: Record<string, string | number | boolean>;
64
+ }
65
+
66
+ // =============================================================================
67
+ // CONSTANTS
68
+ // =============================================================================
69
+
70
+ /** W3C Trace Context version */
71
+ const TRACE_VERSION = '00';
72
+
73
+ /** W3C Traceparent header name */
74
+ const TRACEPARENT_HEADER = 'traceparent';
75
+
76
+ /** W3C Tracestate header name */
77
+ const TRACESTATE_HEADER = 'tracestate';
78
+
79
+ /** Trace flag: sampled */
80
+ const FLAG_SAMPLED = 0x01;
81
+
82
+ /** Regex for validating traceparent header */
83
+ const TRACEPARENT_REGEX = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
84
+
85
+ // =============================================================================
86
+ // ID GENERATION
87
+ // =============================================================================
88
+
89
+ /**
90
+ * Generate a random trace ID (16 bytes = 32 hex chars).
91
+ */
92
+ export function generateTraceId(): string {
93
+ const bytes = new Uint8Array(16);
94
+ crypto.getRandomValues(bytes);
95
+ return Array.from(bytes)
96
+ .map((b) => b.toString(16).padStart(2, '0'))
97
+ .join('');
98
+ }
99
+
100
+ /**
101
+ * Generate a random span ID (8 bytes = 16 hex chars).
102
+ */
103
+ export function generateSpanId(): string {
104
+ const bytes = new Uint8Array(8);
105
+ crypto.getRandomValues(bytes);
106
+ return Array.from(bytes)
107
+ .map((b) => b.toString(16).padStart(2, '0'))
108
+ .join('');
109
+ }
110
+
111
+ // =============================================================================
112
+ // TRACE CONTEXT MANAGEMENT
113
+ // =============================================================================
114
+
115
+ /**
116
+ * WeakMap to store trace context per environment.
117
+ */
118
+ const traceContexts = new WeakMap<object, TraceContext>();
119
+
120
+ /**
121
+ * Get or create a trace context for an environment.
122
+ */
123
+ export function getTraceContext(env: object): TraceContext {
124
+ let ctx = traceContexts.get(env);
125
+ if (!ctx) {
126
+ ctx = createNewTraceContext();
127
+ traceContexts.set(env, ctx);
128
+ }
129
+ return ctx;
130
+ }
131
+
132
+ /**
133
+ * Set a trace context for an environment.
134
+ */
135
+ export function setTraceContext(env: object, ctx: TraceContext): void {
136
+ traceContexts.set(env, ctx);
137
+ }
138
+
139
+ /**
140
+ * Create a new trace context (for new requests).
141
+ */
142
+ export function createNewTraceContext(): TraceContext {
143
+ return {
144
+ traceId: generateTraceId(),
145
+ spanId: generateSpanId(),
146
+ traceFlags: FLAG_SAMPLED, // Always sampled by default
147
+ isNewTrace: true,
148
+ };
149
+ }
150
+
151
+ // =============================================================================
152
+ // PARSING AND SERIALIZATION
153
+ // =============================================================================
154
+
155
+ /**
156
+ * Parse a W3C traceparent header.
157
+ * Format: {version}-{trace-id}-{parent-id}-{trace-flags}
158
+ * Example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
159
+ */
160
+ export function parseTraceparent(header: string): TraceContext | null {
161
+ const match = header.toLowerCase().match(TRACEPARENT_REGEX);
162
+ if (!match) {
163
+ return null;
164
+ }
165
+
166
+ const [, version, traceId, spanId, flagsHex] = match;
167
+
168
+ // Validate version
169
+ if (version !== TRACE_VERSION) {
170
+ // Future versions may have different formats, but for now only support 00
171
+ return null;
172
+ }
173
+
174
+ // Validate trace ID is not all zeros
175
+ if (traceId === '00000000000000000000000000000000') {
176
+ return null;
177
+ }
178
+
179
+ // Validate span ID is not all zeros
180
+ if (spanId === '0000000000000000') {
181
+ return null;
182
+ }
183
+
184
+ return {
185
+ traceId,
186
+ spanId,
187
+ traceFlags: parseInt(flagsHex, 16),
188
+ isNewTrace: false,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Format a TraceContext as a W3C traceparent header value.
194
+ */
195
+ export function formatTraceparent(ctx: TraceContext): string {
196
+ const flags = ctx.traceFlags.toString(16).padStart(2, '0');
197
+ return `${TRACE_VERSION}-${ctx.traceId}-${ctx.spanId}-${flags}`;
198
+ }
199
+
200
+ // =============================================================================
201
+ // REQUEST HANDLING
202
+ // =============================================================================
203
+
204
+ /**
205
+ * Extract trace context from a request.
206
+ * Creates a new child span for the current operation.
207
+ */
208
+ export function extractTraceContext(request: Request): TraceContext {
209
+ const traceparent = request.headers.get(TRACEPARENT_HEADER);
210
+ const tracestate = request.headers.get(TRACESTATE_HEADER);
211
+
212
+ if (traceparent) {
213
+ const parsed = parseTraceparent(traceparent);
214
+ if (parsed) {
215
+ // Create a new span ID for this operation, keeping the trace ID
216
+ return {
217
+ traceId: parsed.traceId,
218
+ spanId: generateSpanId(), // New span for this operation
219
+ traceFlags: parsed.traceFlags,
220
+ traceState: tracestate || undefined,
221
+ isNewTrace: false,
222
+ };
223
+ }
224
+ }
225
+
226
+ // No valid traceparent, create a new trace
227
+ return createNewTraceContext();
228
+ }
229
+
230
+ /**
231
+ * Create trace context from a request and attach to environment.
232
+ * This is the main entry point for incoming requests.
233
+ */
234
+ export function createTraceContext(request: Request, env: object): TraceContext {
235
+ const ctx = extractTraceContext(request);
236
+ setTraceContext(env, ctx);
237
+ return ctx;
238
+ }
239
+
240
+ // =============================================================================
241
+ // PROPAGATION
242
+ // =============================================================================
243
+
244
+ /**
245
+ * Get headers for propagating trace context to outgoing requests.
246
+ * Creates a new span ID for the outgoing call.
247
+ */
248
+ export function propagateTraceContext(ctx: TraceContext): Headers {
249
+ const headers = new Headers();
250
+
251
+ // Create a new span ID for the outgoing request (child span)
252
+ const childContext: TraceContext = {
253
+ ...ctx,
254
+ spanId: generateSpanId(),
255
+ };
256
+
257
+ headers.set(TRACEPARENT_HEADER, formatTraceparent(childContext));
258
+
259
+ if (ctx.traceState) {
260
+ headers.set(TRACESTATE_HEADER, ctx.traceState);
261
+ }
262
+
263
+ return headers;
264
+ }
265
+
266
+ /**
267
+ * Merge trace propagation headers into existing headers.
268
+ */
269
+ export function addTraceHeaders(
270
+ headers: Headers | Record<string, string>,
271
+ ctx: TraceContext
272
+ ): Headers {
273
+ const propagationHeaders = propagateTraceContext(ctx);
274
+ const result = headers instanceof Headers ? new Headers(headers) : new Headers(headers);
275
+
276
+ propagationHeaders.forEach((value, key) => {
277
+ result.set(key, value);
278
+ });
279
+
280
+ return result;
281
+ }
282
+
283
+ /**
284
+ * Create a fetch wrapper that automatically propagates trace context.
285
+ */
286
+ export function createTracedFetch(
287
+ ctx: TraceContext
288
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
289
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
290
+ const headers = new Headers(init?.headers);
291
+ const propagationHeaders = propagateTraceContext(ctx);
292
+
293
+ propagationHeaders.forEach((value, key) => {
294
+ headers.set(key, value);
295
+ });
296
+
297
+ return fetch(input, {
298
+ ...init,
299
+ headers,
300
+ });
301
+ };
302
+ }
303
+
304
+ // =============================================================================
305
+ // SPAN MANAGEMENT
306
+ // =============================================================================
307
+
308
+ /**
309
+ * Start a new span for an operation.
310
+ */
311
+ export function startSpan(ctx: TraceContext, name: string): Span {
312
+ return {
313
+ spanId: generateSpanId(),
314
+ parentSpanId: ctx.spanId,
315
+ traceContext: ctx,
316
+ name,
317
+ startTime: Date.now(),
318
+ status: 'unset',
319
+ attributes: {},
320
+ };
321
+ }
322
+
323
+ /**
324
+ * End a span with success status.
325
+ */
326
+ export function endSpan(span: Span): Span {
327
+ return {
328
+ ...span,
329
+ endTime: Date.now(),
330
+ status: span.status === 'unset' ? 'ok' : span.status,
331
+ };
332
+ }
333
+
334
+ /**
335
+ * End a span with error status.
336
+ */
337
+ export function failSpan(span: Span, error?: unknown): Span {
338
+ const result: Span = {
339
+ ...span,
340
+ endTime: Date.now(),
341
+ status: 'error',
342
+ };
343
+
344
+ if (error instanceof Error) {
345
+ result.attributes['error.type'] = error.name;
346
+ result.attributes['error.message'] = error.message;
347
+ }
348
+
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * Add an attribute to a span.
354
+ */
355
+ export function setSpanAttribute(span: Span, key: string, value: string | number | boolean): Span {
356
+ return {
357
+ ...span,
358
+ attributes: {
359
+ ...span.attributes,
360
+ [key]: value,
361
+ },
362
+ };
363
+ }
364
+
365
+ // =============================================================================
366
+ // UTILITIES
367
+ // =============================================================================
368
+
369
+ /**
370
+ * Check if a trace context is sampled (should be recorded).
371
+ */
372
+ export function isSampled(ctx: TraceContext): boolean {
373
+ return (ctx.traceFlags & FLAG_SAMPLED) !== 0;
374
+ }
375
+
376
+ /**
377
+ * Get a short trace ID for logging (first 8 chars).
378
+ */
379
+ export function shortTraceId(ctx: TraceContext): string {
380
+ return ctx.traceId.substring(0, 8);
381
+ }
382
+
383
+ /**
384
+ * Get a short span ID for logging (first 8 chars).
385
+ */
386
+ export function shortSpanId(ctx: TraceContext): string {
387
+ return ctx.spanId.substring(0, 8);
388
+ }
389
+
390
+ /**
391
+ * Format trace context for log output.
392
+ */
393
+ export function formatTraceForLog(ctx: TraceContext): {
394
+ trace_id: string;
395
+ span_id: string;
396
+ trace_flags: string;
397
+ } {
398
+ return {
399
+ trace_id: ctx.traceId,
400
+ span_id: ctx.spanId,
401
+ trace_flags: ctx.traceFlags.toString(16).padStart(2, '0'),
402
+ };
403
+ }