autotel-cloudflare 2.12.0 → 2.14.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 (44) 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-WDNZVVRW.js} +8 -9
  9. package/dist/chunk-WDNZVVRW.js.map +1 -0
  10. package/dist/handlers.d.ts +5 -14
  11. package/dist/handlers.js +2 -2
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +34 -6
  14. package/dist/index.js.map +1 -1
  15. package/package.json +2 -2
  16. package/src/bindings/ai.test.ts +156 -0
  17. package/src/bindings/ai.ts +71 -0
  18. package/src/bindings/analytics-engine.test.ts +160 -0
  19. package/src/bindings/analytics-engine.ts +78 -0
  20. package/src/bindings/bindings-detection.test.ts +235 -0
  21. package/src/bindings/bindings.ts +98 -47
  22. package/src/bindings/browser-rendering.test.ts +144 -0
  23. package/src/bindings/browser-rendering.ts +70 -0
  24. package/src/bindings/common.ts +9 -0
  25. package/src/bindings/hyperdrive.test.ts +154 -0
  26. package/src/bindings/hyperdrive.ts +74 -0
  27. package/src/bindings/images.test.ts +229 -0
  28. package/src/bindings/images.ts +182 -0
  29. package/src/bindings/index.ts +8 -0
  30. package/src/bindings/queue-producer.test.ts +192 -0
  31. package/src/bindings/queue-producer.ts +105 -0
  32. package/src/bindings/rate-limiter.test.ts +124 -0
  33. package/src/bindings/rate-limiter.ts +69 -0
  34. package/src/bindings/vectorize.test.ts +340 -0
  35. package/src/bindings/vectorize.ts +86 -0
  36. package/src/handlers/workflows.test.ts +325 -0
  37. package/src/handlers/workflows.ts +51 -41
  38. package/src/index.ts +8 -0
  39. package/src/wrappers/cf-attributes.test.ts +275 -0
  40. package/src/wrappers/instrument.ts +38 -0
  41. package/dist/chunk-5NL62W4L.js.map +0 -1
  42. package/dist/chunk-ADWSZ5GY.js.map +0 -1
  43. package/dist/chunk-UPQE3J4I.js +0 -520
  44. package/dist/chunk-UPQE3J4I.js.map +0 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Images binding instrumentation
3
+ *
4
+ * The Images binding uses a fluent chain: input() -> transform() -> draw() -> output()
5
+ * We only create a span at the terminal output() call to avoid intermediate noise.
6
+ * info() is a standalone operation and gets its own span.
7
+ */
8
+
9
+ import {
10
+ trace,
11
+ SpanKind,
12
+ SpanStatusCode,
13
+ } from '@opentelemetry/api';
14
+ import type { WorkerTracer } from 'autotel-edge';
15
+ import { wrap, setAttr } from './common';
16
+
17
+ const pipelineMetaSymbol = Symbol('images-pipeline-meta');
18
+
19
+ interface PipelineMeta {
20
+ operationCount: number;
21
+ }
22
+
23
+ interface ImagesLike {
24
+ info(blob: ReadableStream | ArrayBuffer | Blob): Promise<{ width: number; height: number; format: string }>;
25
+ input(blob: ReadableStream | ArrayBuffer | Blob): ImageTransformerLike;
26
+ }
27
+
28
+ interface ImageTransformerLike {
29
+ transform(options: unknown): ImageTransformerLike;
30
+ draw(image: unknown, options?: unknown): ImageTransformerLike;
31
+ output(options?: unknown): Promise<ImageOutputLike>;
32
+ }
33
+
34
+ interface ImageOutputLike {
35
+ response(): Response;
36
+ blob(): Promise<Blob>;
37
+ arrayBuffer(): Promise<ArrayBuffer>;
38
+ }
39
+
40
+ function proxyTransformer(transformer: ImageTransformerLike, meta: PipelineMeta, bindingName: string): ImageTransformerLike {
41
+ const handler: ProxyHandler<ImageTransformerLike> = {
42
+ get(target, prop) {
43
+ const value = Reflect.get(target, prop);
44
+
45
+ if ((prop === 'transform' || prop === 'draw') && typeof value === 'function') {
46
+ return new Proxy(value, {
47
+ apply: (fnTarget, _thisArg, args) => {
48
+ meta.operationCount++;
49
+ const result = Reflect.apply(fnTarget, target, args);
50
+ // If the result is the transformer itself (fluent chain), return our proxy
51
+ if (result === target || (result && typeof result === 'object' && 'output' in result)) {
52
+ return proxyTransformer(result as ImageTransformerLike, meta, bindingName);
53
+ }
54
+ return result;
55
+ },
56
+ });
57
+ }
58
+
59
+ if (prop === 'output' && typeof value === 'function') {
60
+ return new Proxy(value, {
61
+ apply: (fnTarget, _thisArg, args) => {
62
+ const tracer = trace.getTracer('autotel-edge') as WorkerTracer;
63
+ const [formatOrOptions] = args;
64
+
65
+ const attributes: Record<string, string | number> = {
66
+ 'images.system': 'cloudflare-images',
67
+ 'images.pipeline.operation_count': meta.operationCount,
68
+ };
69
+
70
+ // Capture output format
71
+ if (typeof formatOrOptions === 'string') {
72
+ attributes['images.output.format'] = formatOrOptions;
73
+ } else if (formatOrOptions && typeof formatOrOptions === 'object') {
74
+ const fmt = (formatOrOptions as any).format;
75
+ if (fmt) attributes['images.output.format'] = fmt;
76
+ }
77
+
78
+ return tracer.startActiveSpan(
79
+ `Images ${bindingName}: output`,
80
+ {
81
+ kind: SpanKind.CLIENT,
82
+ attributes,
83
+ },
84
+ async (span) => {
85
+ try {
86
+ const result = await Reflect.apply(fnTarget, target, args);
87
+ span.setStatus({ code: SpanStatusCode.OK });
88
+ return result;
89
+ } catch (error) {
90
+ span.recordException(error as Error);
91
+ span.setStatus({
92
+ code: SpanStatusCode.ERROR,
93
+ message: error instanceof Error ? error.message : String(error),
94
+ });
95
+ throw error;
96
+ } finally {
97
+ span.end();
98
+ }
99
+ },
100
+ );
101
+ },
102
+ });
103
+ }
104
+
105
+ return value;
106
+ },
107
+ };
108
+
109
+ const proxy = new Proxy(transformer, handler);
110
+ Object.defineProperty(proxy, pipelineMetaSymbol, {
111
+ value: meta,
112
+ writable: false,
113
+ enumerable: false,
114
+ configurable: false,
115
+ });
116
+ return proxy;
117
+ }
118
+
119
+ /**
120
+ * Instrument Images binding
121
+ */
122
+ export function instrumentImages<T extends ImagesLike>(images: T, bindingName?: string): T {
123
+ const name = bindingName || 'images';
124
+
125
+ const handler: ProxyHandler<T> = {
126
+ get(target, prop) {
127
+ const value = Reflect.get(target, prop);
128
+
129
+ if (prop === 'info' && typeof value === 'function') {
130
+ return new Proxy(value, {
131
+ apply: (fnTarget, thisArg, args) => {
132
+ const tracer = trace.getTracer('autotel-edge') as WorkerTracer;
133
+
134
+ return tracer.startActiveSpan(
135
+ `Images ${name}: info`,
136
+ {
137
+ kind: SpanKind.CLIENT,
138
+ attributes: {
139
+ 'images.system': 'cloudflare-images',
140
+ 'images.operation': 'info',
141
+ },
142
+ },
143
+ async (span) => {
144
+ try {
145
+ const result = await Reflect.apply(fnTarget, thisArg, args);
146
+ setAttr(span, 'images.width', result?.width);
147
+ setAttr(span, 'images.height', result?.height);
148
+ setAttr(span, 'images.format', result?.format);
149
+ span.setStatus({ code: SpanStatusCode.OK });
150
+ return result;
151
+ } catch (error) {
152
+ span.recordException(error as Error);
153
+ span.setStatus({
154
+ code: SpanStatusCode.ERROR,
155
+ message: error instanceof Error ? error.message : String(error),
156
+ });
157
+ throw error;
158
+ } finally {
159
+ span.end();
160
+ }
161
+ },
162
+ );
163
+ },
164
+ });
165
+ }
166
+
167
+ if (prop === 'input' && typeof value === 'function') {
168
+ return new Proxy(value, {
169
+ apply: (fnTarget, thisArg, args) => {
170
+ const transformer = Reflect.apply(fnTarget, thisArg, args) as ImageTransformerLike;
171
+ const meta: PipelineMeta = { operationCount: 0 };
172
+ return proxyTransformer(transformer, meta, name);
173
+ },
174
+ });
175
+ }
176
+
177
+ return value;
178
+ },
179
+ };
180
+
181
+ return wrap(images, handler);
182
+ }
@@ -10,3 +10,11 @@ export {
10
10
  instrumentServiceBinding,
11
11
  instrumentBindings,
12
12
  } from './bindings';
13
+ export { instrumentAI } from './ai';
14
+ export { instrumentVectorize } from './vectorize';
15
+ export { instrumentHyperdrive } from './hyperdrive';
16
+ export { instrumentQueueProducer } from './queue-producer';
17
+ export { instrumentAnalyticsEngine } from './analytics-engine';
18
+ export { instrumentImages } from './images';
19
+ export { instrumentRateLimiter } from './rate-limiter';
20
+ export { instrumentBrowserRendering } from './browser-rendering';
@@ -0,0 +1,192 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { instrumentQueueProducer } from './queue-producer';
3
+ import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
4
+
5
+ describe('Queue Producer Binding Instrumentation', () => {
6
+ let mockTracer: any;
7
+ let mockSpan: any;
8
+ let getTracerSpy: any;
9
+
10
+ beforeEach(() => {
11
+ mockSpan = {
12
+ spanContext: () => ({
13
+ traceId: 'test-trace-id',
14
+ spanId: 'test-span-id',
15
+ traceFlags: 1,
16
+ }),
17
+ setAttribute: vi.fn(),
18
+ setAttributes: vi.fn(),
19
+ setStatus: vi.fn(),
20
+ recordException: vi.fn(),
21
+ end: vi.fn(),
22
+ isRecording: () => true,
23
+ updateName: vi.fn(),
24
+ addEvent: vi.fn(),
25
+ };
26
+
27
+ mockTracer = {
28
+ startActiveSpan: vi.fn((name, options, fn) => {
29
+ return fn(mockSpan);
30
+ }),
31
+ };
32
+
33
+ getTracerSpy = vi.spyOn(trace, 'getTracer').mockReturnValue(mockTracer as any);
34
+ });
35
+
36
+ afterEach(() => {
37
+ getTracerSpy.mockRestore();
38
+ });
39
+
40
+ function createMockQueue(overrides: Partial<Queue> = {}): Queue {
41
+ return {
42
+ send: vi.fn(async () => ({ messageId: 'msg-123', outcome: 'ok' })),
43
+ sendBatch: vi.fn(async () => ({})),
44
+ ...overrides,
45
+ } as unknown as Queue;
46
+ }
47
+
48
+ describe('send()', () => {
49
+ it('should create span with PRODUCER kind and correct attributes', async () => {
50
+ const mockQueue = createMockQueue();
51
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
52
+
53
+ await instrumented.send({ data: 'test-payload' });
54
+
55
+ expect(mockTracer.startActiveSpan).toHaveBeenCalledTimes(1);
56
+
57
+ const [spanName, options] = mockTracer.startActiveSpan.mock.calls[0];
58
+ expect(spanName).toBe('Queue my-queue: send');
59
+ expect(options.kind).toBe(SpanKind.PRODUCER);
60
+ expect(options.attributes['messaging.system']).toBe('cloudflare-queues');
61
+ expect(options.attributes['messaging.operation.type']).toBe('publish');
62
+ expect(options.attributes['messaging.operation']).toBe('send');
63
+ expect(options.attributes['messaging.destination.name']).toBe('my-queue');
64
+ });
65
+
66
+ it('should record messageId from result when available', async () => {
67
+ const mockQueue = createMockQueue({
68
+ send: vi.fn(async () => ({ messageId: 'msg-456', outcome: 'ok' })) as any,
69
+ });
70
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
71
+
72
+ await instrumented.send({ data: 'test-payload' });
73
+
74
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith('messaging.message.id', 'msg-456');
75
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
76
+ expect(mockSpan.end).toHaveBeenCalled();
77
+ });
78
+
79
+ it('should not set messageId attribute when result has no messageId', async () => {
80
+ const mockQueue = createMockQueue({
81
+ send: vi.fn(async () => ({})) as any,
82
+ });
83
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
84
+
85
+ await instrumented.send({ data: 'test-payload' });
86
+
87
+ // setAttr uses the common helper which skips undefined/null values
88
+ const messageIdCalls = mockSpan.setAttribute.mock.calls.filter(
89
+ (call: any) => call[0] === 'messaging.message.id'
90
+ );
91
+ expect(messageIdCalls.length).toBe(0);
92
+ });
93
+
94
+ it('should handle errors in send()', async () => {
95
+ const sendError = new Error('Queue full');
96
+ const mockQueue = createMockQueue({
97
+ send: vi.fn(async () => {
98
+ throw sendError;
99
+ }) as any,
100
+ });
101
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
102
+
103
+ await expect(instrumented.send({ data: 'test-payload' })).rejects.toThrow('Queue full');
104
+
105
+ expect(mockSpan.recordException).toHaveBeenCalledWith(sendError);
106
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({
107
+ code: SpanStatusCode.ERROR,
108
+ message: 'Queue full',
109
+ });
110
+ expect(mockSpan.end).toHaveBeenCalled();
111
+ });
112
+
113
+ it('should use default queue name when none provided', async () => {
114
+ const mockQueue = createMockQueue();
115
+ const instrumented = instrumentQueueProducer(mockQueue);
116
+
117
+ await instrumented.send({ data: 'test-payload' });
118
+
119
+ const [spanName, options] = mockTracer.startActiveSpan.mock.calls[0];
120
+ expect(spanName).toBe('Queue queue: send');
121
+ expect(options.attributes['messaging.destination.name']).toBe('queue');
122
+ });
123
+ });
124
+
125
+ describe('sendBatch()', () => {
126
+ it('should create span with batch_message_count', async () => {
127
+ const mockQueue = createMockQueue();
128
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
129
+
130
+ const messages = [
131
+ { body: { data: 'msg-1' } },
132
+ { body: { data: 'msg-2' } },
133
+ { body: { data: 'msg-3' } },
134
+ ];
135
+
136
+ await instrumented.sendBatch(messages);
137
+
138
+ expect(mockTracer.startActiveSpan).toHaveBeenCalledTimes(1);
139
+
140
+ const [spanName, options] = mockTracer.startActiveSpan.mock.calls[0];
141
+ expect(spanName).toBe('Queue my-queue: sendBatch');
142
+ expect(options.kind).toBe(SpanKind.PRODUCER);
143
+ expect(options.attributes['messaging.system']).toBe('cloudflare-queues');
144
+ expect(options.attributes['messaging.operation.type']).toBe('publish');
145
+ expect(options.attributes['messaging.operation']).toBe('sendBatch');
146
+ expect(options.attributes['messaging.destination.name']).toBe('my-queue');
147
+ expect(options.attributes['messaging.batch.message_count']).toBe(3);
148
+ });
149
+
150
+ it('should set OK status and end span on success', async () => {
151
+ const mockQueue = createMockQueue();
152
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
153
+
154
+ await instrumented.sendBatch([{ body: { data: 'msg-1' } }]);
155
+
156
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
157
+ expect(mockSpan.end).toHaveBeenCalled();
158
+ });
159
+
160
+ it('should handle errors in sendBatch()', async () => {
161
+ const batchError = new Error('Batch limit exceeded');
162
+ const mockQueue = createMockQueue({
163
+ sendBatch: vi.fn(async () => {
164
+ throw batchError;
165
+ }) as any,
166
+ });
167
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
168
+
169
+ const messages = [{ body: { data: 'msg-1' } }, { body: { data: 'msg-2' } }];
170
+
171
+ await expect(instrumented.sendBatch(messages)).rejects.toThrow('Batch limit exceeded');
172
+
173
+ expect(mockSpan.recordException).toHaveBeenCalledWith(batchError);
174
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({
175
+ code: SpanStatusCode.ERROR,
176
+ message: 'Batch limit exceeded',
177
+ });
178
+ expect(mockSpan.end).toHaveBeenCalled();
179
+ });
180
+ });
181
+
182
+ describe('non-instrumented methods', () => {
183
+ it('should pass through non-instrumented properties', () => {
184
+ const mockQueue = createMockQueue();
185
+ (mockQueue as any).customProp = 'test-value';
186
+
187
+ const instrumented = instrumentQueueProducer(mockQueue, 'my-queue');
188
+
189
+ expect((instrumented as any).customProp).toBe('test-value');
190
+ });
191
+ });
192
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Queue producer 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, setAttr } from './common';
12
+
13
+ /**
14
+ * Instrument Queue producer binding
15
+ */
16
+ export function instrumentQueueProducer<T extends Queue>(queue: T, queueName?: string): T {
17
+ const name = queueName || 'queue';
18
+
19
+ const handler: ProxyHandler<T> = {
20
+ get(target, prop) {
21
+ const value = Reflect.get(target, prop);
22
+
23
+ if (prop === 'send' && typeof value === 'function') {
24
+ return new Proxy(value, {
25
+ apply: (fnTarget, thisArg, args) => {
26
+ const tracer = trace.getTracer('autotel-edge') as WorkerTracer;
27
+
28
+ return tracer.startActiveSpan(
29
+ `Queue ${name}: send`,
30
+ {
31
+ kind: SpanKind.PRODUCER,
32
+ attributes: {
33
+ 'messaging.system': 'cloudflare-queues',
34
+ 'messaging.operation.type': 'publish',
35
+ 'messaging.operation': 'send',
36
+ 'messaging.destination.name': name,
37
+ },
38
+ },
39
+ async (span) => {
40
+ try {
41
+ const result = await Reflect.apply(fnTarget, thisArg, args);
42
+ setAttr(span, 'messaging.message.id', (result as any)?.messageId);
43
+ span.setStatus({ code: SpanStatusCode.OK });
44
+ return result;
45
+ } catch (error) {
46
+ span.recordException(error as Error);
47
+ span.setStatus({
48
+ code: SpanStatusCode.ERROR,
49
+ message: error instanceof Error ? error.message : String(error),
50
+ });
51
+ throw error;
52
+ } finally {
53
+ span.end();
54
+ }
55
+ },
56
+ );
57
+ },
58
+ });
59
+ }
60
+
61
+ if (prop === 'sendBatch' && typeof value === 'function') {
62
+ return new Proxy(value, {
63
+ apply: (fnTarget, thisArg, args) => {
64
+ const [messages] = args as [{ body: unknown }[]];
65
+ const tracer = trace.getTracer('autotel-edge') as WorkerTracer;
66
+
67
+ return tracer.startActiveSpan(
68
+ `Queue ${name}: sendBatch`,
69
+ {
70
+ kind: SpanKind.PRODUCER,
71
+ attributes: {
72
+ 'messaging.system': 'cloudflare-queues',
73
+ 'messaging.operation.type': 'publish',
74
+ 'messaging.operation': 'sendBatch',
75
+ 'messaging.destination.name': name,
76
+ 'messaging.batch.message_count': Array.isArray(messages) ? messages.length : 0,
77
+ },
78
+ },
79
+ async (span) => {
80
+ try {
81
+ const result = await Reflect.apply(fnTarget, thisArg, args);
82
+ span.setStatus({ code: SpanStatusCode.OK });
83
+ return result;
84
+ } catch (error) {
85
+ span.recordException(error as Error);
86
+ span.setStatus({
87
+ code: SpanStatusCode.ERROR,
88
+ message: error instanceof Error ? error.message : String(error),
89
+ });
90
+ throw error;
91
+ } finally {
92
+ span.end();
93
+ }
94
+ },
95
+ );
96
+ },
97
+ });
98
+ }
99
+
100
+ return value;
101
+ },
102
+ };
103
+
104
+ return wrap(queue, handler);
105
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { instrumentRateLimiter } from './rate-limiter';
3
+ import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
4
+
5
+ describe('Rate Limiter Instrumentation', () => {
6
+ let mockTracer: any;
7
+ let mockSpan: any;
8
+ let getTracerSpy: any;
9
+ let mockLimiter: 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
+ mockLimiter = {
37
+ limit: vi.fn(async () => ({ success: true })),
38
+ someOtherMethod: vi.fn(() => 'passthrough-value'),
39
+ };
40
+ });
41
+
42
+ afterEach(() => {
43
+ getTracerSpy.mockRestore();
44
+ });
45
+
46
+ describe('limit()', () => {
47
+ it('should create span with correct attributes', async () => {
48
+ const instrumented = instrumentRateLimiter(mockLimiter, 'my-limiter');
49
+
50
+ await instrumented.limit({ key: 'user-123' });
51
+
52
+ expect(mockTracer.startActiveSpan).toHaveBeenCalledTimes(1);
53
+
54
+ const [spanName, spanOptions] = mockTracer.startActiveSpan.mock.calls[0];
55
+ expect(spanName).toBe('RateLimiter my-limiter: limit');
56
+ expect(spanOptions.kind).toBe(SpanKind.CLIENT);
57
+ expect(spanOptions.attributes['rate_limiter.system']).toBe('cloudflare-rate-limiter');
58
+ expect(spanOptions.attributes['rate_limiter.key']).toBe('user-123');
59
+ });
60
+
61
+ it('should record success from result', async () => {
62
+ const instrumented = instrumentRateLimiter(mockLimiter, 'my-limiter');
63
+
64
+ await instrumented.limit({ key: 'user-123' });
65
+
66
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith('rate_limiter.success', true);
67
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
68
+ expect(mockSpan.end).toHaveBeenCalled();
69
+ });
70
+
71
+ it('should record success=false when rate limited', async () => {
72
+ mockLimiter.limit = vi.fn(async () => ({ success: false }));
73
+
74
+ const instrumented = instrumentRateLimiter(mockLimiter, 'my-limiter');
75
+
76
+ const result = await instrumented.limit({ key: 'user-456' });
77
+
78
+ expect(result.success).toBe(false);
79
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith('rate_limiter.success', false);
80
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
81
+ expect(mockSpan.end).toHaveBeenCalled();
82
+ });
83
+
84
+ it('should handle errors by recording exception and rethrowing', async () => {
85
+ const error = new Error('Rate limiter unavailable');
86
+ mockLimiter.limit = vi.fn(async () => {
87
+ throw error;
88
+ });
89
+
90
+ const instrumented = instrumentRateLimiter(mockLimiter, 'my-limiter');
91
+
92
+ await expect(instrumented.limit({ key: 'user-789' })).rejects.toThrow('Rate limiter unavailable');
93
+
94
+ expect(mockSpan.recordException).toHaveBeenCalledWith(error);
95
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({
96
+ code: SpanStatusCode.ERROR,
97
+ message: 'Rate limiter unavailable',
98
+ });
99
+ expect(mockSpan.end).toHaveBeenCalled();
100
+ });
101
+
102
+ it('should use default binding name when none provided', async () => {
103
+ const instrumented = instrumentRateLimiter(mockLimiter);
104
+
105
+ await instrumented.limit({ key: 'user-123' });
106
+
107
+ const spanName = mockTracer.startActiveSpan.mock.calls[0][0];
108
+ expect(spanName).toBe('RateLimiter rate-limiter: limit');
109
+ });
110
+ });
111
+
112
+ describe('Non-instrumented methods', () => {
113
+ it('should pass through non-instrumented methods unchanged', () => {
114
+ const instrumented = instrumentRateLimiter(mockLimiter, 'my-limiter');
115
+
116
+ const result = instrumented.someOtherMethod();
117
+
118
+ expect(result).toBe('passthrough-value');
119
+ expect(mockLimiter.someOtherMethod).toHaveBeenCalled();
120
+ // No span should be created for non-instrumented methods
121
+ expect(mockTracer.startActiveSpan).not.toHaveBeenCalled();
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Rate Limiter 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, setAttr } from './common';
12
+
13
+ interface RateLimiterLike {
14
+ limit(options: { key: string }): Promise<{ success: boolean }>;
15
+ }
16
+
17
+ /**
18
+ * Instrument Rate Limiter binding (manual only — not auto-detected)
19
+ */
20
+ export function instrumentRateLimiter<T extends RateLimiterLike>(limiter: T, bindingName?: string): T {
21
+ const name = bindingName || 'rate-limiter';
22
+
23
+ const handler: ProxyHandler<T> = {
24
+ get(target, prop) {
25
+ const value = Reflect.get(target, prop);
26
+
27
+ if (prop === 'limit' && typeof value === 'function') {
28
+ return new Proxy(value, {
29
+ apply: (fnTarget, thisArg, args) => {
30
+ const [options] = args as [{ key: string }];
31
+ const tracer = trace.getTracer('autotel-edge') as WorkerTracer;
32
+
33
+ return tracer.startActiveSpan(
34
+ `RateLimiter ${name}: limit`,
35
+ {
36
+ kind: SpanKind.CLIENT,
37
+ attributes: {
38
+ 'rate_limiter.system': 'cloudflare-rate-limiter',
39
+ 'rate_limiter.key': options?.key,
40
+ },
41
+ },
42
+ async (span) => {
43
+ try {
44
+ const result = await Reflect.apply(fnTarget, thisArg, args);
45
+ setAttr(span, 'rate_limiter.success', result?.success);
46
+ span.setStatus({ code: SpanStatusCode.OK });
47
+ return result;
48
+ } catch (error) {
49
+ span.recordException(error as Error);
50
+ span.setStatus({
51
+ code: SpanStatusCode.ERROR,
52
+ message: error instanceof Error ? error.message : String(error),
53
+ });
54
+ throw error;
55
+ } finally {
56
+ span.end();
57
+ }
58
+ },
59
+ );
60
+ },
61
+ });
62
+ }
63
+
64
+ return value;
65
+ },
66
+ };
67
+
68
+ return wrap(limiter, handler);
69
+ }