@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/proxy.ts ADDED
@@ -0,0 +1,732 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Platform SDK Proxy
5
+ *
6
+ * Proxy handlers for automatic metric collection on Cloudflare bindings.
7
+ * Supports D1, KV, AI, and Vectorize with lazy/JIT circuit breaker checks.
8
+ */
9
+
10
+ import type { MetricsAccumulator } from './types';
11
+ import { getTelemetryContext } from './telemetry';
12
+
13
+ // =============================================================================
14
+ // TYPE GUARDS
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Type guard to check if a value is a D1 database.
19
+ */
20
+ export function isD1Database(value: unknown): value is D1Database {
21
+ return (
22
+ value !== null &&
23
+ typeof value === 'object' &&
24
+ 'prepare' in value &&
25
+ 'batch' in value &&
26
+ 'exec' in value
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Type guard to check if a value is a KV namespace.
32
+ */
33
+ export function isKVNamespace(value: unknown): value is KVNamespace {
34
+ return (
35
+ value !== null &&
36
+ typeof value === 'object' &&
37
+ 'get' in value &&
38
+ 'put' in value &&
39
+ 'delete' in value &&
40
+ 'list' in value &&
41
+ !('prepare' in value) && // Distinguish from D1
42
+ !('head' in value) // Distinguish from R2
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Type guard to check if a value is a Workers AI binding.
48
+ * AI bindings have a 'run' method.
49
+ */
50
+ export function isAIBinding(value: unknown): value is Ai {
51
+ return (
52
+ value !== null &&
53
+ typeof value === 'object' &&
54
+ 'run' in value &&
55
+ typeof (value as { run: unknown }).run === 'function'
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Type guard to check if a value is a Vectorize index.
61
+ * Vectorize has query, insert, upsert, deleteByIds methods.
62
+ */
63
+ export function isVectorizeIndex(value: unknown): value is VectorizeIndex {
64
+ return (
65
+ value !== null &&
66
+ typeof value === 'object' &&
67
+ 'query' in value &&
68
+ 'insert' in value &&
69
+ 'upsert' in value &&
70
+ 'deleteByIds' in value
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Type guard to check if a value is a Queue.
76
+ */
77
+ export function isQueue(value: unknown): value is Queue {
78
+ return value !== null && typeof value === 'object' && 'send' in value && 'sendBatch' in value;
79
+ }
80
+
81
+ /**
82
+ * Type guard to check if a value is a Durable Object namespace.
83
+ */
84
+ export function isDurableObjectNamespace(value: unknown): value is DurableObjectNamespace {
85
+ return (
86
+ value !== null &&
87
+ typeof value === 'object' &&
88
+ 'get' in value &&
89
+ 'idFromName' in value &&
90
+ 'idFromString' in value
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Type guard to check if a value is an R2 bucket.
96
+ */
97
+ export function isR2Bucket(value: unknown): value is R2Bucket {
98
+ return (
99
+ value !== null &&
100
+ typeof value === 'object' &&
101
+ 'put' in value &&
102
+ 'get' in value &&
103
+ 'head' in value &&
104
+ 'list' in value &&
105
+ 'delete' in value &&
106
+ 'createMultipartUpload' in value
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Type guard to check if a value is a Workflow binding.
112
+ * Workflow bindings have get, create, and createBatch methods.
113
+ */
114
+ export function isWorkflow(value: unknown): value is Workflow {
115
+ return (
116
+ value !== null &&
117
+ typeof value === 'object' &&
118
+ 'get' in value &&
119
+ 'create' in value &&
120
+ 'createBatch' in value &&
121
+ !('idFromName' in value) // Distinguish from DurableObjectNamespace
122
+ );
123
+ }
124
+
125
+ // =============================================================================
126
+ // D1 PROXY
127
+ // =============================================================================
128
+
129
+ /**
130
+ * Create a proxied D1 database that tracks reads and writes.
131
+ */
132
+ export function createD1Proxy(db: D1Database, metrics: MetricsAccumulator): D1Database {
133
+ return new Proxy(db, {
134
+ get(target, prop) {
135
+ if (prop === 'prepare') {
136
+ return (query: string) => {
137
+ const stmt = target.prepare(query);
138
+ return createD1StatementProxy(stmt, metrics);
139
+ };
140
+ }
141
+
142
+ if (prop === 'batch') {
143
+ return async (statements: D1PreparedStatement[]) => {
144
+ const results = await target.batch(statements);
145
+ // Count all batch operations from meta
146
+ for (const result of results) {
147
+ if (result.meta) {
148
+ metrics.d1Writes += result.meta.changes ?? 0;
149
+ metrics.d1RowsWritten += result.meta.changes ?? 0;
150
+ metrics.d1Reads += result.meta.rows_read ?? 0;
151
+ metrics.d1RowsRead += result.meta.rows_read ?? 0;
152
+ }
153
+ }
154
+ return results;
155
+ };
156
+ }
157
+
158
+ if (prop === 'dump') {
159
+ return async () => {
160
+ metrics.d1Reads += 1;
161
+ return target.dump();
162
+ };
163
+ }
164
+
165
+ return Reflect.get(target, prop);
166
+ },
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Create a proxied D1 prepared statement that tracks statement runs.
172
+ * Handles bind() chaining correctly.
173
+ */
174
+ function createD1StatementProxy(
175
+ stmt: D1PreparedStatement,
176
+ metrics: MetricsAccumulator
177
+ ): D1PreparedStatement {
178
+ return new Proxy(stmt, {
179
+ get(target, prop) {
180
+ // Handle bind() - returns a new statement that also needs proxying
181
+ if (prop === 'bind') {
182
+ return (...values: unknown[]) => {
183
+ const boundStmt = target.bind(...values);
184
+ return createD1StatementProxy(boundStmt, metrics);
185
+ };
186
+ }
187
+
188
+ // Handle statement runs
189
+ if (prop === 'run' || prop === 'all' || prop === 'first' || prop === 'raw') {
190
+ return async (...args: unknown[]) => {
191
+ const method = target[prop as keyof D1PreparedStatement] as (
192
+ ...args: unknown[]
193
+ ) => Promise<D1Result<unknown>>;
194
+ const result = await method.apply(target, args);
195
+
196
+ // Track based on result meta
197
+ if (result && typeof result === 'object' && 'meta' in result) {
198
+ const meta = (result as D1Result<unknown>).meta;
199
+ if (meta) {
200
+ metrics.d1Writes += meta.changes ?? 0;
201
+ metrics.d1RowsWritten += meta.changes ?? 0;
202
+ metrics.d1Reads += meta.rows_read ?? 0;
203
+ metrics.d1RowsRead += meta.rows_read ?? 0;
204
+ }
205
+ }
206
+
207
+ return result;
208
+ };
209
+ }
210
+
211
+ return Reflect.get(target, prop);
212
+ },
213
+ });
214
+ }
215
+
216
+ // =============================================================================
217
+ // KV PROXY
218
+ // =============================================================================
219
+
220
+ /**
221
+ * Create a proxied KV namespace that tracks reads, writes, deletes, and lists.
222
+ */
223
+ export function createKVProxy(kv: KVNamespace, metrics: MetricsAccumulator): KVNamespace {
224
+ return new Proxy(kv, {
225
+ get(target, prop) {
226
+ if (prop === 'get') {
227
+ return async (...args: Parameters<KVNamespace['get']>) => {
228
+ metrics.kvReads += 1;
229
+ return target.get(...args);
230
+ };
231
+ }
232
+
233
+ if (prop === 'getWithMetadata') {
234
+ return async (...args: Parameters<KVNamespace['getWithMetadata']>) => {
235
+ metrics.kvReads += 1;
236
+ return target.getWithMetadata(...args);
237
+ };
238
+ }
239
+
240
+ if (prop === 'put') {
241
+ return async (...args: Parameters<KVNamespace['put']>) => {
242
+ metrics.kvWrites += 1;
243
+ return target.put(...args);
244
+ };
245
+ }
246
+
247
+ if (prop === 'delete') {
248
+ return async (...args: Parameters<KVNamespace['delete']>) => {
249
+ metrics.kvDeletes += 1;
250
+ return target.delete(...args);
251
+ };
252
+ }
253
+
254
+ if (prop === 'list') {
255
+ return async (...args: Parameters<KVNamespace['list']>) => {
256
+ metrics.kvLists += 1;
257
+ return target.list(...args);
258
+ };
259
+ }
260
+
261
+ return Reflect.get(target, prop);
262
+ },
263
+ });
264
+ }
265
+
266
+ // =============================================================================
267
+ // AI PROXY
268
+ // =============================================================================
269
+
270
+ /**
271
+ * Create a proxied AI binding that tracks run() calls.
272
+ * Tracks both total request count and per-model breakdown.
273
+ */
274
+ export function createAIProxy(ai: Ai, metrics: MetricsAccumulator): Ai {
275
+ return new Proxy(ai, {
276
+ get(target, prop) {
277
+ if (prop === 'run') {
278
+ return async (
279
+ model: string | { name: string; [key: string]: unknown },
280
+ inputs: unknown,
281
+ options?: unknown
282
+ ) => {
283
+ // Extract model name from string or object
284
+ const modelName = typeof model === 'string' ? model : model.name;
285
+
286
+ // Track total AI requests
287
+ metrics.aiRequests += 1;
288
+
289
+ // Track per-model count
290
+ const currentCount = metrics.aiModelCounts.get(modelName) ?? 0;
291
+ metrics.aiModelCounts.set(modelName, currentCount + 1);
292
+
293
+ // Call the actual AI.run method
294
+ const result = await target.run(
295
+ model as Parameters<Ai['run']>[0],
296
+ inputs as Parameters<Ai['run']>[1],
297
+ options as Parameters<Ai['run']>[2]
298
+ );
299
+
300
+ // Note: We can't easily track neurons here as they're returned
301
+ // in usage metadata that varies by model. The consumer can
302
+ // derive this from the model + input size.
303
+
304
+ return result;
305
+ };
306
+ }
307
+
308
+ return Reflect.get(target, prop);
309
+ },
310
+ });
311
+ }
312
+
313
+ // =============================================================================
314
+ // VECTORIZE PROXY
315
+ // =============================================================================
316
+
317
+ /**
318
+ * Create a proxied Vectorize index that tracks queries, inserts, and deletes.
319
+ */
320
+ export function createVectorizeProxy(
321
+ index: VectorizeIndex,
322
+ metrics: MetricsAccumulator
323
+ ): VectorizeIndex {
324
+ return new Proxy(index, {
325
+ get(target, prop) {
326
+ if (prop === 'query') {
327
+ return async (...args: Parameters<VectorizeIndex['query']>) => {
328
+ metrics.vectorizeQueries += 1;
329
+ return target.query(...args);
330
+ };
331
+ }
332
+
333
+ if (prop === 'insert') {
334
+ return async (vectors: VectorizeVector[]) => {
335
+ metrics.vectorizeInserts += vectors.length;
336
+ return target.insert(vectors);
337
+ };
338
+ }
339
+
340
+ if (prop === 'upsert') {
341
+ return async (vectors: VectorizeVector[]) => {
342
+ metrics.vectorizeInserts += vectors.length;
343
+ return target.upsert(vectors);
344
+ };
345
+ }
346
+
347
+ if (prop === 'deleteByIds') {
348
+ return async (ids: string[]) => {
349
+ // vectorizeDeletes removed - Analytics Engine 20 double limit
350
+ // Deletes still work, just not tracked in telemetry
351
+ return target.deleteByIds(ids);
352
+ };
353
+ }
354
+
355
+ if (prop === 'getByIds') {
356
+ return async (...args: Parameters<VectorizeIndex['getByIds']>) => {
357
+ metrics.vectorizeQueries += 1;
358
+ return target.getByIds(...args);
359
+ };
360
+ }
361
+
362
+ if (prop === 'describe') {
363
+ return async () => {
364
+ // describe() is a read operation but doesn't query vectors
365
+ return target.describe();
366
+ };
367
+ }
368
+
369
+ return Reflect.get(target, prop);
370
+ },
371
+ });
372
+ }
373
+
374
+ // =============================================================================
375
+ // R2 PROXY
376
+ // =============================================================================
377
+
378
+ /**
379
+ * Create a proxied R2MultipartUpload that tracks uploadPart/complete/abort.
380
+ */
381
+ function createR2MultipartUploadProxy(
382
+ upload: R2MultipartUpload,
383
+ metrics: MetricsAccumulator
384
+ ): R2MultipartUpload {
385
+ return new Proxy(upload, {
386
+ get(target, prop) {
387
+ if (prop === 'uploadPart') {
388
+ return async (
389
+ partNumber: number,
390
+ value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob
391
+ ) => {
392
+ metrics.r2ClassA += 1;
393
+ return target.uploadPart(partNumber, value);
394
+ };
395
+ }
396
+
397
+ if (prop === 'complete') {
398
+ return async (uploadedParts: R2UploadedPart[]) => {
399
+ metrics.r2ClassA += 1;
400
+ return target.complete(uploadedParts);
401
+ };
402
+ }
403
+
404
+ if (prop === 'abort') {
405
+ return async () => {
406
+ metrics.r2ClassA += 1;
407
+ return target.abort();
408
+ };
409
+ }
410
+
411
+ return Reflect.get(target, prop);
412
+ },
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Create a proxied R2 bucket that tracks Class A and Class B operations.
418
+ * Class A: list, put, delete, createMultipartUpload, resumeMultipartUpload
419
+ * Class B: head, get
420
+ *
421
+ * IMPORTANT: get() does NOT consume the stream body - just logs the call.
422
+ */
423
+ export function createR2Proxy(bucket: R2Bucket, metrics: MetricsAccumulator): R2Bucket {
424
+ return new Proxy(bucket, {
425
+ get(target, prop) {
426
+ // Class B: head
427
+ if (prop === 'head') {
428
+ return async (key: string) => {
429
+ metrics.r2ClassB += 1;
430
+ return target.head(key);
431
+ };
432
+ }
433
+
434
+ // Class B: get (don't consume stream)
435
+ if (prop === 'get') {
436
+ return async (key: string, options?: R2GetOptions) => {
437
+ metrics.r2ClassB += 1;
438
+ return target.get(key, options);
439
+ };
440
+ }
441
+
442
+ // Class A: put
443
+ if (prop === 'put') {
444
+ return async (
445
+ key: string,
446
+ value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob,
447
+ options?: R2PutOptions
448
+ ) => {
449
+ metrics.r2ClassA += 1;
450
+ return target.put(key, value, options);
451
+ };
452
+ }
453
+
454
+ // Class A: delete
455
+ if (prop === 'delete') {
456
+ return async (keys: string | string[]) => {
457
+ metrics.r2ClassA += 1;
458
+ return target.delete(keys);
459
+ };
460
+ }
461
+
462
+ // Class A: list
463
+ if (prop === 'list') {
464
+ return async (options?: R2ListOptions) => {
465
+ metrics.r2ClassA += 1;
466
+ return target.list(options);
467
+ };
468
+ }
469
+
470
+ // Class A: createMultipartUpload
471
+ if (prop === 'createMultipartUpload') {
472
+ return async (key: string, options?: R2MultipartOptions) => {
473
+ metrics.r2ClassA += 1;
474
+ const upload = await target.createMultipartUpload(key, options);
475
+ return createR2MultipartUploadProxy(upload, metrics);
476
+ };
477
+ }
478
+
479
+ // Class A: resumeMultipartUpload (sync)
480
+ if (prop === 'resumeMultipartUpload') {
481
+ return (key: string, uploadId: string) => {
482
+ metrics.r2ClassA += 1;
483
+ const upload = target.resumeMultipartUpload(key, uploadId);
484
+ return createR2MultipartUploadProxy(upload, metrics);
485
+ };
486
+ }
487
+
488
+ return Reflect.get(target, prop);
489
+ },
490
+ });
491
+ }
492
+
493
+ // =============================================================================
494
+ // QUEUE PROXY
495
+ // =============================================================================
496
+
497
+ /**
498
+ * Create a proxied Queue that tracks message sends.
499
+ * send() = 1 message, sendBatch() = N messages.
500
+ */
501
+ export function createQueueProxy<T = unknown>(
502
+ queue: Queue<T>,
503
+ metrics: MetricsAccumulator
504
+ ): Queue<T> {
505
+ return new Proxy(queue, {
506
+ get(target, prop) {
507
+ if (prop === 'send') {
508
+ return async (message: T, options?: QueueSendOptions) => {
509
+ metrics.queueMessages += 1;
510
+ return target.send(message, options);
511
+ };
512
+ }
513
+
514
+ if (prop === 'sendBatch') {
515
+ return async (
516
+ messages: Iterable<MessageSendRequest<T>>,
517
+ options?: QueueSendBatchOptions
518
+ ) => {
519
+ const messageArray = Array.isArray(messages) ? messages : Array.from(messages);
520
+ metrics.queueMessages += messageArray.length;
521
+ return target.sendBatch(messageArray, options);
522
+ };
523
+ }
524
+
525
+ return Reflect.get(target, prop);
526
+ },
527
+ });
528
+ }
529
+
530
+ // =============================================================================
531
+ // WORKFLOW PROXY
532
+ // =============================================================================
533
+
534
+ /**
535
+ * Create a proxied Workflow that tracks invocations.
536
+ * create() = 1 invocation, createBatch() = N invocations.
537
+ * get() is read-only and not tracked.
538
+ */
539
+ export function createWorkflowProxy<PARAMS = unknown>(
540
+ workflow: Workflow<PARAMS>,
541
+ metrics: MetricsAccumulator
542
+ ): Workflow<PARAMS> {
543
+ return new Proxy(workflow, {
544
+ get(target, prop) {
545
+ if (prop === 'create') {
546
+ return async (options?: WorkflowInstanceCreateOptions<PARAMS>) => {
547
+ metrics.workflowInvocations += 1;
548
+ return target.create(options);
549
+ };
550
+ }
551
+
552
+ if (prop === 'createBatch') {
553
+ return async (batch: WorkflowInstanceCreateOptions<PARAMS>[]) => {
554
+ metrics.workflowInvocations += batch.length;
555
+ return target.createBatch(batch);
556
+ };
557
+ }
558
+
559
+ // get() is read-only, pass through
560
+ return Reflect.get(target, prop);
561
+ },
562
+ });
563
+ }
564
+
565
+ // =============================================================================
566
+ // DURABLE OBJECTS PROXY
567
+ // =============================================================================
568
+
569
+ /**
570
+ * Create a proxied Durable Object stub that tracks fetch() calls.
571
+ * This is the second level (returned by namespace.get()).
572
+ */
573
+ function createDOStubProxy(
574
+ stub: DurableObjectStub,
575
+ metrics: MetricsAccumulator
576
+ ): DurableObjectStub {
577
+ return new Proxy(stub, {
578
+ get(target, prop) {
579
+ if (prop === 'fetch') {
580
+ return async (...args: Parameters<DurableObjectStub['fetch']>) => {
581
+ const startTime = performance.now();
582
+ metrics.doRequests += 1;
583
+ try {
584
+ return await target.fetch(...args);
585
+ } finally {
586
+ const latencyMs = performance.now() - startTime;
587
+ metrics.doLatencyMs.push(latencyMs);
588
+ metrics.doTotalLatencyMs += latencyMs;
589
+ }
590
+ };
591
+ }
592
+
593
+ // Pass through: id, name
594
+ return Reflect.get(target, prop);
595
+ },
596
+ });
597
+ }
598
+
599
+ /**
600
+ * Create a proxied Durable Object namespace.
601
+ * get(id) returns a wrapped stub that tracks fetch() calls.
602
+ * ID creation methods (idFromName, idFromString, newUniqueId) pass through.
603
+ */
604
+ export function createDOProxy(
605
+ ns: DurableObjectNamespace,
606
+ metrics: MetricsAccumulator
607
+ ): DurableObjectNamespace {
608
+ return new Proxy(ns, {
609
+ get(target, prop) {
610
+ if (prop === 'get') {
611
+ return (id: DurableObjectId) => {
612
+ const stub = target.get(id);
613
+ return createDOStubProxy(stub, metrics);
614
+ };
615
+ }
616
+
617
+ // Pass through ID methods
618
+ return Reflect.get(target, prop);
619
+ },
620
+ });
621
+ }
622
+
623
+ // =============================================================================
624
+ // ENVIRONMENT PROXY
625
+ // =============================================================================
626
+
627
+ /**
628
+ * Reserved binding names that should NOT be proxied.
629
+ * These are used by the SDK itself for control/telemetry.
630
+ */
631
+ const RESERVED_BINDINGS = new Set(['PLATFORM_CACHE', 'PLATFORM_TELEMETRY']);
632
+
633
+ /**
634
+ * Create a proxied environment that wraps all known binding types.
635
+ * Bindings are wrapped lazily on first access.
636
+ *
637
+ * @param env - Original environment object
638
+ * @param metrics - Metrics accumulator to update
639
+ * @returns Proxied environment with tracked bindings
640
+ */
641
+ export function createEnvProxy<T extends object>(env: T, metrics: MetricsAccumulator): T {
642
+ // Cache for wrapped bindings to avoid re-wrapping on each access
643
+ const wrappedBindings = new Map<string | symbol, unknown>();
644
+
645
+ return new Proxy(env, {
646
+ get(target, prop) {
647
+ // Return cached wrapped binding if available
648
+ if (wrappedBindings.has(prop)) {
649
+ return wrappedBindings.get(prop);
650
+ }
651
+
652
+ const value = Reflect.get(target, prop);
653
+
654
+ // Skip wrapping for reserved bindings
655
+ if (typeof prop === 'string' && RESERVED_BINDINGS.has(prop)) {
656
+ return value;
657
+ }
658
+
659
+ // Wrap D1 databases
660
+ if (isD1Database(value)) {
661
+ const wrapped = createD1Proxy(value, metrics);
662
+ wrappedBindings.set(prop, wrapped);
663
+ return wrapped;
664
+ }
665
+
666
+ // Wrap KV namespaces
667
+ if (isKVNamespace(value)) {
668
+ const wrapped = createKVProxy(value, metrics);
669
+ wrappedBindings.set(prop, wrapped);
670
+ return wrapped;
671
+ }
672
+
673
+ // Wrap AI bindings
674
+ if (isAIBinding(value)) {
675
+ const wrapped = createAIProxy(value, metrics);
676
+ wrappedBindings.set(prop, wrapped);
677
+ return wrapped;
678
+ }
679
+
680
+ // Wrap Vectorize indexes
681
+ if (isVectorizeIndex(value)) {
682
+ const wrapped = createVectorizeProxy(value, metrics);
683
+ wrappedBindings.set(prop, wrapped);
684
+ return wrapped;
685
+ }
686
+
687
+ // Wrap R2 buckets
688
+ if (isR2Bucket(value)) {
689
+ const wrapped = createR2Proxy(value, metrics);
690
+ wrappedBindings.set(prop, wrapped);
691
+ return wrapped;
692
+ }
693
+
694
+ // Wrap Queues (skip PLATFORM_TELEMETRY - handled by RESERVED_BINDINGS)
695
+ if (isQueue(value)) {
696
+ const wrapped = createQueueProxy(value, metrics);
697
+ wrappedBindings.set(prop, wrapped);
698
+ return wrapped;
699
+ }
700
+
701
+ // Wrap Workflows
702
+ if (isWorkflow(value)) {
703
+ const wrapped = createWorkflowProxy(value, metrics);
704
+ wrappedBindings.set(prop, wrapped);
705
+ return wrapped;
706
+ }
707
+
708
+ // Wrap Durable Object namespaces
709
+ if (isDurableObjectNamespace(value)) {
710
+ const wrapped = createDOProxy(value, metrics);
711
+ wrappedBindings.set(prop, wrapped);
712
+ return wrapped;
713
+ }
714
+
715
+ // Return unwrapped value for other types
716
+ return value;
717
+ },
718
+ });
719
+ }
720
+
721
+ // =============================================================================
722
+ // UTILITY FUNCTIONS
723
+ // =============================================================================
724
+
725
+ /**
726
+ * Get the metrics accumulator for a proxied environment.
727
+ * Returns undefined if the environment is not being tracked.
728
+ */
729
+ export function getMetrics(env: object): MetricsAccumulator | undefined {
730
+ const context = getTelemetryContext(env);
731
+ return context?.metrics;
732
+ }