@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/README.md +306 -0
- package/package.json +53 -0
- package/src/ai-gateway.ts +305 -0
- package/src/constants.ts +147 -0
- package/src/costs.ts +590 -0
- package/src/do-heartbeat.ts +249 -0
- package/src/dynamic-patterns.ts +273 -0
- package/src/errors.ts +285 -0
- package/src/features.ts +149 -0
- package/src/heartbeat.ts +27 -0
- package/src/index.ts +950 -0
- package/src/logging.ts +543 -0
- package/src/middleware.ts +447 -0
- package/src/patterns.ts +156 -0
- package/src/proxy.ts +732 -0
- package/src/retry.ts +19 -0
- package/src/service-client.ts +291 -0
- package/src/telemetry.ts +342 -0
- package/src/timeout.ts +212 -0
- package/src/tracing.ts +403 -0
- package/src/types.ts +465 -0
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';
|