autotel-cloudflare 2.12.0 → 2.13.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.
Files changed (40) hide show
  1. package/dist/actors.js +1 -1
  2. package/dist/bindings.d.ts +113 -1
  3. package/dist/bindings.js +2 -2
  4. package/dist/chunk-4UG2QCPQ.js +1060 -0
  5. package/dist/chunk-4UG2QCPQ.js.map +1 -0
  6. package/dist/{chunk-5NL62W4L.js → chunk-O4IYKWPJ.js} +8 -3
  7. package/dist/chunk-O4IYKWPJ.js.map +1 -0
  8. package/dist/{chunk-ADWSZ5GY.js → chunk-ZJPX4N7S.js} +3 -3
  9. package/dist/{chunk-ADWSZ5GY.js.map → chunk-ZJPX4N7S.js.map} +1 -1
  10. package/dist/handlers.js +2 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +34 -6
  13. package/dist/index.js.map +1 -1
  14. package/package.json +1 -1
  15. package/src/bindings/ai.test.ts +156 -0
  16. package/src/bindings/ai.ts +71 -0
  17. package/src/bindings/analytics-engine.test.ts +160 -0
  18. package/src/bindings/analytics-engine.ts +78 -0
  19. package/src/bindings/bindings-detection.test.ts +235 -0
  20. package/src/bindings/bindings.ts +98 -47
  21. package/src/bindings/browser-rendering.test.ts +144 -0
  22. package/src/bindings/browser-rendering.ts +70 -0
  23. package/src/bindings/common.ts +9 -0
  24. package/src/bindings/hyperdrive.test.ts +154 -0
  25. package/src/bindings/hyperdrive.ts +74 -0
  26. package/src/bindings/images.test.ts +229 -0
  27. package/src/bindings/images.ts +182 -0
  28. package/src/bindings/index.ts +8 -0
  29. package/src/bindings/queue-producer.test.ts +192 -0
  30. package/src/bindings/queue-producer.ts +105 -0
  31. package/src/bindings/rate-limiter.test.ts +124 -0
  32. package/src/bindings/rate-limiter.ts +69 -0
  33. package/src/bindings/vectorize.test.ts +340 -0
  34. package/src/bindings/vectorize.ts +86 -0
  35. package/src/index.ts +8 -0
  36. package/src/wrappers/cf-attributes.test.ts +275 -0
  37. package/src/wrappers/instrument.ts +38 -0
  38. package/dist/chunk-5NL62W4L.js.map +0 -1
  39. package/dist/chunk-UPQE3J4I.js +0 -520
  40. package/dist/chunk-UPQE3J4I.js.map +0 -1
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Analytics Engine binding instrumentation
3
+ */
4
+
5
+ import {
6
+ trace,
7
+ SpanKind,
8
+ SpanStatusCode,
9
+ } from '@opentelemetry/api';
10
+ import type { WorkerTracer } from 'autotel-edge';
11
+ import { wrap } from './common';
12
+
13
+ /**
14
+ * Instrument Analytics Engine binding
15
+ */
16
+ export function instrumentAnalyticsEngine<T extends AnalyticsEngineDataset>(ae: T, datasetName?: string): T {
17
+ const name = datasetName || 'analytics-engine';
18
+
19
+ const handler: ProxyHandler<T> = {
20
+ get(target, prop) {
21
+ const value = Reflect.get(target, prop);
22
+
23
+ if (prop === 'writeDataPoint' && typeof value === 'function') {
24
+ return new Proxy(value, {
25
+ apply: (fnTarget, thisArg, args) => {
26
+ const [dataPoint] = args as [AnalyticsEngineDataPoint | undefined];
27
+ const tracer = trace.getTracer('autotel-edge') as WorkerTracer;
28
+
29
+ const attributes: Record<string, string | number> = {
30
+ 'analytics.system': 'cloudflare-analytics-engine',
31
+ 'analytics.operation': 'writeDataPoint',
32
+ };
33
+
34
+ if (dataPoint) {
35
+ if (dataPoint.indexes) {
36
+ attributes['analytics.indexes_count'] = Array.isArray(dataPoint.indexes) ? dataPoint.indexes.length : 1;
37
+ }
38
+ if (dataPoint.doubles) {
39
+ attributes['analytics.doubles_count'] = dataPoint.doubles.length;
40
+ }
41
+ if (dataPoint.blobs) {
42
+ attributes['analytics.blobs_count'] = dataPoint.blobs.length;
43
+ }
44
+ }
45
+
46
+ return tracer.startActiveSpan(
47
+ `AnalyticsEngine ${name}: writeDataPoint`,
48
+ {
49
+ kind: SpanKind.CLIENT,
50
+ attributes,
51
+ },
52
+ (span) => {
53
+ try {
54
+ // writeDataPoint is synchronous/void
55
+ Reflect.apply(fnTarget, thisArg, args);
56
+ span.setStatus({ code: SpanStatusCode.OK });
57
+ } catch (error) {
58
+ span.recordException(error as Error);
59
+ span.setStatus({
60
+ code: SpanStatusCode.ERROR,
61
+ message: error instanceof Error ? error.message : String(error),
62
+ });
63
+ throw error;
64
+ } finally {
65
+ span.end();
66
+ }
67
+ },
68
+ );
69
+ },
70
+ });
71
+ }
72
+
73
+ return value;
74
+ },
75
+ };
76
+
77
+ return wrap(ae, handler);
78
+ }
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
3
+ import { instrumentBindings } from './bindings';
4
+ import { isWrapped, wrap } from './common';
5
+
6
+ describe('instrumentBindings() detection logic', () => {
7
+ let mockTracer: any;
8
+ let mockSpan: any;
9
+ let getTracerSpy: any;
10
+
11
+ beforeEach(() => {
12
+ mockSpan = {
13
+ spanContext: () => ({
14
+ traceId: 'test-trace-id',
15
+ spanId: 'test-span-id',
16
+ traceFlags: 1,
17
+ }),
18
+ setAttribute: vi.fn(),
19
+ setAttributes: vi.fn(),
20
+ setStatus: vi.fn(),
21
+ recordException: vi.fn(),
22
+ end: vi.fn(),
23
+ isRecording: () => true,
24
+ updateName: vi.fn(),
25
+ addEvent: vi.fn(),
26
+ };
27
+
28
+ mockTracer = {
29
+ startActiveSpan: vi.fn((name, options, fn) => {
30
+ return fn(mockSpan);
31
+ }),
32
+ };
33
+
34
+ getTracerSpy = vi.spyOn(trace, 'getTracer').mockReturnValue(mockTracer as any);
35
+ });
36
+
37
+ afterEach(() => {
38
+ getTracerSpy.mockRestore();
39
+ });
40
+
41
+ it('should detect R2 (object with get, put, delete, list, head methods)', () => {
42
+ const mockR2 = {
43
+ get: vi.fn(),
44
+ put: vi.fn(),
45
+ delete: vi.fn(),
46
+ list: vi.fn(),
47
+ head: vi.fn(),
48
+ };
49
+
50
+ const result = instrumentBindings({ MY_R2: mockR2 });
51
+
52
+ expect(result.MY_R2).not.toBe(mockR2);
53
+ expect(isWrapped(result.MY_R2)).toBe(true);
54
+ });
55
+
56
+ it('should detect KV (object with get, put, delete, list but NOT head)', () => {
57
+ const mockKV = {
58
+ get: vi.fn(),
59
+ put: vi.fn(),
60
+ delete: vi.fn(),
61
+ list: vi.fn(),
62
+ };
63
+
64
+ const result = instrumentBindings({ MY_KV: mockKV });
65
+
66
+ expect(result.MY_KV).not.toBe(mockKV);
67
+ expect(isWrapped(result.MY_KV)).toBe(true);
68
+ });
69
+
70
+ it('should detect D1 (object with prepare, exec methods)', () => {
71
+ const mockD1 = {
72
+ prepare: vi.fn(),
73
+ exec: vi.fn(),
74
+ };
75
+
76
+ const result = instrumentBindings({ MY_DB: mockD1 });
77
+
78
+ expect(result.MY_DB).not.toBe(mockD1);
79
+ expect(isWrapped(result.MY_DB)).toBe(true);
80
+ });
81
+
82
+ it('should detect Vectorize (object with query, insert, upsert, describe methods)', () => {
83
+ const mockVectorize = {
84
+ query: vi.fn(),
85
+ insert: vi.fn(),
86
+ upsert: vi.fn(),
87
+ describe: vi.fn(),
88
+ };
89
+
90
+ const result = instrumentBindings({ MY_INDEX: mockVectorize });
91
+
92
+ expect(result.MY_INDEX).not.toBe(mockVectorize);
93
+ expect(isWrapped(result.MY_INDEX)).toBe(true);
94
+ });
95
+
96
+ it('should detect AI (object with run method AND gateway property)', () => {
97
+ const mockAI = {
98
+ run: vi.fn(),
99
+ gateway: {},
100
+ };
101
+
102
+ const result = instrumentBindings({ AI: mockAI });
103
+
104
+ expect(result.AI).not.toBe(mockAI);
105
+ expect(isWrapped(result.AI)).toBe(true);
106
+ });
107
+
108
+ it('should detect Hyperdrive (object with connect method AND connectionString, host properties)', () => {
109
+ const mockHyperdrive = {
110
+ connect: vi.fn(),
111
+ connectionString: 'postgresql://user:pass@host:5432/db',
112
+ host: 'db.example.com',
113
+ port: 5432,
114
+ user: 'user',
115
+ password: 'pass',
116
+ database: 'db',
117
+ };
118
+
119
+ const result = instrumentBindings({ HYPERDRIVE: mockHyperdrive });
120
+
121
+ expect(result.HYPERDRIVE).not.toBe(mockHyperdrive);
122
+ expect(isWrapped(result.HYPERDRIVE)).toBe(true);
123
+ });
124
+
125
+ it('should detect Queue Producer (object with send, sendBatch but NOT get)', () => {
126
+ const mockQueue = {
127
+ send: vi.fn(),
128
+ sendBatch: vi.fn(),
129
+ };
130
+
131
+ const result = instrumentBindings({ MY_QUEUE: mockQueue });
132
+
133
+ expect(result.MY_QUEUE).not.toBe(mockQueue);
134
+ expect(isWrapped(result.MY_QUEUE)).toBe(true);
135
+ });
136
+
137
+ it('should detect Analytics Engine (object with writeDataPoint method)', () => {
138
+ const mockAE = {
139
+ writeDataPoint: vi.fn(),
140
+ };
141
+
142
+ const result = instrumentBindings({ ANALYTICS: mockAE });
143
+
144
+ expect(result.ANALYTICS).not.toBe(mockAE);
145
+ expect(isWrapped(result.ANALYTICS)).toBe(true);
146
+ });
147
+
148
+ it('should detect Images (object with info, input methods)', () => {
149
+ const mockImages = {
150
+ info: vi.fn(),
151
+ input: vi.fn(),
152
+ };
153
+
154
+ const result = instrumentBindings({ IMAGES: mockImages });
155
+
156
+ expect(result.IMAGES).not.toBe(mockImages);
157
+ expect(isWrapped(result.IMAGES)).toBe(true);
158
+ });
159
+
160
+ it('should detect Service Binding (object with fetch method) - last', () => {
161
+ const mockService = {
162
+ fetch: vi.fn(),
163
+ };
164
+
165
+ const result = instrumentBindings({ MY_SERVICE: mockService });
166
+
167
+ expect(result.MY_SERVICE).not.toBe(mockService);
168
+ expect(isWrapped(result.MY_SERVICE)).toBe(true);
169
+ });
170
+
171
+ it('should detect R2 before KV (object with head gets R2, without head gets KV)', () => {
172
+ const withHead = {
173
+ get: vi.fn(),
174
+ put: vi.fn(),
175
+ delete: vi.fn(),
176
+ list: vi.fn(),
177
+ head: vi.fn(),
178
+ };
179
+ const withoutHead = {
180
+ get: vi.fn(),
181
+ put: vi.fn(),
182
+ delete: vi.fn(),
183
+ list: vi.fn(),
184
+ };
185
+
186
+ const result = instrumentBindings({
187
+ R2_BUCKET: withHead,
188
+ KV_STORE: withoutHead,
189
+ });
190
+
191
+ // Both should be wrapped but detected as different types
192
+ expect(isWrapped(result.R2_BUCKET)).toBe(true);
193
+ expect(isWrapped(result.KV_STORE)).toBe(true);
194
+ expect(result.R2_BUCKET).not.toBe(withHead);
195
+ expect(result.KV_STORE).not.toBe(withoutHead);
196
+ });
197
+
198
+ it('should skip already-wrapped bindings', () => {
199
+ const mockKV = {
200
+ get: vi.fn(),
201
+ put: vi.fn(),
202
+ delete: vi.fn(),
203
+ list: vi.fn(),
204
+ };
205
+
206
+ // Pre-wrap the binding using the wrap helper
207
+ const preWrapped = wrap(mockKV, {
208
+ get(target, prop) {
209
+ return Reflect.get(target, prop);
210
+ },
211
+ });
212
+
213
+ const result = instrumentBindings({ MY_KV: preWrapped });
214
+
215
+ // Should be the same already-wrapped object, not double-wrapped
216
+ expect(result.MY_KV).toBe(preWrapped);
217
+ expect(isWrapped(result.MY_KV)).toBe(true);
218
+ });
219
+
220
+ it('should pass through non-object values (strings, numbers)', () => {
221
+ const result = instrumentBindings({
222
+ API_KEY: 'my-secret-key',
223
+ TIMEOUT: 5000,
224
+ ENABLED: true,
225
+ EMPTY: null,
226
+ UNDEF: undefined,
227
+ });
228
+
229
+ expect(result.API_KEY).toBe('my-secret-key');
230
+ expect(result.TIMEOUT).toBe(5000);
231
+ expect(result.ENABLED).toBe(true);
232
+ expect(result.EMPTY).toBe(null);
233
+ expect(result.UNDEF).toBe(undefined);
234
+ });
235
+ });
@@ -23,7 +23,13 @@ import {
23
23
  SpanStatusCode,
24
24
  } from '@opentelemetry/api';
25
25
  import { WorkerTracer } from 'autotel-edge';
26
- import { wrap } from './common';
26
+ import { wrap, isWrapped } from './common';
27
+ import { instrumentAI } from './ai';
28
+ import { instrumentVectorize } from './vectorize';
29
+ import { instrumentHyperdrive } from './hyperdrive';
30
+ import { instrumentQueueProducer } from './queue-producer';
31
+ import { instrumentAnalyticsEngine } from './analytics-engine';
32
+ import { instrumentImages } from './images';
27
33
 
28
34
  /**
29
35
  * Instrument KV namespace
@@ -554,68 +560,113 @@ export function instrumentServiceBinding<F extends Fetcher>(fetcher: F, serviceN
554
560
  return wrap(fetcher, fetcherHandler);
555
561
  }
556
562
 
563
+ /**
564
+ * Detection helpers
565
+ */
566
+ const hasMethod = (obj: any, m: string): boolean =>
567
+ typeof obj?.[m] === 'function';
568
+
569
+ const hasExactMethods = (obj: any, methods: string[]): boolean =>
570
+ methods.every(m => hasMethod(obj, m));
571
+
557
572
  /**
558
573
  * Auto-instrument all Cloudflare bindings in the environment
574
+ *
575
+ * Detection order (most specific first):
576
+ * 1. R2 — get, put, delete, list, head
577
+ * 2. KV — get, put, delete, list (not head)
578
+ * 3. D1 — prepare, exec
579
+ * 4. Vectorize — query, insert, upsert, describe
580
+ * 5. AI — run + (gateway or models discriminator)
581
+ * 6. Hyperdrive — connect + connectionString + host
582
+ * 7. Queue Producer — send, sendBatch (not get)
583
+ * 8. Analytics Engine — writeDataPoint
584
+ * 9. Images — info, input
585
+ * 10. Service Binding — fetch (broadest, must be last)
586
+ *
587
+ * Not auto-detected (manual only):
588
+ * - Rate Limiter — limit() alone too generic
589
+ * - Browser Rendering — indistinguishable from Service Binding
559
590
  */
560
591
  export function instrumentBindings(env: Record<string, any>): Record<string, any> {
561
592
  const instrumented: Record<string, any> = {};
562
-
593
+
563
594
  for (const [key, value] of Object.entries(env)) {
564
595
  if (!value || typeof value !== 'object') {
565
596
  instrumented[key] = value;
566
597
  continue;
567
598
  }
568
-
569
- // Check for KV namespace
570
- if ('get' in value && 'put' in value && 'delete' in value && 'list' in value) {
571
- // Likely KV namespace
572
- try {
573
- instrumented[key] = instrumentKV(value as KVNamespace, key);
574
- continue;
575
- } catch {
576
- // Not KV, continue checking
577
- }
599
+
600
+ // Skip already-instrumented bindings
601
+ if (isWrapped(value)) {
602
+ instrumented[key] = value;
603
+ continue;
578
604
  }
579
-
580
- // Check for R2 bucket
581
- if ('get' in value && 'put' in value && 'delete' in value && 'list' in value && 'head' in value) {
582
- // Likely R2 bucket
583
- try {
584
- instrumented[key] = instrumentR2(value as R2Bucket, key);
585
- continue;
586
- } catch {
587
- // Not R2, continue checking
588
- }
605
+
606
+ // 1. R2 — most specific (has head)
607
+ if (hasExactMethods(value, ['get', 'put', 'delete', 'list', 'head'])) {
608
+ instrumented[key] = instrumentR2(value as R2Bucket, key);
609
+ continue;
589
610
  }
590
-
591
- // Check for D1 database
592
- if ('prepare' in value && 'exec' in value && typeof value.prepare === 'function') {
593
- // Likely D1 database
594
- try {
595
- instrumented[key] = instrumentD1(value as D1Database, key);
596
- continue;
597
- } catch {
598
- // Not D1, continue checking
599
- }
611
+
612
+ // 2. KV like R2 but without head
613
+ if (hasExactMethods(value, ['get', 'put', 'delete', 'list']) && !('head' in value)) {
614
+ instrumented[key] = instrumentKV(value as KVNamespace, key);
615
+ continue;
600
616
  }
601
-
602
- // Check for Service Binding (Fetcher)
603
- if ('fetch' in value && typeof value.fetch === 'function') {
604
- // Likely service binding
605
- try {
606
- instrumented[key] = instrumentServiceBinding(value as Fetcher, key);
607
- continue;
608
- } catch {
609
- // Not a service binding, continue checking
610
- }
617
+
618
+ // 3. D1
619
+ if (hasExactMethods(value, ['prepare', 'exec'])) {
620
+ instrumented[key] = instrumentD1(value as D1Database, key);
621
+ continue;
622
+ }
623
+
624
+ // 4. Vectorize
625
+ if (hasExactMethods(value, ['query', 'insert', 'upsert', 'describe'])) {
626
+ instrumented[key] = instrumentVectorize(value as VectorizeIndex, key);
627
+ continue;
611
628
  }
612
-
613
- // For other bindings (Events Engine, Workers AI, Vectorize, Hyperdrive),
614
- // they don't have standard interfaces we can detect, so we pass them through
615
- // Users can manually instrument them if needed
629
+
630
+ // 5. AI has run() + discriminator properties
631
+ if (hasMethod(value, 'run') && ('gateway' in value || 'models' in value)) {
632
+ instrumented[key] = instrumentAI(value as Ai, key);
633
+ continue;
634
+ }
635
+
636
+ // 6. Hyperdrive — connect + connection properties
637
+ if (hasMethod(value, 'connect') && 'connectionString' in value && 'host' in value) {
638
+ instrumented[key] = instrumentHyperdrive(value as Hyperdrive, key);
639
+ continue;
640
+ }
641
+
642
+ // 7. Queue Producer — send + sendBatch (not get, to avoid KV collision)
643
+ if (hasExactMethods(value, ['send', 'sendBatch']) && !('get' in value)) {
644
+ instrumented[key] = instrumentQueueProducer(value as Queue, key);
645
+ continue;
646
+ }
647
+
648
+ // 8. Analytics Engine
649
+ if (hasMethod(value, 'writeDataPoint')) {
650
+ instrumented[key] = instrumentAnalyticsEngine(value as AnalyticsEngineDataset, key);
651
+ continue;
652
+ }
653
+
654
+ // 9. Images
655
+ if (hasExactMethods(value, ['info', 'input'])) {
656
+ instrumented[key] = instrumentImages(value as any, key);
657
+ continue;
658
+ }
659
+
660
+ // 10. Service Binding (broadest — must be last)
661
+ if (hasMethod(value, 'fetch')) {
662
+ instrumented[key] = instrumentServiceBinding(value as Fetcher, key);
663
+ continue;
664
+ }
665
+
666
+ // Unknown binding type — pass through
616
667
  instrumented[key] = value;
617
668
  }
618
-
669
+
619
670
  return instrumented;
620
671
  }
621
672
 
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { instrumentBrowserRendering } from './browser-rendering';
3
+ import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
4
+
5
+ describe('Browser Rendering Instrumentation', () => {
6
+ let mockTracer: any;
7
+ let mockSpan: any;
8
+ let getTracerSpy: any;
9
+ let mockBrowser: any;
10
+
11
+ beforeEach(() => {
12
+ mockSpan = {
13
+ spanContext: () => ({
14
+ traceId: 'test-trace-id',
15
+ spanId: 'test-span-id',
16
+ traceFlags: 1,
17
+ }),
18
+ setAttribute: vi.fn(),
19
+ setAttributes: vi.fn(),
20
+ setStatus: vi.fn(),
21
+ recordException: vi.fn(),
22
+ end: vi.fn(),
23
+ isRecording: () => true,
24
+ updateName: vi.fn(),
25
+ addEvent: vi.fn(),
26
+ };
27
+
28
+ mockTracer = {
29
+ startActiveSpan: vi.fn((name, options, fn) => {
30
+ return fn(mockSpan);
31
+ }),
32
+ };
33
+
34
+ getTracerSpy = vi.spyOn(trace, 'getTracer').mockReturnValue(mockTracer as any);
35
+
36
+ mockBrowser = {
37
+ fetch: vi.fn(async () => new Response('<html></html>', { status: 200 })),
38
+ someOtherMethod: vi.fn(() => 'passthrough-value'),
39
+ };
40
+ });
41
+
42
+ afterEach(() => {
43
+ getTracerSpy.mockRestore();
44
+ });
45
+
46
+ describe('fetch()', () => {
47
+ it('should create span with correct attributes', async () => {
48
+ const instrumented = instrumentBrowserRendering(mockBrowser, 'my-browser');
49
+
50
+ await instrumented.fetch('https://example.com/page');
51
+
52
+ expect(mockTracer.startActiveSpan).toHaveBeenCalledTimes(1);
53
+
54
+ const [spanName, spanOptions] = mockTracer.startActiveSpan.mock.calls[0];
55
+ expect(spanName).toBe('BrowserRendering my-browser: fetch');
56
+ expect(spanOptions.kind).toBe(SpanKind.CLIENT);
57
+ expect(spanOptions.attributes['browser.system']).toBe('cloudflare-browser-rendering');
58
+ expect(spanOptions.attributes['url.full']).toBe('https://example.com/page');
59
+ });
60
+
61
+ it('should record http.response.status_code', async () => {
62
+ mockBrowser.fetch = vi.fn(async () => new Response('Not Found', { status: 404 }));
63
+
64
+ const instrumented = instrumentBrowserRendering(mockBrowser, 'my-browser');
65
+
66
+ await instrumented.fetch('https://example.com/missing');
67
+
68
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith('http.response.status_code', 404);
69
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
70
+ expect(mockSpan.end).toHaveBeenCalled();
71
+ });
72
+
73
+ it('should handle errors by recording exception and rethrowing', async () => {
74
+ const error = new Error('Browser rendering failed');
75
+ mockBrowser.fetch = vi.fn(async () => {
76
+ throw error;
77
+ });
78
+
79
+ const instrumented = instrumentBrowserRendering(mockBrowser, 'my-browser');
80
+
81
+ await expect(instrumented.fetch('https://example.com/broken')).rejects.toThrow('Browser rendering failed');
82
+
83
+ expect(mockSpan.recordException).toHaveBeenCalledWith(error);
84
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({
85
+ code: SpanStatusCode.ERROR,
86
+ message: 'Browser rendering failed',
87
+ });
88
+ expect(mockSpan.end).toHaveBeenCalled();
89
+ });
90
+
91
+ it('should use default binding name when none provided', async () => {
92
+ const instrumented = instrumentBrowserRendering(mockBrowser);
93
+
94
+ await instrumented.fetch('https://example.com/page');
95
+
96
+ const spanName = mockTracer.startActiveSpan.mock.calls[0][0];
97
+ expect(spanName).toBe('BrowserRendering browser: fetch');
98
+ });
99
+
100
+ it('should handle URL objects as input', async () => {
101
+ const instrumented = instrumentBrowserRendering(mockBrowser, 'my-browser');
102
+
103
+ const url = new URL('https://example.com/rendered-page');
104
+ await instrumented.fetch(url);
105
+
106
+ const spanOptions = mockTracer.startActiveSpan.mock.calls[0][1];
107
+ expect(spanOptions.attributes['url.full']).toBe('https://example.com/rendered-page');
108
+ });
109
+
110
+ it('should handle Request objects as input', async () => {
111
+ const instrumented = instrumentBrowserRendering(mockBrowser, 'my-browser');
112
+
113
+ const request = new Request('https://example.com/request-page');
114
+ await instrumented.fetch(request);
115
+
116
+ const spanOptions = mockTracer.startActiveSpan.mock.calls[0][1];
117
+ expect(spanOptions.attributes['url.full']).toBe('https://example.com/request-page');
118
+ });
119
+
120
+ it('should set OK status and end span on success', async () => {
121
+ const instrumented = instrumentBrowserRendering(mockBrowser, 'my-browser');
122
+
123
+ const result = await instrumented.fetch('https://example.com/page');
124
+
125
+ expect(result).toBeInstanceOf(Response);
126
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith('http.response.status_code', 200);
127
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
128
+ expect(mockSpan.end).toHaveBeenCalled();
129
+ });
130
+ });
131
+
132
+ describe('Non-instrumented methods', () => {
133
+ it('should pass through non-instrumented methods unchanged', () => {
134
+ const instrumented = instrumentBrowserRendering(mockBrowser, 'my-browser');
135
+
136
+ const result = instrumented.someOtherMethod();
137
+
138
+ expect(result).toBe('passthrough-value');
139
+ expect(mockBrowser.someOtherMethod).toHaveBeenCalled();
140
+ // No span should be created for non-instrumented methods
141
+ expect(mockTracer.startActiveSpan).not.toHaveBeenCalled();
142
+ });
143
+ });
144
+ });