UNPKG

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
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(''); }); }); }); });