autotel-cloudflare
Version:
The #1 OpenTelemetry package for Cloudflare Workers - complete bindings coverage, native CF OTel integration, advanced sampling
400 lines (312 loc) • 12.7 kB
text/typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { instrumentGlobalFetch } from './fetch';
import { trace, SpanStatusCode, SpanKind, context as api_context } from '@opentelemetry/api';
import { setConfig, parseConfig } from 'autotel-edge';
describe('Global Fetch Instrumentation', () => {
let mockTracer: any;
let mockSpan: any;
let getTracerSpy: any;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
// Save original fetch
originalFetch = globalThis.fetch;
mockSpan = {
spanContext: () => ({
traceId: 'test-trace-id',
spanId: 'test-span-id',
traceFlags: 1,
}),
setAttribute: vi.fn(),
setAttributes: vi.fn(),
setStatus: vi.fn(),
recordException: vi.fn(),
end: vi.fn(),
isRecording: () => true,
updateName: vi.fn(),
addEvent: vi.fn(),
};
mockTracer = {
startActiveSpan: vi.fn((name, options, fn) => {
return fn(mockSpan);
}),
};
getTracerSpy = vi.spyOn(trace, 'getTracer').mockReturnValue(mockTracer as any);
// Mock underlying fetch to return test responses
globalThis.fetch = vi.fn(async (_input) => {
return new Response('{"data": "test"}', {
status: 200,
headers: { 'content-type': 'application/json' },
});
}) as any;
});
afterEach(() => {
// Restore original fetch
globalThis.fetch = originalFetch;
getTracerSpy.mockRestore();
});
describe('instrumentGlobalFetch()', () => {
it('should wrap globalThis.fetch', () => {
const originalFetch = globalThis.fetch;
instrumentGlobalFetch();
expect(globalThis.fetch).not.toBe(originalFetch);
expect(typeof globalThis.fetch).toBe('function');
});
it('should create span for HTTP requests', async () => {
// Set up config
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users');
expect(mockTracer.startActiveSpan).toHaveBeenCalled();
const spanName = mockTracer.startActiveSpan.mock.calls[0][0];
expect(spanName).toContain('api.example.com');
expect(spanName).toContain('GET');
});
});
it('should add HTTP attributes (method, URL, status, headers)', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'user-agent': 'test-client/1.0' },
});
const options = mockTracer.startActiveSpan.mock.calls[0][1];
expect(options.kind).toBe(SpanKind.CLIENT);
expect(options.attributes['http.request.method']).toBe('POST');
expect(options.attributes['url.full']).toBe('https://api.example.com/users');
expect(options.attributes['server.address']).toBe('api.example.com');
expect(options.attributes['url.scheme']).toBe('https');
});
});
it('should add response attributes', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users');
expect(mockSpan.setAttributes).toHaveBeenCalled();
// Find the call with response attributes
const responseAttributesCall = mockSpan.setAttributes.mock.calls.find(
(call: any) => call[0]['http.response.status_code'] !== undefined
);
expect(responseAttributesCall).toBeDefined();
expect(responseAttributesCall[0]['http.response.status_code']).toBe(200);
});
});
it('should inject traceparent header for context propagation by default', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users');
// Span should have been created
expect(mockTracer.startActiveSpan).toHaveBeenCalled();
// In a real scenario, traceparent would be injected via propagation.inject()
// For this test, we just verify the span was created
expect(mockSpan.end).toHaveBeenCalled();
});
});
it('should NOT inject traceparent when includeTraceContext = false', async () => {
const config = parseConfig({
service: { name: 'test-service' },
fetch: { includeTraceContext: false },
});
setConfig(config);
instrumentGlobalFetch();
// Create a spy to check if headers.set was called
const headersSpy = vi.spyOn(Headers.prototype, 'set');
await fetch('https://api.example.com/users');
// Should not have tried to inject traceparent
const traceparentCalls = headersSpy.mock.calls.filter(
(call) => call[0] === 'traceparent'
);
expect(traceparentCalls.length).toBe(0);
headersSpy.mockRestore();
});
it('should skip non-HTTP requests (file://, data://)', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
setConfig(config);
instrumentGlobalFetch();
// Try to fetch a file:// URL (should skip instrumentation)
try {
await fetch('file:///path/to/file.txt');
} catch (_e) {
// file:// will fail, that's expected
}
// Should not have created a span
expect(mockTracer.startActiveSpan).not.toHaveBeenCalled();
});
it('should skip when no active config (not initialized)', async () => {
// Don't set config
setConfig(null as any);
instrumentGlobalFetch();
await fetch('https://api.example.com/users');
// Should not have created a span
expect(mockTracer.startActiveSpan).not.toHaveBeenCalled();
// Original fetch should have been called (returning mocked response)
// We can't easily verify this without more complex mocking,
// but the key assertion is that no span was created
});
it('should handle successful responses (200-299)', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
// Mock successful response
globalThis.fetch = vi.fn(async () => {
return new Response('OK', { status: 200 });
}) as any;
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users');
expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
expect(mockSpan.end).toHaveBeenCalled();
});
});
it('should handle error responses (400-599)', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
// Mock error response
globalThis.fetch = vi.fn(async () => {
return new Response('Not Found', { status: 404 });
}) as any;
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users');
expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
expect(mockSpan.end).toHaveBeenCalled();
});
});
it('should handle network errors', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
// Mock network error
globalThis.fetch = vi.fn(async () => {
throw new Error('Network error');
}) as any;
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await expect(fetch('https://api.example.com/users')).rejects.toThrow('Network error');
expect(mockSpan.recordException).toHaveBeenCalled();
expect(mockSpan.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'Network error',
});
expect(mockSpan.end).toHaveBeenCalled();
});
});
it('should allow custom includeTraceContext function', async () => {
const includeTraceContextFn = vi.fn((request: Request) => {
// Only include for specific domains
return request.url.includes('internal.example.com');
});
const config = parseConfig({
service: { name: 'test-service' },
fetch: { includeTraceContext: includeTraceContextFn },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
// Fetch internal domain - should include context
await fetch('https://internal.example.com/api');
expect(includeTraceContextFn).toHaveBeenCalledTimes(1);
// Fetch external domain - should not include context
await fetch('https://external.com/api');
expect(includeTraceContextFn).toHaveBeenCalledTimes(2);
});
});
it('should handle Request objects as input', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
const request = new Request('https://api.example.com/users', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'test' }),
});
await fetch(request);
expect(mockTracer.startActiveSpan).toHaveBeenCalled();
const spanName = mockTracer.startActiveSpan.mock.calls[0][0];
expect(spanName).toContain('POST');
expect(spanName).toContain('api.example.com');
});
});
it('should handle URL objects as input', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
const url = new URL('https://api.example.com/users');
await fetch(url);
expect(mockTracer.startActiveSpan).toHaveBeenCalled();
const spanName = mockTracer.startActiveSpan.mock.calls[0][0];
expect(spanName).toContain('api.example.com');
});
});
});
describe('dataSafety.redactQueryParams', () => {
it('should capture full URL and query string by default', async () => {
const config = parseConfig({
service: { name: 'test-service' },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users?token=secret&page=1');
const options = mockTracer.startActiveSpan.mock.calls[0][1];
expect(options.attributes['url.full']).toBe('https://api.example.com/users?token=secret&page=1');
expect(options.attributes['url.query']).toBe('?token=secret&page=1');
});
});
it('should redact query params when redactQueryParams is true', async () => {
const config = parseConfig({
service: { name: 'test-service' },
dataSafety: { redactQueryParams: true },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users?token=secret&page=1');
const options = mockTracer.startActiveSpan.mock.calls[0][1];
expect(options.attributes['url.full']).toBe('https://api.example.com/users');
expect(options.attributes['url.query']).toBe('[REDACTED]');
});
});
it('should handle URLs without query string when redactQueryParams is true', async () => {
const config = parseConfig({
service: { name: 'test-service' },
dataSafety: { redactQueryParams: true },
});
const ctx = setConfig(config);
instrumentGlobalFetch();
await api_context.with(ctx, async () => {
await fetch('https://api.example.com/users');
const options = mockTracer.startActiveSpan.mock.calls[0][1];
expect(options.attributes['url.full']).toBe('https://api.example.com/users');
expect(options.attributes['url.query']).toBe('');
});
});
});
});