autotel
Version:
Write Once, Observe Anywhere
1,528 lines (1,335 loc) • 53.9 kB
text/typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
traceProducer,
traceConsumer,
clearOrderingState,
type ProducerConfig,
type ConsumerConfig,
type DLQOptions,
type DLQReplayOptions,
type DLQReasonCategory,
type OrderingConfig,
type OutOfOrderInfo,
type ConsumerGroupTrackingConfig,
type RebalanceEvent,
type PartitionAssignment,
type ConsumerGroupState,
type PartitionLag,
type RebalanceType,
} from './messaging';
// Mock the trace function to capture span options
vi.mock('./functional', () => ({
trace: vi.fn((name, factory) => {
return (...args: unknown[]) => {
const mockCtx = createMockContext();
const fn = factory(mockCtx);
return fn(...args);
};
}),
}));
// Mock sampling functions
vi.mock('./sampling', () => ({
createLinkFromHeaders: vi.fn((headers) => {
if (headers.traceparent) {
return {
context: {
traceId: '00000000000000000000000000000001',
spanId: '0000000000000001',
traceFlags: 1,
},
attributes: {},
};
}
return null;
}),
extractLinksFromBatch: vi.fn((messages) => {
return messages
.filter(
(m: { headers?: Record<string, string> }) => m.headers?.traceparent,
)
.map(() => ({
context: {
traceId: '00000000000000000000000000000001',
spanId: '0000000000000001',
traceFlags: 1,
},
attributes: {},
}));
}),
}));
function createMockContext() {
const attributes: Record<string, unknown> = {};
const events: Array<{ name: string; attributes?: Record<string, unknown> }> =
[];
const links: unknown[] = [];
return {
setAttribute: vi.fn((key, value) => {
attributes[key] = value;
}),
setAttributes: vi.fn((attrs) => {
Object.assign(attributes, attrs);
}),
addEvent: vi.fn((name, attrs) => {
events.push({ name, attributes: attrs });
}),
addLink: vi.fn((link) => {
links.push(link);
}),
addLinks: vi.fn((newLinks) => {
links.push(...newLinks);
}),
setStatus: vi.fn(),
getAttributes: () => attributes,
getEvents: () => events,
getLinks: () => links,
// ProducerContext methods
getTraceHeaders: vi.fn(() => ({
traceparent: '00-00000000000000000000000000000001-0000000000000001-01',
})),
getAllPropagationHeaders: vi.fn(() => ({
traceparent: '00-00000000000000000000000000000001-0000000000000001-01',
baggage: 'key=value',
})),
// ConsumerContext methods
recordDLQ: vi.fn(),
recordRetry: vi.fn(),
recordReplay: vi.fn(),
getProducerLinks: vi.fn(() => []),
};
}
describe('Messaging Helpers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('traceProducer', () => {
it('should create a producer function with correct span name', async () => {
const config: ProducerConfig = {
system: 'kafka',
destination: 'user-events',
};
const producer = traceProducer(config)(
(_ctx) => async (_event: { id: string }) => {
return { sent: true };
},
);
const result = await producer({ id: 'event-1' });
expect(result).toEqual({ sent: true });
});
it('should set messaging semantic attributes', async () => {
const config: ProducerConfig = {
system: 'kafka',
destination: 'user-events',
messageIdFrom: (args) => (args[0] as { id: string }).id,
};
// We need to directly test the attribute setting
// Since the trace mock doesn't persist context properly,
// we test the config structure
expect(config.system).toBe('kafka');
expect(config.destination).toBe('user-events');
});
it('should support SQS system type', () => {
const config: ProducerConfig = {
system: 'sqs',
destination: 'orders-queue',
};
const producer = traceProducer(config)(
(_ctx) => async (_message: string) => {
return { messageId: '123' };
},
);
expect(producer).toBeDefined();
});
it('should support RabbitMQ system type', () => {
const config: ProducerConfig = {
system: 'rabbitmq',
destination: 'notifications',
};
const producer = traceProducer(config)(
(_ctx) => async (_message: string) => {
return { delivered: true };
},
);
expect(producer).toBeDefined();
});
it('should call beforeSend callback', async () => {
const beforeSend = vi.fn();
const config: ProducerConfig = {
system: 'kafka',
destination: 'events',
beforeSend,
};
const producer = traceProducer(config)(
(_ctx) => async (_event: unknown) => {
return { sent: true };
},
);
await producer({ id: '1' });
expect(beforeSend).toHaveBeenCalled();
});
it('should call onError callback on failure', async () => {
const onError = vi.fn();
const testError = new Error('Send failed');
const config: ProducerConfig = {
system: 'kafka',
destination: 'events',
onError,
};
const producer = traceProducer(config)((_ctx) => async () => {
throw testError;
});
await expect(producer()).rejects.toThrow('Send failed');
expect(onError).toHaveBeenCalledWith(testError, expect.anything());
});
it('should extract message ID from function extractor', () => {
const config: ProducerConfig = {
system: 'kafka',
destination: 'events',
messageIdFrom: (args) => (args[0] as { eventId: string }).eventId,
};
expect(typeof config.messageIdFrom).toBe('function');
const extractor = config.messageIdFrom as (
args: unknown[],
) => string | undefined;
expect(extractor([{ eventId: 'evt-123' }])).toBe('evt-123');
});
it('should extract partition from function extractor', () => {
const config: ProducerConfig = {
system: 'kafka',
destination: 'events',
partitionFrom: (args) => (args[0] as { partition: number }).partition,
};
expect(typeof config.partitionFrom).toBe('function');
const extractor = config.partitionFrom as (
args: unknown[],
) => number | undefined;
expect(extractor([{ partition: 3 }])).toBe(3);
});
});
describe('traceConsumer', () => {
it('should create a consumer function with correct span name', async () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'user-events',
consumerGroup: 'my-consumer',
};
const consumer = traceConsumer(config)(
(_ctx) => async (_message: unknown) => {
return { processed: true };
},
);
const result = await consumer({ value: 'test' });
expect(result).toEqual({ processed: true });
});
it('should support batch mode', async () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'user-events',
batchMode: true,
headersFrom: (msg) =>
(msg as { headers: Record<string, string> }).headers,
};
const consumer = traceConsumer(config)(
(_ctx) => async (messages: unknown[]) => {
return { count: messages.length };
},
);
const messages = [
{ value: 'a', headers: { traceparent: '00-abc-def-01' } },
{ value: 'b', headers: { traceparent: '00-xyz-uvw-01' } },
];
const result = await consumer(messages);
expect(result).toEqual({ count: 2 });
});
it('should call onError callback on failure', async () => {
const onError = vi.fn();
const testError = new Error('Process failed');
const config: ConsumerConfig = {
system: 'sqs',
destination: 'orders',
onError,
};
const consumer = traceConsumer(config)((_ctx) => async () => {
throw testError;
});
await expect(consumer({})).rejects.toThrow('Process failed');
expect(onError).toHaveBeenCalledWith(testError, expect.anything());
});
it('should support lag metrics configuration', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'events',
lagMetrics: {
getCurrentOffset: (msg) => (msg as { offset: number }).offset,
getEndOffset: () => Promise.resolve(1000),
getPartition: (msg) => (msg as { partition: number }).partition,
},
};
expect(config.lagMetrics).toBeDefined();
expect(config.lagMetrics!.getCurrentOffset).toBeDefined();
expect(config.lagMetrics!.getCurrentOffset!({ offset: 500 })).toBe(500);
});
it('should extract headers using string path', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'events',
headersFrom: 'headers',
};
expect(config.headersFrom).toBe('headers');
});
it('should extract headers using function', () => {
const config: ConsumerConfig = {
system: 'sqs',
destination: 'events',
headersFrom: (msg) => {
const m = msg as {
MessageAttributes: Record<string, { StringValue: string }>;
};
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(m.MessageAttributes || {})) {
result[k] = v.StringValue;
}
return result;
},
};
const extractor = config.headersFrom as (
msg: unknown,
) => Record<string, string> | undefined;
const headers = extractor({
MessageAttributes: {
traceparent: { StringValue: '00-abc-def-01' },
},
});
expect(headers).toEqual({ traceparent: '00-abc-def-01' });
});
});
describe('ProducerContext', () => {
it('getTraceHeaders should return W3C trace context headers', () => {
const mockCtx = createMockContext();
const headers = mockCtx.getTraceHeaders();
expect(headers.traceparent).toBeDefined();
expect(headers.traceparent).toMatch(
/^\d{2}-[a-f0-9]{32}-[a-f0-9]{16}-\d{2}$/,
);
});
it('getAllPropagationHeaders should include baggage when enabled', () => {
const mockCtx = createMockContext();
const headers = mockCtx.getAllPropagationHeaders();
expect(headers.traceparent).toBeDefined();
expect(headers.baggage).toBeDefined();
});
});
describe('ConsumerContext', () => {
it('recordDLQ should be callable with basic args', () => {
const mockCtx = createMockContext();
mockCtx.recordDLQ('Max retries exceeded', 'orders-dlq');
expect(mockCtx.recordDLQ).toHaveBeenCalledWith(
'Max retries exceeded',
'orders-dlq',
);
});
it('recordRetry should be callable', () => {
const mockCtx = createMockContext();
mockCtx.recordRetry(2, 5);
expect(mockCtx.recordRetry).toHaveBeenCalledWith(2, 5);
});
it('recordReplay should be callable', () => {
const mockCtx = createMockContext();
mockCtx.recordReplay({ replayAttempt: 1, dlqDwellTimeMs: 5000 });
expect(mockCtx.recordReplay).toHaveBeenCalledWith({
replayAttempt: 1,
dlqDwellTimeMs: 5000,
});
});
it('getProducerLinks should return links', () => {
const mockCtx = createMockContext();
const links = mockCtx.getProducerLinks();
expect(mockCtx.getProducerLinks).toHaveBeenCalled();
expect(Array.isArray(links)).toBe(true);
});
});
describe('Enhanced DLQ Handling', () => {
describe('DLQOptions types', () => {
it('should support all reason categories', () => {
const categories: DLQReasonCategory[] = [
'validation',
'processing',
'timeout',
'poison',
'unknown',
];
for (const category of categories) {
const options: DLQOptions = {
reasonCategory: category,
};
expect(options.reasonCategory).toBe(category);
}
});
it('should support full DLQ options', () => {
const error = new Error('Processing failed');
const options: DLQOptions = {
linkToProducer: true,
reasonCategory: 'processing',
attemptCount: 3,
originalError: error,
metadata: {
customField: 'value',
retryDelay: 1000,
isReplayable: true,
},
};
expect(options.linkToProducer).toBe(true);
expect(options.reasonCategory).toBe('processing');
expect(options.attemptCount).toBe(3);
expect(options.originalError).toBe(error);
expect(options.metadata?.customField).toBe('value');
});
it('should allow partial options', () => {
const options: DLQOptions = {
reasonCategory: 'validation',
};
expect(options.reasonCategory).toBe('validation');
expect(options.linkToProducer).toBeUndefined();
expect(options.attemptCount).toBeUndefined();
});
});
describe('DLQReplayOptions types', () => {
it('should support replay options with span context', () => {
const options: DLQReplayOptions = {
originalDLQSpanContext: {
traceId: '00000000000000000000000000000001',
spanId: '0000000000000002',
traceFlags: 1,
},
dlqDwellTimeMs: 3_600_000, // 1 hour
replayAttempt: 2,
};
expect(options.originalDLQSpanContext?.traceId).toBe(
'00000000000000000000000000000001',
);
expect(options.dlqDwellTimeMs).toBe(3_600_000);
expect(options.replayAttempt).toBe(2);
});
it('should allow partial replay options', () => {
const options: DLQReplayOptions = {
replayAttempt: 1,
};
expect(options.replayAttempt).toBe(1);
expect(options.originalDLQSpanContext).toBeUndefined();
});
});
describe('recordDLQ overloads', () => {
it('should support basic signature (reason only)', () => {
const mockCtx = createMockContext();
// This tests the type system accepts reason-only calls
mockCtx.recordDLQ('Processing failed');
expect(mockCtx.recordDLQ).toHaveBeenCalledWith('Processing failed');
});
it('should support reason + dlqName signature', () => {
const mockCtx = createMockContext();
mockCtx.recordDLQ('Schema validation failed', 'orders-dlq');
expect(mockCtx.recordDLQ).toHaveBeenCalledWith(
'Schema validation failed',
'orders-dlq',
);
});
it('should support reason + options signature', () => {
const mockCtx = createMockContext();
const options: DLQOptions = {
reasonCategory: 'validation',
attemptCount: 3,
};
mockCtx.recordDLQ('Invalid payload format', options);
expect(mockCtx.recordDLQ).toHaveBeenCalledWith(
'Invalid payload format',
options,
);
});
it('should support reason + dlqName + options signature', () => {
const mockCtx = createMockContext();
const error = new Error('Timeout exceeded');
const options: DLQOptions = {
reasonCategory: 'timeout',
attemptCount: 5,
originalError: error,
linkToProducer: true,
metadata: { processingTimeMs: 30_000 },
};
mockCtx.recordDLQ('Processing timeout', 'orders-dlq', options);
expect(mockCtx.recordDLQ).toHaveBeenCalledWith(
'Processing timeout',
'orders-dlq',
options,
);
});
});
describe('Poison pill detection', () => {
it('should support poison category for repeated failures', () => {
const options: DLQOptions = {
reasonCategory: 'poison',
attemptCount: 10,
metadata: {
failurePattern: 'OutOfMemoryError',
affectedConsumers: 3,
},
};
expect(options.reasonCategory).toBe('poison');
expect(options.attemptCount).toBe(10);
});
});
});
describe('MessagingSystem types', () => {
it('should support kafka', () => {
const config: ProducerConfig = { system: 'kafka', destination: 'topic' };
expect(config.system).toBe('kafka');
});
it('should support sqs', () => {
const config: ProducerConfig = { system: 'sqs', destination: 'queue' };
expect(config.system).toBe('sqs');
});
it('should support rabbitmq', () => {
const config: ProducerConfig = {
system: 'rabbitmq',
destination: 'exchange',
};
expect(config.system).toBe('rabbitmq');
});
it('should support sns', () => {
const config: ProducerConfig = {
system: 'sns',
destination: 'topic-arn',
};
expect(config.system).toBe('sns');
});
it('should support pubsub', () => {
const config: ProducerConfig = { system: 'pubsub', destination: 'topic' };
expect(config.system).toBe('pubsub');
});
it('should support custom system strings', () => {
const config: ProducerConfig = {
system: 'custom-mq',
destination: 'channel',
};
expect(config.system).toBe('custom-mq');
});
});
describe('Span naming conventions', () => {
it('producer should use system.publish destination format', () => {
const config: ProducerConfig = {
system: 'kafka',
destination: 'user-events',
};
// The span name is constructed as `${config.system}.publish ${config.destination}`
const expectedSpanName = `${config.system}.publish ${config.destination}`;
expect(expectedSpanName).toBe('kafka.publish user-events');
});
it('consumer (single) should use system.process destination format', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'user-events',
};
const expectedSpanName = `${config.system}.process ${config.destination}`;
expect(expectedSpanName).toBe('kafka.process user-events');
});
it('consumer (batch) should use system.receive destination format', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'user-events',
batchMode: true,
};
const expectedSpanName = `${config.system}.receive ${config.destination}`;
expect(expectedSpanName).toBe('kafka.receive user-events');
});
});
// =========================================================================
// Extensible Hooks ("Bring Your Own" System Support)
// =========================================================================
describe('Extensible Hooks', () => {
describe('Producer customAttributes hook', () => {
it('should call customAttributes hook with context and args', async () => {
const customAttributes = vi.fn().mockReturnValue({
'nats.subject': 'orders.created',
'nats.stream': 'ORDERS',
});
const config: ProducerConfig = {
system: 'nats' as const,
destination: 'orders.created',
customAttributes,
};
const producer = traceProducer(config)(
(_ctx) => async (event: { orderId: string }) => {
return { sent: true, orderId: event.orderId };
},
);
await producer({ orderId: 'order-123' });
expect(customAttributes).toHaveBeenCalled();
// Args should include the event
const [, args] = customAttributes.mock.calls[0];
expect(args[0]).toEqual({ orderId: 'order-123' });
});
it('should support NATS-style attributes', () => {
const config: ProducerConfig = {
system: 'nats' as const,
destination: 'orders.created',
customAttributes: (_ctx, args) => ({
'nats.subject':
(args[0] as { subject?: string })?.subject || 'default',
'nats.reply_to': (args[0] as { replyTo?: string })?.replyTo,
'nats.stream': 'ORDERS',
}),
};
expect(config.customAttributes).toBeDefined();
const attrs = config.customAttributes!(createMockContext() as never, [
{ subject: 'orders.created', replyTo: '_INBOX.reply' },
]);
expect(attrs['nats.subject']).toBe('orders.created');
expect(attrs['nats.reply_to']).toBe('_INBOX.reply');
});
it('should support Temporal-style attributes', () => {
const config: ProducerConfig = {
system: 'temporal' as const,
destination: 'orders-queue',
customAttributes: (_ctx, args) => ({
'temporal.workflow_id': (args[0] as { workflowId: string })
.workflowId,
'temporal.run_id': (args[0] as { runId: string }).runId,
'temporal.task_queue': 'orders-queue',
}),
};
expect(config.customAttributes).toBeDefined();
const attrs = config.customAttributes!(createMockContext() as never, [
{ workflowId: 'wf-123', runId: 'run-456' },
]);
expect(attrs['temporal.workflow_id']).toBe('wf-123');
expect(attrs['temporal.run_id']).toBe('run-456');
});
});
describe('Producer customHeaders hook', () => {
it('should call customHeaders hook', async () => {
const customHeaders = vi.fn().mockReturnValue({
'x-correlation-id': 'corr-123',
'x-request-id': 'req-456',
});
const config: ProducerConfig = {
system: 'kafka',
destination: 'events',
customHeaders,
};
// The customHeaders is called via getFullHeaders()
// For testing, we verify the config accepts it
expect(config.customHeaders).toBeDefined();
});
it('should support Datadog-style custom headers', () => {
const config: ProducerConfig = {
system: 'kafka',
destination: 'events',
customHeaders: (ctx) => ({
'x-datadog-trace-id': ctx.getTraceId?.() || 'unknown',
'x-datadog-parent-id': ctx.getSpanId?.() || 'unknown',
}),
};
expect(config.customHeaders).toBeDefined();
});
});
describe('Consumer customAttributes hook', () => {
it('should call customAttributes hook with context and message', async () => {
const customAttributes = vi.fn().mockReturnValue({
'cloudflare.queue_id': 'q-123',
'cloudflare.attempts': 1,
});
const config: ConsumerConfig = {
system: 'cloudflare_queues' as const,
destination: 'orders-queue',
customAttributes,
};
const consumer = traceConsumer(config)(
(_ctx) => async (msg: { id: string; attempts: number }) => {
return { processed: true, id: msg.id };
},
);
await consumer({ id: 'msg-123', attempts: 1 });
expect(customAttributes).toHaveBeenCalled();
});
it('should support Cloudflare Queue attributes', () => {
const config: ConsumerConfig = {
system: 'cloudflare_queues' as const,
destination: 'orders',
customAttributes: (_ctx, msg) => ({
'cloudflare.queue_id': (msg as { id: string }).id,
'cloudflare.timestamp_ms': Date.now(),
'cloudflare.attempts': (msg as { attempts: number }).attempts,
}),
};
expect(config.customAttributes).toBeDefined();
const attrs = config.customAttributes!(createMockContext() as never, {
id: 'msg-123',
attempts: 2,
});
expect(attrs['cloudflare.queue_id']).toBe('msg-123');
expect(attrs['cloudflare.attempts']).toBe(2);
});
it('should support Redis Streams attributes', () => {
const config: ConsumerConfig = {
system: 'redis_streams' as const,
destination: 'orders:stream',
customAttributes: (_ctx, msg) => ({
'redis.stream_id': (msg as { streamId: string }).streamId,
'redis.consumer_group': 'processors',
'redis.pending_count': (msg as { pendingCount: number })
.pendingCount,
}),
};
expect(config.customAttributes).toBeDefined();
const attrs = config.customAttributes!(createMockContext() as never, {
streamId: '1234567890-0',
pendingCount: 5,
});
expect(attrs['redis.stream_id']).toBe('1234567890-0');
});
});
describe('Consumer customContextExtractor hook', () => {
it('should support B3 format extraction', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'events',
headersFrom: 'headers',
customContextExtractor: (headers) => {
const traceId = headers['x-b3-traceid'];
const spanId = headers['x-b3-spanid'];
const sampled = headers['x-b3-sampled'] === '1';
if (!traceId || !spanId) return null;
return {
traceId,
spanId,
traceFlags: sampled ? 1 : 0,
isRemote: true,
};
},
};
expect(config.customContextExtractor).toBeDefined();
const context = config.customContextExtractor!({
'x-b3-traceid': '00000000000000000000000000000abc',
'x-b3-spanid': '00000000000def',
'x-b3-sampled': '1',
});
expect(context).not.toBeNull();
expect(context!.traceId).toBe('00000000000000000000000000000abc');
expect(context!.spanId).toBe('00000000000def');
expect(context!.traceFlags).toBe(1);
});
it('should support Datadog format extraction', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'events',
headersFrom: 'headers',
customContextExtractor: (headers) => {
const traceId = headers['x-datadog-trace-id'];
const spanId = headers['x-datadog-parent-id'];
if (!traceId || !spanId) return null;
// Convert Datadog's decimal format to OTel hex
return {
traceId: BigInt(traceId).toString(16).padStart(32, '0'),
spanId: BigInt(spanId).toString(16).padStart(16, '0'),
traceFlags: 1,
isRemote: true,
};
},
};
expect(config.customContextExtractor).toBeDefined();
const context = config.customContextExtractor!({
'x-datadog-trace-id': '123456789',
'x-datadog-parent-id': '987654321',
});
expect(context).not.toBeNull();
// 123456789 decimal = 75bcd15 hex (7 chars) → padStart(32) = 25 zeros + 7 = 32 chars
expect(context!.traceId).toBe('000000000000000000000000075bcd15');
// 987654321 decimal = 3ade68b1 hex (8 chars) → padStart(16) = 8 zeros + 8 = 16 chars
expect(context!.spanId).toBe('000000003ade68b1');
});
it('should return null for missing headers', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'events',
headersFrom: 'headers',
customContextExtractor: (headers) => {
const traceId = headers['x-b3-traceid'];
const spanId = headers['x-b3-spanid'];
if (!traceId || !spanId) return null;
return { traceId, spanId, traceFlags: 1, isRemote: true };
},
};
const context = config.customContextExtractor!({});
expect(context).toBeNull();
});
});
describe('Combined hooks usage', () => {
it('should support using all hooks together for NATS', () => {
const producerConfig: ProducerConfig = {
system: 'nats' as const,
destination: 'orders.created',
customAttributes: (_ctx, args) => ({
'nats.subject': 'orders.created',
'nats.stream': 'ORDERS',
'nats.sequence': (args[0] as { seq?: number })?.seq || 0,
}),
customHeaders: (_ctx) => ({
'Nats-Msg-Id': `msg-${Date.now()}`,
}),
};
const consumerConfig: ConsumerConfig = {
system: 'nats' as const,
destination: 'orders.created',
headersFrom: 'headers',
customAttributes: (_ctx, msg) => ({
'nats.subject': (msg as { subject: string }).subject,
'nats.redelivered':
(msg as { redelivered?: boolean })?.redelivered || false,
}),
customContextExtractor: (headers) => {
// NATS uses its own tracing headers
const traceId = headers['nats-trace-id'];
const spanId = headers['nats-span-id'];
if (!traceId || !spanId) return null;
return { traceId, spanId, traceFlags: 1, isRemote: true };
},
};
expect(producerConfig.customAttributes).toBeDefined();
expect(producerConfig.customHeaders).toBeDefined();
expect(consumerConfig.customAttributes).toBeDefined();
expect(consumerConfig.customContextExtractor).toBeDefined();
});
});
});
describe('Message Ordering Support', () => {
beforeEach(() => {
// Clear global ordering state between tests
clearOrderingState();
});
describe('OrderingConfig types', () => {
it('should define OrderingConfig interface correctly', () => {
const config: OrderingConfig = {
sequenceFrom: (msg) => (msg as { offset: number }).offset,
partitionKeyFrom: (msg) => (msg as { key: string }).key,
messageIdFrom: (msg) => (msg as { id: string }).id,
detectOutOfOrder: true,
detectDuplicates: true,
deduplicationWindowSize: 500,
onOutOfOrder: (_ctx, _info) => {},
onDuplicate: (_ctx, _id) => {},
};
expect(config.sequenceFrom).toBeDefined();
expect(config.partitionKeyFrom).toBeDefined();
expect(config.messageIdFrom).toBeDefined();
expect(config.detectOutOfOrder).toBe(true);
expect(config.detectDuplicates).toBe(true);
expect(config.deduplicationWindowSize).toBe(500);
});
it('should define OutOfOrderInfo interface correctly', () => {
const info: OutOfOrderInfo = {
currentSequence: 10,
expectedSequence: 5,
partitionKey: 'partition-1',
gap: 5,
};
expect(info.currentSequence).toBe(10);
expect(info.expectedSequence).toBe(5);
expect(info.partitionKey).toBe('partition-1');
expect(info.gap).toBe(5);
});
});
describe('Sequence tracking', () => {
it('should extract sequence number from message', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
sequenceFrom: (msg) => (msg as { offset: number }).offset,
},
};
expect(config.ordering?.sequenceFrom?.({ offset: 42 })).toBe(42);
});
it('should extract partition key from message', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
partitionKeyFrom: (msg) => (msg as { key: string }).key,
},
};
expect(config.ordering?.partitionKeyFrom?.({ key: 'user-123' })).toBe(
'user-123',
);
});
it('should extract message ID for deduplication', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
messageIdFrom: (msg) => (msg as { id: string }).id,
},
};
expect(config.ordering?.messageIdFrom?.({ id: 'msg-abc' })).toBe(
'msg-abc',
);
});
});
describe('Out-of-order detection', () => {
it('should detect out-of-order messages and call callback', async () => {
const outOfOrderCallback = vi.fn();
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
sequenceFrom: (msg) => (msg as { offset: number }).offset,
detectOutOfOrder: true,
onOutOfOrder: outOfOrderCallback,
},
};
// Verify config is set up correctly
expect(config.ordering?.detectOutOfOrder).toBe(true);
expect(config.ordering?.onOutOfOrder).toBe(outOfOrderCallback);
});
it('should calculate gap correctly for missing messages', () => {
// Test gap calculation: if we expect 5 but get 10, gap is 5
const info: OutOfOrderInfo = {
currentSequence: 10,
expectedSequence: 5,
gap: 10 - 5, // = 5 (5 messages missing)
};
expect(info.gap).toBe(5);
});
it('should calculate negative gap for out-of-order (earlier) messages', () => {
// If we expect 10 but get 5 (received earlier message), gap is -5
const info: OutOfOrderInfo = {
currentSequence: 5,
expectedSequence: 10,
gap: 5 - 10, // = -5 (message from past)
};
expect(info.gap).toBe(-5);
});
it('should track sequence per partition key', () => {
// Verify config supports partition-specific tracking
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
sequenceFrom: (msg) => (msg as { offset: number }).offset,
partitionKeyFrom: (msg) => (msg as { partition: string }).partition,
detectOutOfOrder: true,
},
};
expect(config.ordering?.partitionKeyFrom).toBeDefined();
expect(config.ordering?.sequenceFrom).toBeDefined();
});
});
describe('Duplicate detection', () => {
it('should detect duplicate messages and call callback', async () => {
const duplicateCallback = vi.fn();
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
messageIdFrom: (msg) => (msg as { id: string }).id,
detectDuplicates: true,
onDuplicate: duplicateCallback,
},
};
// Verify config is set up correctly
expect(config.ordering?.detectDuplicates).toBe(true);
expect(config.ordering?.onDuplicate).toBe(duplicateCallback);
});
it('should use custom deduplication window size', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
messageIdFrom: (msg) => (msg as { id: string }).id,
detectDuplicates: true,
deduplicationWindowSize: 500,
},
};
expect(config.ordering?.deduplicationWindowSize).toBe(500);
});
it('should default deduplication window to 1000', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
messageIdFrom: (msg) => (msg as { id: string }).id,
detectDuplicates: true,
// No deduplicationWindowSize - defaults to 1000
},
};
expect(config.ordering?.deduplicationWindowSize).toBeUndefined();
// The actual default (1000) is applied at runtime in extractAndProcessOrdering
});
});
describe('ConsumerContext ordering methods', () => {
it('should have isDuplicate() method in ConsumerContext interface', () => {
// Type check - these methods are defined in ConsumerContext interface
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
detectDuplicates: true,
},
};
// Verify the config is valid
expect(config.ordering?.detectDuplicates).toBe(true);
});
it('should have getOutOfOrderInfo() method in ConsumerContext interface', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
detectOutOfOrder: true,
},
};
expect(config.ordering?.detectOutOfOrder).toBe(true);
});
it('should have getSequenceNumber() method in ConsumerContext interface', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
sequenceFrom: (msg) => (msg as { offset: number }).offset,
},
};
expect(config.ordering?.sequenceFrom).toBeDefined();
});
it('should have getPartitionKey() method in ConsumerContext interface', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'orders',
ordering: {
partitionKeyFrom: (msg) => (msg as { key: string }).key,
},
};
expect(config.ordering?.partitionKeyFrom).toBeDefined();
});
});
describe('Combined ordering configuration', () => {
it('should support full Kafka ordering configuration', () => {
const outOfOrderCallback = vi.fn();
const duplicateCallback = vi.fn();
const config: ConsumerConfig = {
system: 'kafka',
destination: 'user-events',
consumerGroup: 'event-processor',
ordering: {
sequenceFrom: (msg) => (msg as { offset: number }).offset,
partitionKeyFrom: (msg) => (msg as { key: string }).key,
messageIdFrom: (msg) =>
(msg as { headers: { idempotencyKey: string } }).headers
.idempotencyKey,
detectOutOfOrder: true,
detectDuplicates: true,
deduplicationWindowSize: 2000,
onOutOfOrder: outOfOrderCallback,
onDuplicate: duplicateCallback,
},
};
expect(config.ordering).toBeDefined();
expect(config.ordering?.detectOutOfOrder).toBe(true);
expect(config.ordering?.detectDuplicates).toBe(true);
expect(config.ordering?.deduplicationWindowSize).toBe(2000);
});
it('should support SQS ordering configuration with message group', () => {
const config: ConsumerConfig = {
system: 'sqs',
destination: 'orders.fifo',
ordering: {
sequenceFrom: (msg) =>
Number.parseInt(
(msg as { Attributes: { SequenceNumber: string } }).Attributes
.SequenceNumber,
10,
),
partitionKeyFrom: (msg) =>
(msg as { Attributes: { MessageGroupId: string } }).Attributes
.MessageGroupId,
messageIdFrom: (msg) => (msg as { MessageId: string }).MessageId,
detectOutOfOrder: true,
detectDuplicates: true,
},
};
// Test extraction functions
const sqsMessage = {
MessageId: 'msg-123',
Attributes: {
SequenceNumber: '12345',
MessageGroupId: 'order-group-1',
},
};
expect(config.ordering?.sequenceFrom?.(sqsMessage)).toBe(12_345);
expect(config.ordering?.partitionKeyFrom?.(sqsMessage)).toBe(
'order-group-1',
);
expect(config.ordering?.messageIdFrom?.(sqsMessage)).toBe('msg-123');
});
it('should support RabbitMQ ordering configuration', () => {
const config: ConsumerConfig = {
system: 'rabbitmq',
destination: 'orders',
consumerGroup: 'order-processor',
ordering: {
sequenceFrom: (msg) =>
(msg as { properties: { headers: { 'x-sequence': number } } })
.properties.headers['x-sequence'],
messageIdFrom: (msg) =>
(msg as { properties: { messageId: string } }).properties
.messageId,
detectDuplicates: true,
},
};
const rabbitMessage = {
properties: {
messageId: 'rabbit-msg-1',
headers: {
'x-sequence': 42,
},
},
};
expect(config.ordering?.sequenceFrom?.(rabbitMessage)).toBe(42);
expect(config.ordering?.messageIdFrom?.(rabbitMessage)).toBe(
'rabbit-msg-1',
);
});
});
describe('clearOrderingState()', () => {
it('should be exported for test isolation', () => {
expect(typeof clearOrderingState).toBe('function');
});
it('should not throw when called', () => {
expect(() => clearOrderingState()).not.toThrow();
});
it('should allow multiple calls', () => {
clearOrderingState();
clearOrderingState();
clearOrderingState();
// No error = success
expect(true).toBe(true);
});
});
describe('Span attributes', () => {
it('should define expected attribute names for sequence number', () => {
// Document expected span attributes
const expectedAttributes = [
'messaging.message.sequence_number',
'messaging.message.partition_key',
'messaging.message.id',
'messaging.ordering.out_of_order',
'messaging.ordering.expected_sequence',
'messaging.ordering.gap',
'messaging.ordering.duplicate',
];
// These are the attributes set by extractAndProcessOrdering
expect(expectedAttributes).toContain(
'messaging.message.sequence_number',
);
expect(expectedAttributes).toContain('messaging.ordering.out_of_order');
expect(expectedAttributes).toContain('messaging.ordering.duplicate');
});
it('should define expected event names', () => {
const expectedEvents = ['message_out_of_order', 'message_duplicate'];
expect(expectedEvents).toContain('message_out_of_order');
expect(expectedEvents).toContain('message_duplicate');
});
});
});
describe('Consumer Group Tracking', () => {
describe('ConsumerGroupTrackingConfig types', () => {
it('should define ConsumerGroupTrackingConfig interface correctly', () => {
const config: ConsumerGroupTrackingConfig = {
memberId: 'consumer-1',
groupInstanceId: 'instance-1',
onRebalance: (_ctx, _event) => {},
onPartitionsAssigned: (_ctx, _partitions) => {},
onPartitionsRevoked: (_ctx, _partitions) => {},
trackPartitionLag: true,
trackHeartbeat: true,
heartbeatIntervalMs: 3000,
};
expect(config.memberId).toBe('consumer-1');
expect(config.groupInstanceId).toBe('instance-1');
expect(config.trackPartitionLag).toBe(true);
expect(config.trackHeartbeat).toBe(true);
expect(config.heartbeatIntervalMs).toBe(3000);
});
it('should support function-based memberId and groupInstanceId', () => {
let dynamicMemberId = 'consumer-initial';
const config: ConsumerGroupTrackingConfig = {
memberId: () => dynamicMemberId,
groupInstanceId: () => 'static-instance',
};
expect(typeof config.memberId).toBe('function');
expect((config.memberId as () => string)()).toBe('consumer-initial');
dynamicMemberId = 'consumer-updated';
expect((config.memberId as () => string)()).toBe('consumer-updated');
});
});
describe('RebalanceEvent types', () => {
it('should define RebalanceEvent interface correctly', () => {
const event: RebalanceEvent = {
type: 'assigned',
partitions: [
{ topic: 'orders', partition: 0, offset: 100 },
{ topic: 'orders', partition: 1, offset: 200 },
],
timestamp: Date.now(),
generation: 5,
memberId: 'consumer-1',
reason: 'new consumer joined',
};
expect(event.type).toBe('assigned');
expect(event.partitions).toHaveLength(2);
expect(event.generation).toBe(5);
expect(event.memberId).toBe('consumer-1');
});
it('should support all rebalance types', () => {
const types: RebalanceType[] = ['assigned', 'revoked', 'lost'];
for (const type of types) {
const event: RebalanceEvent = {
type,
partitions: [],
timestamp: Date.now(),
};
expect(event.type).toBe(type);
}
});
});
describe('PartitionAssignment types', () => {
it('should define PartitionAssignment interface correctly', () => {
const assignment: PartitionAssignment = {
topic: 'user-events',
partition: 3,
offset: 12_345,
metadata: 'leader-epoch:10',
};
expect(assignment.topic).toBe('user-events');
expect(assignment.partition).toBe(3);
expect(assignment.offset).toBe(12_345);
expect(assignment.metadata).toBe('leader-epoch:10');
});
it('should allow minimal partition assignment', () => {
const assignment: PartitionAssignment = {
topic: 'events',
partition: 0,
};
expect(assignment.topic).toBe('events');
expect(assignment.partition).toBe(0);
expect(assignment.offset).toBeUndefined();
});
});
describe('ConsumerGroupState types', () => {
it('should define ConsumerGroupState interface correctly', () => {
const state: ConsumerGroupState = {
groupId: 'order-processors',
memberId: 'consumer-abc123',
groupInstanceId: 'instance-1',
assignedPartitions: [
{ topic: 'orders', partition: 0 },
{ topic: 'orders', partition: 1 },
],
generation: 10,
isActive: true,
lastHeartbeat: Date.now(),
state: 'stable',
};
expect(state.groupId).toBe('order-processors');
expect(state.isActive).toBe(true);
expect(state.state).toBe('stable');
expect(state.assignedPartitions).toHaveLength(2);
});
it('should support all consumer group states', () => {
const states: ConsumerGroupState['state'][] = [
'stable',
'preparing_rebalance',
'completing_rebalance',
'dead',
'empty',
];
for (const groupState of states) {
const state: ConsumerGroupState = {
groupId: 'test',
assignedPartitions: [],
isActive: groupState !== 'dead',
state: groupState,
};
expect(state.state).toBe(groupState);
}
});
});
describe('PartitionLag types', () => {
it('should define PartitionLag interface correctly', () => {
const lag: PartitionLag = {
topic: 'events',
partition: 2,
currentOffset: 1000,
endOffset: 1500,
lag: 500,
timestamp: Date.now(),
};
expect(lag.topic).toBe('events');
expect(lag.partition).toBe(2);
expect(lag.lag).toBe(500);
expect(lag.currentOffset).toBe(1000);
expect(lag.endOffset).toBe(1500);
});
it('should calculate lag correctly', () => {
const currentOffset = 5000;
const endOffset = 5250;
const lag: PartitionLag = {
topic: 'orders',
partition: 0,
currentOffset,
endOffset,
lag: endOffset - currentOffset,
timestamp: Date.now(),
};
expect(lag.lag).toBe(250);
});
});
describe('Consumer config with group tracking', () => {
it('should support full Kafka consumer group configuration', () => {
const rebalanceCallback = vi.fn();
const assignedCallback = vi.fn();
const revokedCallback = vi.fn();
const config: ConsumerConfig = {
system: 'kafka',
destination: 'user-events',
consumerGroup: 'event-processor',
consumerGroupTracking: {
memberId: () => 'consumer-123',
groupInstanceId: 'static-instance-1',
onRebalance: rebalanceCallback,
onPartitionsAssigned: assignedCallback,
onPartitionsRevoked: revokedCallback,
trackPartitionLag: true,
trackHeartbeat: true,
heartbeatIntervalMs: 3000,
},
};
expect(config.consumerGroupTracking).toBeDefined();
expect(config.consumerGroupTracking?.trackPartitionLag).toBe(true);
expect(config.consumerGroupTracking?.trackHeartbeat).toBe(true);
});
it('should support minimal group tracking configuration', () => {
const config: ConsumerConfig = {
system: 'kafka',
destination: 'events',
consumerGroup: 'processors',
consumerGroupTracking: {
memberId: 'consumer-1',
},
};
expect(config.consumerGroupTracking?.memberId).toBe('consumer-1');
expect(config.consumerGroupTracking?.trackPartitionLag).toBeUndefined();
});
});
describe('Span attributes for consumer groups', () => {
it('should define expected attribute names', () => {
const expectedAttributes = [
'messaging.consumer_group.rebalance.type',
'messaging.consumer_group.rebalance.partition_count',
'messaging.consumer_group.generation',
'messagi