@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/index.ts ADDED
@@ -0,0 +1,950 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Platform SDK
5
+ *
6
+ * Automatic metric collection and circuit breaking for Cloudflare Workers.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { withFeatureBudget, CircuitBreakerError } from '../lib/platform-sdk';
11
+ *
12
+ * export default {
13
+ * async fetch(request: Request, env: Env, ctx: ExecutionContext) {
14
+ * try {
15
+ * const trackedEnv = withFeatureBudget(env, 'scout:ocr:process', { ctx });
16
+ * const result = await trackedEnv.DB.prepare('SELECT...').all();
17
+ * return Response.json(result);
18
+ * } catch (e) {
19
+ * if (e instanceof CircuitBreakerError) {
20
+ * return Response.json({ error: 'Feature disabled' }, { status: 503 });
21
+ * }
22
+ * throw e;
23
+ * }
24
+ * }
25
+ * };
26
+ * ```
27
+ */
28
+
29
+ // Re-export types
30
+ export {
31
+ CircuitBreakerError,
32
+ type CircuitBreakerResult,
33
+ type CircuitStatus,
34
+ type ControlPlaneHealth,
35
+ type CronBudgetOptions,
36
+ type DataPlaneHealth,
37
+ type ErrorCategory,
38
+ type FeatureConfig,
39
+ type FeatureId,
40
+ type FeatureMetrics,
41
+ type HealthResult,
42
+ type MetricsAccumulator,
43
+ type PlatformBindings,
44
+ type QueueBudgetOptions,
45
+ type SDKOptions,
46
+ type TelemetryMessage,
47
+ type TrackedEnv,
48
+ type WithPlatformBindings,
49
+ createMetricsAccumulator,
50
+ } from './types';
51
+
52
+ // Re-export constants
53
+ export { BINDING_NAMES, CIRCUIT_STATUS, KV_KEYS, METRIC_FIELDS } from './constants';
54
+
55
+ // Re-export Platform feature IDs
56
+ export {
57
+ // Ingest features
58
+ INGEST_GITHUB,
59
+ INGEST_STRIPE,
60
+ INGEST_LOGGER,
61
+ // Connector features
62
+ CONNECTOR_STRIPE,
63
+ CONNECTOR_ADS,
64
+ CONNECTOR_GA4,
65
+ CONNECTOR_PLAUSIBLE,
66
+ // Monitor features
67
+ MONITOR_ALERT_ROUTER,
68
+ MONITOR_COST_SPIKE,
69
+ MONITOR_OBSERVER,
70
+ MONITOR_GITHUB,
71
+ MONITOR_AUDITOR,
72
+ MONITOR_PATTERN_DISCOVERY,
73
+ // Discovery features
74
+ DISCOVERY_TOPOLOGY,
75
+ // Test features
76
+ TEST_INGEST,
77
+ TEST_QUERY,
78
+ TEST_HEALTHCHECK,
79
+ // Heartbeat
80
+ HEARTBEAT_HEALTH,
81
+ // Email
82
+ EMAIL_HEALTHCHECK,
83
+ // All features
84
+ PLATFORM_FEATURES,
85
+ getAllPlatformFeatures,
86
+ } from './features';
87
+
88
+ // Re-export telemetry utilities
89
+ export {
90
+ flushMetrics,
91
+ reportUsage,
92
+ scheduleFlush,
93
+ getTelemetryContext,
94
+ setTelemetryContext,
95
+ clearTelemetryContext,
96
+ type TelemetryContext,
97
+ } from './telemetry';
98
+
99
+ // Re-export AI Gateway utilities
100
+ export {
101
+ createAIGatewayFetch,
102
+ createAIGatewayFetchWithBodyParsing,
103
+ parseAIGatewayUrl,
104
+ reportAIGatewayUsage,
105
+ type AIGatewayProvider,
106
+ type AIGatewayUrlInfo,
107
+ } from './ai-gateway';
108
+
109
+ // Re-export logging utilities
110
+ export {
111
+ createLogger,
112
+ createLoggerFromEnv,
113
+ createLoggerFromRequest,
114
+ extractCorrelationIdFromRequest,
115
+ generateCorrelationId,
116
+ getCorrelationId,
117
+ setCorrelationId,
118
+ type LogLevel,
119
+ type Logger,
120
+ type LoggerOptions,
121
+ type StructuredLog,
122
+ // Also export categoriseError from logging (same implementation)
123
+ categoriseError as categoriseErrorFromLog,
124
+ } from './logging';
125
+
126
+ // Re-export error utilities
127
+ export {
128
+ categoriseError,
129
+ extractErrorCode,
130
+ getErrorCount,
131
+ hasErrors,
132
+ reportError,
133
+ reportErrorExplicit,
134
+ trackError,
135
+ withErrorTracking,
136
+ } from './errors';
137
+
138
+ // Re-export distributed tracing utilities
139
+ export {
140
+ // Trace context management
141
+ createTraceContext,
142
+ extractTraceContext,
143
+ getTraceContext,
144
+ setTraceContext,
145
+ createNewTraceContext,
146
+ // ID generation
147
+ generateTraceId,
148
+ generateSpanId,
149
+ // Parsing and serialization
150
+ parseTraceparent,
151
+ formatTraceparent,
152
+ // Propagation
153
+ propagateTraceContext,
154
+ addTraceHeaders,
155
+ createTracedFetch,
156
+ // Span management
157
+ startSpan,
158
+ endSpan,
159
+ failSpan,
160
+ setSpanAttribute,
161
+ // Utilities
162
+ isSampled,
163
+ shortTraceId,
164
+ shortSpanId,
165
+ formatTraceForLog,
166
+ // Types
167
+ type TraceContext,
168
+ type Span,
169
+ } from './tracing';
170
+
171
+ // Re-export timeout utilities
172
+ export {
173
+ withTimeout,
174
+ withTrackedTimeout,
175
+ withRequestTimeout,
176
+ timeoutResponse,
177
+ isTimeoutError,
178
+ TimeoutError,
179
+ DEFAULT_TIMEOUTS,
180
+ } from './timeout';
181
+
182
+ // Re-export service client utilities for cross-feature correlation
183
+ export {
184
+ createServiceClient,
185
+ createServiceBindingHeaders,
186
+ wrapServiceBinding,
187
+ extractCorrelationChain,
188
+ CORRELATION_ID_HEADER,
189
+ SOURCE_SERVICE_HEADER,
190
+ TARGET_SERVICE_HEADER,
191
+ FEATURE_ID_HEADER,
192
+ type ServiceClient,
193
+ type ServiceClientOptions,
194
+ type CorrelationChain,
195
+ } from './service-client';
196
+
197
+ // Re-export DO heartbeat utilities
198
+ export {
199
+ withHeartbeat,
200
+ type DOClass,
201
+ type HeartbeatConfig,
202
+ type HeartbeatEnv,
203
+ } from './do-heartbeat';
204
+
205
+ // Re-export proxy utilities
206
+ export {
207
+ // Proxy creators
208
+ createAIProxy,
209
+ createD1Proxy,
210
+ createDOProxy,
211
+ createEnvProxy,
212
+ createKVProxy,
213
+ createQueueProxy,
214
+ createR2Proxy,
215
+ createVectorizeProxy,
216
+ createWorkflowProxy,
217
+ // Utilities
218
+ getMetrics,
219
+ // Type guards
220
+ isAIBinding,
221
+ isD1Database,
222
+ isDurableObjectNamespace,
223
+ isKVNamespace,
224
+ isQueue,
225
+ isR2Bucket,
226
+ isVectorizeIndex,
227
+ isWorkflow,
228
+ } from './proxy';
229
+
230
+ // Internal imports
231
+ import {
232
+ CircuitBreakerError,
233
+ createMetricsAccumulator,
234
+ type CircuitStatus,
235
+ type ControlPlaneHealth,
236
+ type CronBudgetOptions,
237
+ type DataPlaneHealth,
238
+ type FeatureId,
239
+ type HealthResult,
240
+ type QueueBudgetOptions,
241
+ type SDKOptions,
242
+ type TelemetryMessage,
243
+ type TrackedEnv,
244
+ } from './types';
245
+ import { KV_KEYS, CIRCUIT_STATUS } from './constants';
246
+ import { setTelemetryContext, flushMetrics, type TelemetryContext } from './telemetry';
247
+ import { createEnvProxy } from './proxy';
248
+ import { setCorrelationId, getCorrelationId } from './logging';
249
+ import { getTraceContext } from './tracing';
250
+ import { parseAIGatewayUrl, reportAIGatewayUsage } from './ai-gateway';
251
+
252
+ // =============================================================================
253
+ // CIRCUIT BREAKER PROXY CONSTANTS (hoisted from get trap for performance)
254
+ // =============================================================================
255
+
256
+ /**
257
+ * Properties that bypass circuit breaker checks entirely.
258
+ * Platform bindings + Promise-related properties.
259
+ */
260
+ const CB_SKIP_PROPS = new Set([
261
+ 'PLATFORM_CACHE',
262
+ 'PLATFORM_TELEMETRY',
263
+ 'then', // For Promise detection
264
+ 'catch',
265
+ 'finally',
266
+ ]);
267
+
268
+ /**
269
+ * Synchronous methods that must NOT be wrapped in async circuit breaker check.
270
+ * These return immediately and wrapping would break their return value chain.
271
+ * IMPORTANT: Must bind to innerTarget to preserve `this` context
272
+ * for native Cloudflare binding methods (DO stubs, KV, R2, Fetcher, etc.)
273
+ * Without binding: "Illegal invocation: function called with incorrect `this` reference"
274
+ */
275
+ const CB_SYNC_METHODS = new Set([
276
+ // D1Database: prepare() returns D1PreparedStatement synchronously
277
+ 'prepare',
278
+ // D1PreparedStatement: bind() returns new D1PreparedStatement synchronously
279
+ 'bind',
280
+ // DurableObjectNamespace: ALL methods are synchronous
281
+ 'get', // Returns DurableObjectStub
282
+ 'idFromName', // Returns DurableObjectId
283
+ 'idFromString', // Returns DurableObjectId
284
+ 'newUniqueId', // Returns DurableObjectId
285
+ // R2Bucket: resumeMultipartUpload() returns R2MultipartUpload synchronously
286
+ 'resumeMultipartUpload',
287
+ // Fetcher (service bindings): fetch/connect need correct `this` binding
288
+ 'fetch',
289
+ 'connect',
290
+ ]);
291
+
292
+ // =============================================================================
293
+ // CIRCUIT BREAKER CHECK
294
+ // =============================================================================
295
+
296
+ /**
297
+ * Cache for circuit breaker checks within a single request.
298
+ * Keyed by feature ID to avoid redundant KV reads.
299
+ */
300
+ const circuitBreakerCache = new Map<string, Promise<void>>();
301
+
302
+ /**
303
+ * Clear the circuit breaker cache.
304
+ * Primarily used for testing to ensure clean state between tests.
305
+ */
306
+ export function clearCircuitBreakerCache(): void {
307
+ circuitBreakerCache.clear();
308
+ }
309
+
310
+ /**
311
+ * Parse a feature ID into its component parts.
312
+ */
313
+ function parseFeatureId(featureId: FeatureId): {
314
+ project: string;
315
+ category: string;
316
+ feature: string;
317
+ } {
318
+ const parts = featureId.split(':');
319
+ if (parts.length !== 3) {
320
+ throw new Error(
321
+ `Invalid featureId format: "${featureId}". Expected "project:category:feature"`
322
+ );
323
+ }
324
+ return {
325
+ project: parts[0],
326
+ category: parts[1],
327
+ feature: parts[2],
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Check circuit breaker status for a feature.
333
+ * Checks in order: feature -> project -> global.
334
+ * Throws CircuitBreakerError if any level is STOP.
335
+ *
336
+ * Uses per-request caching to avoid redundant KV reads.
337
+ */
338
+ async function checkCircuitBreaker(featureId: FeatureId, kv: KVNamespace): Promise<void> {
339
+ // Check cache first
340
+ const cached = circuitBreakerCache.get(featureId);
341
+ if (cached) {
342
+ return cached;
343
+ }
344
+
345
+ // Create and cache the check promise
346
+ const checkPromise = performCircuitBreakerCheck(featureId, kv);
347
+ circuitBreakerCache.set(featureId, checkPromise);
348
+
349
+ return checkPromise;
350
+ }
351
+
352
+ /**
353
+ * Perform the actual circuit breaker check against KV.
354
+ */
355
+ async function performCircuitBreakerCheck(featureId: FeatureId, kv: KVNamespace): Promise<void> {
356
+ const { project } = parseFeatureId(featureId);
357
+
358
+ // Check all levels in parallel for efficiency
359
+ const [featureStatus, projectStatus, globalStatus, featureReason] = await Promise.all([
360
+ kv.get(KV_KEYS.featureStatus(featureId)),
361
+ kv.get(KV_KEYS.projectStatus(project)),
362
+ kv.get(KV_KEYS.globalStatus()),
363
+ kv.get(KV_KEYS.featureReason(featureId)),
364
+ ]);
365
+
366
+ // Check global first (highest priority)
367
+ if (globalStatus === CIRCUIT_STATUS.STOP) {
368
+ throw new CircuitBreakerError(featureId, 'global', 'Global circuit breaker is STOP');
369
+ }
370
+
371
+ // Check project level
372
+ if (projectStatus === CIRCUIT_STATUS.STOP) {
373
+ throw new CircuitBreakerError(
374
+ featureId,
375
+ 'project',
376
+ `Project ${project} circuit breaker is STOP`
377
+ );
378
+ }
379
+
380
+ // Check feature level
381
+ if (featureStatus === CIRCUIT_STATUS.STOP) {
382
+ throw new CircuitBreakerError(featureId, 'feature', featureReason ?? undefined);
383
+ }
384
+
385
+ // Also check legacy format for backwards compatibility
386
+ const legacyEnabled = await kv.get(KV_KEYS.legacy.enabled(featureId));
387
+ if (legacyEnabled === 'false') {
388
+ const legacyReason = await kv.get(KV_KEYS.legacy.disabledReason(featureId));
389
+ throw new CircuitBreakerError(featureId, 'feature', legacyReason ?? undefined);
390
+ }
391
+ }
392
+
393
+ // =============================================================================
394
+ // MAIN ENTRY POINT
395
+ // =============================================================================
396
+
397
+ /**
398
+ * Wrap an environment with feature budget tracking.
399
+ *
400
+ * This is the main entry point for the Platform SDK.
401
+ * Returns a proxied environment that:
402
+ * 1. Checks circuit breaker status (throws CircuitBreakerError if STOP)
403
+ * 2. Tracks all D1, KV, AI, and Vectorize operations
404
+ * 3. Reports metrics to the telemetry queue on completion
405
+ *
406
+ * @param env - Worker environment with bindings
407
+ * @param featureId - Feature identifier in format 'project:category:feature'
408
+ * @param options - Optional configuration
409
+ * @returns Proxied environment with tracked bindings
410
+ * @throws CircuitBreakerError if the feature is disabled
411
+ *
412
+ * @example
413
+ * ```typescript
414
+ * const trackedEnv = withFeatureBudget(env, 'scout:ocr:process', { ctx });
415
+ * const result = await trackedEnv.DB.prepare('SELECT...').all();
416
+ * ```
417
+ */
418
+ export function withFeatureBudget<T extends object>(
419
+ env: T,
420
+ featureId: FeatureId,
421
+ options: SDKOptions = {}
422
+ ): TrackedEnv<T> {
423
+ const {
424
+ ctx,
425
+ checkCircuitBreaker: shouldCheck = true,
426
+ reportTelemetry = true,
427
+ cacheKv,
428
+ telemetryQueue,
429
+ correlationId: providedCorrelationId,
430
+ externalCostUsd,
431
+ } = options;
432
+
433
+ // Set up correlation ID
434
+ const correlationId = providedCorrelationId ?? getCorrelationId(env);
435
+ if (providedCorrelationId) {
436
+ setCorrelationId(env, providedCorrelationId);
437
+ }
438
+
439
+ // Validate feature ID format
440
+ parseFeatureId(featureId);
441
+
442
+ // Get KV and queue bindings
443
+ const kv = cacheKv ?? (env as unknown as { PLATFORM_CACHE?: KVNamespace }).PLATFORM_CACHE;
444
+ const queue =
445
+ telemetryQueue ??
446
+ (env as unknown as { PLATFORM_TELEMETRY?: Queue<TelemetryMessage> }).PLATFORM_TELEMETRY;
447
+
448
+ // Create metrics accumulator
449
+ const metrics = createMetricsAccumulator();
450
+
451
+ // Create proxied environment
452
+ const proxiedEnv = createEnvProxy(env, metrics);
453
+
454
+ // Check circuit breaker synchronously by throwing on first binding access
455
+ // This is the lazy/JIT approach - we only check when bindings are used
456
+ let finalEnv: T;
457
+ if (shouldCheck && kv) {
458
+ // Wrap the proxy to check circuit breaker on first real binding access
459
+ let circuitBreakerChecked = false;
460
+ let circuitBreakerPromise: Promise<void> | null = null;
461
+ // Cache for CB-wrapped bindings to avoid creating new Proxy on every access
462
+ const cbWrappedBindings = new Map<string | symbol, unknown>();
463
+
464
+ finalEnv = new Proxy(proxiedEnv, {
465
+ get(target, prop) {
466
+ if (!CB_SKIP_PROPS.has(String(prop)) && !circuitBreakerChecked) {
467
+ // Trigger circuit breaker check on first non-platform binding access
468
+ // The check is async but we don't block - errors propagate through proxy
469
+ if (!circuitBreakerPromise) {
470
+ circuitBreakerPromise = checkCircuitBreaker(featureId, kv).then(() => {
471
+ circuitBreakerChecked = true;
472
+ });
473
+ }
474
+ }
475
+
476
+ // Return cached CB-wrapped binding if available
477
+ if (cbWrappedBindings.has(prop)) {
478
+ return cbWrappedBindings.get(prop);
479
+ }
480
+
481
+ const value = Reflect.get(target, prop);
482
+
483
+ // If the value is a function or has async methods, wrap to check CB first
484
+ if (typeof value === 'object' && value !== null && circuitBreakerPromise) {
485
+ const wrapped = new Proxy(value, {
486
+ get(innerTarget, innerProp) {
487
+ const innerValue = Reflect.get(innerTarget, innerProp);
488
+
489
+ if (typeof innerValue === 'function') {
490
+ // Don't wrap synchronous builder methods, but MUST bind to preserve `this`
491
+ if (CB_SYNC_METHODS.has(String(innerProp))) {
492
+ return innerValue.bind(innerTarget);
493
+ }
494
+
495
+ return async (...args: unknown[]) => {
496
+ // Ensure circuit breaker check completes before operation
497
+ await circuitBreakerPromise;
498
+ return (innerValue as (...args: unknown[]) => unknown).apply(innerTarget, args);
499
+ };
500
+ }
501
+
502
+ return innerValue;
503
+ },
504
+ });
505
+
506
+ cbWrappedBindings.set(prop, wrapped);
507
+ return wrapped;
508
+ }
509
+
510
+ return value;
511
+ },
512
+ }) as T;
513
+ } else {
514
+ finalEnv = proxiedEnv;
515
+ }
516
+
517
+ // Add health() and fetch() methods to the tracked environment
518
+ const envWithHealth = new Proxy(finalEnv, {
519
+ get(target, prop) {
520
+ if (prop === 'health') {
521
+ return () => health(featureId, kv!, queue, ctx);
522
+ }
523
+ if (prop === 'fetch') {
524
+ // Return a tracked fetch that auto-detects AI Gateway URLs
525
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
526
+ const url =
527
+ typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
528
+
529
+ const response = await fetch(input, init);
530
+
531
+ // Track AI Gateway calls
532
+ const parsed = parseAIGatewayUrl(url);
533
+ if (parsed) {
534
+ // Try to extract model from body for OpenAI/Anthropic
535
+ let model = parsed.model;
536
+ if (init?.body && typeof init.body === 'string') {
537
+ try {
538
+ const body = JSON.parse(init.body) as { model?: string };
539
+ if (body.model && typeof body.model === 'string') {
540
+ model = body.model;
541
+ }
542
+ } catch {
543
+ // Not JSON or no model field - use URL-derived model
544
+ }
545
+ }
546
+ reportAIGatewayUsage(envWithHealth, parsed.provider, model);
547
+ }
548
+
549
+ return response;
550
+ };
551
+ }
552
+ return Reflect.get(target, prop);
553
+ },
554
+ }) as TrackedEnv<T>;
555
+
556
+ // Store telemetry context on the FINAL returned object
557
+ // IMPORTANT: Must be set on envWithHealth (not finalEnv) so completeTracking can find it
558
+ // IMPORTANT: User MUST call completeTracking(env) after operations are done.
559
+ if (reportTelemetry) {
560
+ // Get trace context if available (set by createLoggerFromRequest)
561
+ const traceContext = getTraceContext(env);
562
+
563
+ const telemetryContext: TelemetryContext = {
564
+ featureId,
565
+ metrics,
566
+ startTime: Date.now(),
567
+ queue,
568
+ ctx,
569
+ correlationId,
570
+ traceId: traceContext?.traceId,
571
+ spanId: traceContext?.spanId,
572
+ externalCostUsd,
573
+ };
574
+ setTelemetryContext(envWithHealth, telemetryContext);
575
+ }
576
+
577
+ return envWithHealth;
578
+ }
579
+
580
+ /**
581
+ * Complete tracking and flush metrics for a proxied environment.
582
+ * REQUIRED: Must be called after all tracked operations are complete.
583
+ *
584
+ * If you provided `ctx` to withFeatureBudget, the flush will use ctx.waitUntil
585
+ * for non-blocking submission. Otherwise it awaits the queue send directly.
586
+ *
587
+ * @param env - The proxied environment from withFeatureBudget
588
+ *
589
+ * @example
590
+ * ```typescript
591
+ * const trackedEnv = withFeatureBudget(env, 'my-app:api:users', { ctx });
592
+ * await trackedEnv.DB.prepare('SELECT...').all();
593
+ * await trackedEnv.KV.put('key', 'value');
594
+ * await completeTracking(trackedEnv); // Flush metrics to queue
595
+ * ```
596
+ */
597
+ export async function completeTracking(env: object): Promise<void> {
598
+ await flushMetrics(env);
599
+ }
600
+
601
+ // =============================================================================
602
+ // CIRCUIT BREAKER MANAGEMENT
603
+ // =============================================================================
604
+
605
+ /**
606
+ * Manually check if a feature is enabled.
607
+ * Returns true if enabled, false if disabled.
608
+ *
609
+ * @param featureId - Feature identifier
610
+ * @param kv - KV namespace with circuit breaker state
611
+ */
612
+ export async function isFeatureEnabled(featureId: FeatureId, kv: KVNamespace): Promise<boolean> {
613
+ try {
614
+ await checkCircuitBreaker(featureId, kv);
615
+ return true;
616
+ } catch (e) {
617
+ if (e instanceof CircuitBreakerError) {
618
+ return false;
619
+ }
620
+ throw e;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Set circuit breaker status for a feature.
626
+ *
627
+ * @param featureId - Feature identifier
628
+ * @param status - 'GO' to enable, 'STOP' to disable
629
+ * @param kv - KV namespace
630
+ * @param reason - Optional reason for STOP status
631
+ */
632
+ export async function setCircuitBreakerStatus(
633
+ featureId: FeatureId,
634
+ status: 'GO' | 'STOP',
635
+ kv: KVNamespace,
636
+ reason?: string
637
+ ): Promise<void> {
638
+ if (status === 'GO') {
639
+ // Clear all circuit breaker keys
640
+ await Promise.all([
641
+ kv.delete(KV_KEYS.featureStatus(featureId)),
642
+ kv.delete(KV_KEYS.featureReason(featureId)),
643
+ kv.delete(KV_KEYS.featureDisabledAt(featureId)),
644
+ kv.delete(KV_KEYS.featureAutoResetAt(featureId)),
645
+ ]);
646
+ } else {
647
+ // Set STOP status
648
+ await Promise.all([
649
+ kv.put(KV_KEYS.featureStatus(featureId), CIRCUIT_STATUS.STOP),
650
+ reason ? kv.put(KV_KEYS.featureReason(featureId), reason) : Promise.resolve(),
651
+ kv.put(KV_KEYS.featureDisabledAt(featureId), Date.now().toString()),
652
+ ]);
653
+ }
654
+
655
+ // Clear cache
656
+ circuitBreakerCache.delete(featureId);
657
+ }
658
+
659
+ // =============================================================================
660
+ // HEALTH CHECK
661
+ // =============================================================================
662
+
663
+ /**
664
+ * Check the health of the Platform SDK for a given feature.
665
+ * Validates both control plane (KV connectivity) and data plane (queue delivery).
666
+ *
667
+ * @param featureId - Feature identifier in format 'project:category:feature'
668
+ * @param kv - KV namespace for circuit breaker state
669
+ * @param queue - Optional queue for telemetry (if provided, sends heartbeat probe)
670
+ * @param ctx - Optional ExecutionContext for waitUntil
671
+ * @returns HealthResult with status of both planes
672
+ *
673
+ * @example
674
+ * ```typescript
675
+ * const result = await health('scout:ocr:process', env.PLATFORM_CACHE, env.PLATFORM_TELEMETRY);
676
+ * if (!result.healthy) {
677
+ * console.error('Platform unhealthy:', result);
678
+ * }
679
+ * ```
680
+ */
681
+ export async function health(
682
+ featureId: FeatureId,
683
+ kv: KVNamespace,
684
+ queue?: Queue<TelemetryMessage>,
685
+ ctx?: ExecutionContext
686
+ ): Promise<HealthResult> {
687
+ const timestamp = Date.now();
688
+ const { project, category, feature } = parseFeatureId(featureId);
689
+
690
+ // Check control plane (KV connectivity and circuit breaker status)
691
+ let controlPlane: ControlPlaneHealth;
692
+ try {
693
+ const [featureStatus, projectStatus, globalStatus] = await Promise.all([
694
+ kv.get(KV_KEYS.featureStatus(featureId)),
695
+ kv.get(KV_KEYS.projectStatus(project)),
696
+ kv.get(KV_KEYS.globalStatus()),
697
+ ]);
698
+
699
+ // Determine circuit status (any STOP means STOP)
700
+ let status: CircuitStatus = 'GO';
701
+ if (
702
+ globalStatus === CIRCUIT_STATUS.STOP ||
703
+ projectStatus === CIRCUIT_STATUS.STOP ||
704
+ featureStatus === CIRCUIT_STATUS.STOP
705
+ ) {
706
+ status = 'STOP';
707
+ }
708
+
709
+ controlPlane = { healthy: true, status };
710
+ } catch (error) {
711
+ controlPlane = {
712
+ healthy: false,
713
+ status: 'UNKNOWN',
714
+ error: error instanceof Error ? error.message : String(error),
715
+ };
716
+ }
717
+
718
+ // Check data plane (queue delivery) if queue is provided
719
+ let dataPlane: DataPlaneHealth;
720
+ if (queue) {
721
+ try {
722
+ const heartbeatMessage: TelemetryMessage = {
723
+ feature_key: featureId,
724
+ project,
725
+ category,
726
+ feature,
727
+ metrics: {},
728
+ timestamp,
729
+ is_heartbeat: true,
730
+ };
731
+
732
+ // Send heartbeat to queue
733
+ const sendPromise = queue.send(heartbeatMessage);
734
+ if (ctx?.waitUntil) {
735
+ ctx.waitUntil(sendPromise);
736
+ dataPlane = { healthy: true, queueSent: true };
737
+ } else {
738
+ await sendPromise;
739
+ dataPlane = { healthy: true, queueSent: true };
740
+ }
741
+ } catch (error) {
742
+ dataPlane = {
743
+ healthy: false,
744
+ queueSent: false,
745
+ error: error instanceof Error ? error.message : String(error),
746
+ };
747
+ }
748
+ } else {
749
+ // No queue provided - skip data plane check
750
+ dataPlane = { healthy: true, queueSent: false };
751
+ }
752
+
753
+ return {
754
+ healthy: controlPlane.healthy && dataPlane.healthy,
755
+ controlPlane,
756
+ dataPlane,
757
+ project,
758
+ feature: featureId,
759
+ timestamp,
760
+ };
761
+ }
762
+
763
+ // =============================================================================
764
+ // CRON/QUEUE BUDGET HELPERS
765
+ // =============================================================================
766
+
767
+ /**
768
+ * Wrap an environment for cron handler budget tracking.
769
+ *
770
+ * Thin wrapper over withFeatureBudget with cron-specific defaults:
771
+ * - Generates deterministic correlation ID from cron expression + timestamp
772
+ * - Requires ExecutionContext (compile-time enforcement)
773
+ *
774
+ * @param env - Worker environment with bindings
775
+ * @param featureId - Feature identifier in format 'project:category:feature'
776
+ * @param options - Cron-specific options (ctx required)
777
+ * @returns Proxied environment with tracked bindings
778
+ *
779
+ * @example
780
+ * ```typescript
781
+ * export default {
782
+ * async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
783
+ * const trackedEnv = withCronBudget(env, 'platform:cron:cleanup', {
784
+ * ctx,
785
+ * cronExpression: event.cron,
786
+ * });
787
+ * await trackedEnv.DB.prepare('DELETE FROM old_records...').run();
788
+ * await completeTracking(trackedEnv);
789
+ * }
790
+ * };
791
+ * ```
792
+ */
793
+ export function withCronBudget<T extends object>(
794
+ env: T,
795
+ featureId: FeatureId,
796
+ options: CronBudgetOptions
797
+ ): TrackedEnv<T> {
798
+ const { ctx, cronExpression, externalCostUsd } = options;
799
+
800
+ // Generate deterministic correlation ID from cron expression + timestamp
801
+ // Format: cron:{expression}:{epochMs}
802
+ const cronId = cronExpression
803
+ ? `cron:${cronExpression.replace(/\s+/g, '-')}:${Date.now()}`
804
+ : `cron:manual:${Date.now()}`;
805
+
806
+ return withFeatureBudget(env, featureId, {
807
+ ctx,
808
+ correlationId: cronId,
809
+ externalCostUsd,
810
+ });
811
+ }
812
+
813
+ /**
814
+ * Wrap an environment for queue handler budget tracking.
815
+ *
816
+ * Thin wrapper over withFeatureBudget with queue-specific defaults:
817
+ * - Extracts correlation ID from message body if present
818
+ * - Generates queue-prefixed correlation ID otherwise
819
+ *
820
+ * @param env - Worker environment with bindings
821
+ * @param featureId - Feature identifier in format 'project:category:feature'
822
+ * @param options - Queue-specific options
823
+ * @returns Proxied environment with tracked bindings
824
+ *
825
+ * @example
826
+ * ```typescript
827
+ * export default {
828
+ * async queue(batch: MessageBatch<MyMessage>, env: Env) {
829
+ * for (const message of batch.messages) {
830
+ * const trackedEnv = withQueueBudget(env, 'platform:queue:process', {
831
+ * message: message.body,
832
+ * queueName: 'my-queue',
833
+ * });
834
+ * // Process message...
835
+ * await completeTracking(trackedEnv);
836
+ * message.ack();
837
+ * }
838
+ * }
839
+ * };
840
+ * ```
841
+ */
842
+ export function withQueueBudget<T extends object, M = unknown>(
843
+ env: T,
844
+ featureId: FeatureId,
845
+ options: QueueBudgetOptions<M> = {}
846
+ ): TrackedEnv<T> {
847
+ const { message, queueName, externalCostUsd } = options;
848
+
849
+ // Try to extract correlation ID from message body
850
+ let correlationId: string | undefined;
851
+
852
+ if (message && typeof message === 'object' && message !== null) {
853
+ const msgObj = message as Record<string, unknown>;
854
+ if (typeof msgObj.correlation_id === 'string') {
855
+ correlationId = msgObj.correlation_id;
856
+ }
857
+ }
858
+
859
+ // Generate queue-prefixed correlation ID if not extracted
860
+ if (!correlationId) {
861
+ const queuePrefix = queueName ?? 'unknown';
862
+ correlationId = `queue:${queuePrefix}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
863
+ }
864
+
865
+ return withFeatureBudget(env, featureId, {
866
+ correlationId,
867
+ externalCostUsd,
868
+ // Note: No ctx for queue handlers - they don't have ExecutionContext
869
+ // Telemetry is flushed synchronously via completeTracking()
870
+ });
871
+ }
872
+
873
+ // =============================================================================
874
+ // RE-EXPORTED PORTABLE UTILITIES
875
+ // =============================================================================
876
+
877
+ // Gatus heartbeat helper
878
+ export { pingHeartbeat } from './heartbeat';
879
+
880
+ // Exponential backoff retry
881
+ export { withExponentialBackoff } from './retry';
882
+
883
+ // Cloudflare pricing and cost calculation
884
+ export {
885
+ HOURS_PER_MONTH,
886
+ DAYS_PER_MONTH,
887
+ PRICING_TIERS,
888
+ PAID_ALLOWANCES,
889
+ calculateHourlyCosts,
890
+ prorateBaseCost,
891
+ prorateBaseCostByDays,
892
+ calculateDailyBillableCosts,
893
+ type HourlyUsageMetrics,
894
+ type HourlyCostBreakdown,
895
+ type AccountDailyUsage,
896
+ type DailyBillableCostBreakdown,
897
+ } from './costs';
898
+
899
+ // =============================================================================
900
+ // RE-EXPORTED MIDDLEWARE UTILITIES (v0.2.0)
901
+ // =============================================================================
902
+
903
+ // Project-level circuit breaker middleware
904
+ export {
905
+ // Constants
906
+ PROJECT_CB_STATUS,
907
+ GLOBAL_STOP_KEY,
908
+ CB_PROJECT_KEYS,
909
+ CB_ERROR_CODES,
910
+ BUDGET_STATUS_HEADER,
911
+ // Functions
912
+ createProjectKey,
913
+ checkProjectCircuitBreaker,
914
+ checkProjectCircuitBreakerDetailed,
915
+ createCircuitBreakerMiddleware,
916
+ getCircuitBreakerStates,
917
+ getProjectStatus,
918
+ setProjectStatus,
919
+ isGlobalStopActive,
920
+ setGlobalStop,
921
+ // Types
922
+ type CircuitBreakerStatusValue,
923
+ type CircuitBreakerCheckResult,
924
+ type CircuitBreakerMiddlewareOptions,
925
+ type CircuitBreakerErrorResponse,
926
+ } from './middleware';
927
+
928
+ // Transient error patterns (zero I/O, fully portable)
929
+ export {
930
+ TRANSIENT_ERROR_PATTERNS,
931
+ classifyErrorAsTransient,
932
+ type TransientErrorPattern,
933
+ } from './patterns';
934
+
935
+ // Dynamic patterns (KV-backed, AI-discovered)
936
+ export {
937
+ // Constants
938
+ DYNAMIC_PATTERNS_KV_KEY,
939
+ // Functions
940
+ loadDynamicPatterns,
941
+ compileDynamicPatterns,
942
+ clearDynamicPatternsCache,
943
+ classifyWithDynamicPatterns,
944
+ exportDynamicPatterns,
945
+ importDynamicPatterns,
946
+ // Types
947
+ type DynamicPatternRule,
948
+ type CompiledPattern,
949
+ type ClassificationResult,
950
+ } from './dynamic-patterns';