@comprehend/telemetry-browser
Version:
Integration of comprehend.dev with OpenTelemetry in browser environments.
293 lines (253 loc) • 10.4 kB
text/typescript
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();
});
});
});