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.
- package/dist/actors.js +1 -1
- package/dist/bindings.d.ts +113 -1
- package/dist/bindings.js +2 -2
- package/dist/chunk-4UG2QCPQ.js +1060 -0
- package/dist/chunk-4UG2QCPQ.js.map +1 -0
- package/dist/{chunk-5NL62W4L.js → chunk-O4IYKWPJ.js} +8 -3
- package/dist/chunk-O4IYKWPJ.js.map +1 -0
- package/dist/{chunk-ADWSZ5GY.js → chunk-WDNZVVRW.js} +8 -9
- package/dist/chunk-WDNZVVRW.js.map +1 -0
- package/dist/handlers.d.ts +5 -14
- package/dist/handlers.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +34 -6
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/bindings/ai.test.ts +156 -0
- package/src/bindings/ai.ts +71 -0
- package/src/bindings/analytics-engine.test.ts +160 -0
- package/src/bindings/analytics-engine.ts +78 -0
- package/src/bindings/bindings-detection.test.ts +235 -0
- package/src/bindings/bindings.ts +98 -47
- package/src/bindings/browser-rendering.test.ts +144 -0
- package/src/bindings/browser-rendering.ts +70 -0
- package/src/bindings/common.ts +9 -0
- package/src/bindings/hyperdrive.test.ts +154 -0
- package/src/bindings/hyperdrive.ts +74 -0
- package/src/bindings/images.test.ts +229 -0
- package/src/bindings/images.ts +182 -0
- package/src/bindings/index.ts +8 -0
- package/src/bindings/queue-producer.test.ts +192 -0
- package/src/bindings/queue-producer.ts +105 -0
- package/src/bindings/rate-limiter.test.ts +124 -0
- package/src/bindings/rate-limiter.ts +69 -0
- package/src/bindings/vectorize.test.ts +340 -0
- package/src/bindings/vectorize.ts +86 -0
- package/src/handlers/workflows.test.ts +325 -0
- package/src/handlers/workflows.ts +51 -41
- package/src/index.ts +8 -0
- package/src/wrappers/cf-attributes.test.ts +275 -0
- package/src/wrappers/instrument.ts +38 -0
- package/dist/chunk-5NL62W4L.js.map +0 -1
- package/dist/chunk-ADWSZ5GY.js.map +0 -1
- package/dist/chunk-UPQE3J4I.js +0 -520
- 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
|
+
});
|
package/src/bindings/bindings.ts
CHANGED
|
@@ -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
|
-
//
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
//
|
|
581
|
-
if ('get'
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
//
|
|
592
|
-
if ('
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
//
|
|
603
|
-
if ('
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
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
|
+
});
|