UNPKG

@comprehend/telemetry-browser

Version:

Integration of comprehend.dev with OpenTelemetry in browser environments.

293 lines (253 loc) 10.4 kB
import { ComprehendDevSpanProcessor } from './ComprehendDevSpanProcessor'; import { WebSocketConnection } from './WebSocketConnection'; import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; import { ReadableSpan } from '@opentelemetry/sdk-trace-web'; import { resourceFromAttributes } from '@opentelemetry/resources'; // Mock WebSocketConnection jest.mock('./WebSocketConnection'); const MockedWebSocketConnection = WebSocketConnection as jest.MockedClass<typeof WebSocketConnection>; describe('ComprehendDevSpanProcessor', () => { let processor: ComprehendDevSpanProcessor; let mockConnection: jest.Mocked<WebSocketConnection>; beforeEach(() => { jest.clearAllMocks(); mockConnection = { sendMessage: jest.fn(), close: jest.fn(), } as any; MockedWebSocketConnection.mockImplementation(() => mockConnection); processor = new ComprehendDevSpanProcessor({ organization: 'test-org', token: 'test-token', debug: false }); }); const createMockSpan = (overrides: Partial<ReadableSpan> = {}): ReadableSpan => ({ name: 'test-span', spanContext: () => ({ traceId: 'trace-123', spanId: 'span-123', traceFlags: 0 }), kind: SpanKind.CLIENT, startTime: [1234567890, 123456789], endTime: [1234567891, 123456789], duration: [1, 0], status: { code: SpanStatusCode.OK }, attributes: {}, events: [], links: [], resource: resourceFromAttributes({ 'service.name': 'test-service' }), instrumentationScope: { name: 'test', version: '1.0.0' }, droppedAttributesCount: 0, droppedEventsCount: 0, droppedLinksCount: 0, ended: true, ...overrides }); describe('onEnd', () => { it('should ignore spans without service name', () => { const span = createMockSpan({ resource: resourceFromAttributes({}) }); processor.onEnd(span); expect(mockConnection.sendMessage).not.toHaveBeenCalled(); }); it('should ignore non-client spans', () => { const span = createMockSpan({ kind: SpanKind.SERVER }); processor.onEnd(span); // Should only send service discovery message, but not HTTP processing expect(mockConnection.sendMessage).toHaveBeenCalledTimes(1); expect(mockConnection.sendMessage).toHaveBeenCalledWith({ event: 'new-entity', type: 'service', hash: expect.any(String), name: 'test-service' }); }); it('should process HTTP client spans', () => { const span = createMockSpan({ kind: SpanKind.CLIENT, attributes: { 'http.url': 'https://api.example.com/users', 'http.method': 'GET', 'http.status_code': 200 } }); processor.onEnd(span); expect(mockConnection.sendMessage).toHaveBeenCalledTimes(4); // Check service message expect(mockConnection.sendMessage).toHaveBeenCalledWith({ event: 'new-entity', type: 'service', hash: expect.any(String), name: 'test-service' }); // Check HTTP service message expect(mockConnection.sendMessage).toHaveBeenCalledWith({ event: 'new-entity', type: 'http-service', hash: expect.any(String), protocol: 'https', host: 'api.example.com', port: 443 }); // Check HTTP request interaction expect(mockConnection.sendMessage).toHaveBeenCalledWith({ event: 'new-interaction', type: 'http-request', hash: expect.any(String), from: expect.any(String), to: expect.any(String) }); }); it('should send observation for HTTP client span', () => { const span = createMockSpan({ kind: SpanKind.CLIENT, attributes: { 'http.url': 'https://api.example.com/users', 'http.method': 'POST', 'http.status_code': 201, 'http.request_content_length': 100, 'http.response_content_length': 50 }, duration: [0, 500000000] // 500ms in nanoseconds }); processor.onEnd(span); // Should send 4 messages: service, http-service, interaction, observation expect(mockConnection.sendMessage).toHaveBeenCalledTimes(4); // Check observation message const observationCall = mockConnection.sendMessage.mock.calls.find(call => call[0].event === 'observations' ); expect(observationCall).toBeDefined(); expect(observationCall![0]).toMatchObject({ event: 'observations', seq: 1, observations: [{ type: 'http-client', subject: expect.any(String), timestamp: [1234567890, 123456789], path: '/users', method: 'POST', status: 201, duration: [0, 500000000], requestBytes: 100, responseBytes: 50 }] }); }); it('should handle URLs with custom ports', () => { const span = createMockSpan({ kind: SpanKind.CLIENT, attributes: { 'http.url': 'http://localhost:3000/api/data', 'http.method': 'GET' } }); processor.onEnd(span); // Check HTTP service message has correct port const httpServiceCall = mockConnection.sendMessage.mock.calls.find(call => (call[0] as any).type === 'http-service' ); expect(httpServiceCall![0]).toMatchObject({ protocol: 'http', host: 'localhost', port: 3000 }); }); it('should handle error spans', () => { const span = createMockSpan({ kind: SpanKind.CLIENT, status: { code: SpanStatusCode.ERROR, message: 'Request failed' }, attributes: { 'http.url': 'https://api.example.com/error', 'http.method': 'GET', 'http.status_code': 500, 'exception.message': 'Network error', 'exception.type': 'NetworkException' }, events: [{ name: 'exception', time: [1234567890, 123456789], attributes: { 'exception.message': 'Detailed error', 'exception.type': 'DetailedException', 'exception.stacktrace': 'Error at line 1' } }] }); processor.onEnd(span); // Check observation includes error info const observationCall = mockConnection.sendMessage.mock.calls.find(call => call[0].event === 'observations' ); expect((observationCall![0] as any).observations[0]).toMatchObject({ type: 'http-client', errorMessage: 'Detailed error', errorType: 'DetailedException', stack: 'Error at line 1' }); }); it('should handle service with namespace and environment', () => { const span = createMockSpan({ kind: SpanKind.CLIENT, resource: resourceFromAttributes({ 'service.name': 'user-api', 'service.namespace': 'production', 'deployment.environment': 'prod' }), attributes: { 'http.url': 'https://api.example.com/users', 'http.method': 'GET' } }); processor.onEnd(span); // Check service message includes namespace and environment const serviceCall = mockConnection.sendMessage.mock.calls.find(call => (call[0] as any).type === 'service' ); expect(serviceCall![0]).toMatchObject({ name: 'user-api', namespace: 'production', environment: 'prod' }); }); it('should not duplicate services or interactions', () => { const span1 = createMockSpan({ kind: SpanKind.CLIENT, attributes: { 'http.url': 'https://api.example.com/users', 'http.method': 'GET' } }); const span2 = createMockSpan({ kind: SpanKind.CLIENT, attributes: { 'http.url': 'https://api.example.com/posts', 'http.method': 'POST' } }); processor.onEnd(span1); processor.onEnd(span2); // First span: service + http-service + interaction + observation = 4 calls // Second span: only observation (reusing existing service/http-service/interaction) = 1 call expect(mockConnection.sendMessage).toHaveBeenCalledTimes(5); }); }); describe('shutdown', () => { it('should close the WebSocket connection', async () => { await processor.shutdown(); expect(mockConnection.close).toHaveBeenCalled(); }); }); describe('forceFlush', () => { it('should resolve without error', async () => { await expect(processor.forceFlush()).resolves.toBeUndefined(); }); }); });