/** @jest-environment jsdom */ const addAction = jest.fn(); const ddRumAddAction = jest.fn(); import type { JourneyEvent } from '../../core/types'; import { ST_JOURNEY_GLOBAL_KEY, type StJourneyGlobal } from '../../global'; import { normalizeTagValue, sendToDatadog, setJourneyRum } from '../../sinks/datadog'; const DD_RUM_KEY = 'DD_RUM'; function clearJourneyRumFallback(): void { const st = (globalThis as Record)[ST_JOURNEY_GLOBAL_KEY] as StJourneyGlobal | undefined; if (st) { delete st.rum; } } function sampleEvent(overrides: Partial = {}): JourneyEvent { return { journey: { name: 'Call Screen Book!', team: 'JBCE', group: 'call-booking', service: 'call-screen', outcome: 'good', durationMs: 120, steps: [], ...overrides, }, }; } describe('[journey] setJourneyRum / sendToDatadog', () => { let warnSpy: jest.SpyInstance; beforeEach(() => { addAction.mockClear(); ddRumAddAction.mockClear(); clearJourneyRumFallback(); delete (globalThis as Record)[DD_RUM_KEY]; warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); }); afterEach(() => { warnSpy.mockRestore(); clearJourneyRumFallback(); delete (globalThis as Record)[DD_RUM_KEY]; }); describe('without a shared RUM instance', () => { const subject = () => sendToDatadog(sampleEvent()); test('drops the event without throwing', () => { expect(subject).not.toThrow(); expect(addAction).not.toHaveBeenCalled(); }); }); describe('with globalThis.DD_RUM', () => { beforeEach(() => { (globalThis as Record)[DD_RUM_KEY] = { addAction: ddRumAddAction }; }); test('prefers DD_RUM over setJourneyRum and the import', () => { setJourneyRum({ addAction }); warnSpy.mockClear(); sendToDatadog(sampleEvent()); expect(ddRumAddAction).toHaveBeenCalledTimes(1); expect(addAction).not.toHaveBeenCalled(); }); }); describe('when the resolved RUM stub has no addAction', () => { test('is a no-op', () => { (globalThis as Record)[DD_RUM_KEY] = {}; expect(() => sendToDatadog(sampleEvent())).not.toThrow(); expect(addAction).not.toHaveBeenCalled(); }); }); describe('when setJourneyRum is called while globalThis.DD_RUM is set', () => { test('logs a redundancy warning', () => { (globalThis as Record)[DD_RUM_KEY] = { addAction: ddRumAddAction }; setJourneyRum({ addAction }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( 'setJourneyRum() is unnecessary when globalThis.DD_RUM is set' ) ); }); }); describe('with setJourneyRum (no DD_RUM)', () => { beforeEach(() => { setJourneyRum({ addAction }); }); const subject = () => sendToDatadog(sampleEvent({ reason: 'request-error', outcome: 'bad' })); test('uses the shared RUM instance', () => { subject(); expect(addAction).toHaveBeenCalledTimes(1); expect(warnSpy).not.toHaveBeenCalled(); }); test('normalizes identity fields', () => { subject(); const ctx = addAction.mock.calls[0][1]; expect(ctx.journey.name).toBe('call_screen_book'); expect(ctx.journey.team).toBe('jbce'); }); }); test('normalizes string tags and passes numbers/booleans through', () => { setJourneyRum({ addAction }); sendToDatadog( sampleEvent({ tags: { 'plan': 'Pro Plan!!', 'job.id': 42, 'inbound': true, }, expected: ['book-job'], }) ); const ctx = addAction.mock.calls[0][1]; expect(ctx.journey.tags).toEqual({ 'plan': 'pro_plan', 'job.id': 42, 'inbound': true, }); expect(ctx.journey.expected).toEqual(['book-job']); }); test('omits reason and tags when absent', () => { setJourneyRum({ addAction }); sendToDatadog(sampleEvent()); const ctx = addAction.mock.calls[0][1]; expect(ctx.journey.reason).toBeUndefined(); expect(ctx.journey.tags).toBeUndefined(); expect(ctx.journey.expected).toBeUndefined(); }); test('collapses runs of underscores and drops trailing ones', () => { setJourneyRum({ addAction }); sendToDatadog(sampleEvent({ name: 'Foo@@@Bar___' })); expect(addAction.mock.calls[0][1].journey.name).toBe('foo_bar'); }); test('truncates long string tag values to 200 characters', () => { setJourneyRum({ addAction }); const long = `A${'b'.repeat(250)}`; sendToDatadog(sampleEvent({ tags: { note: long } })); const note = addAction.mock.calls[0][1].journey.tags.note as string; expect(note.length).toBeLessThanOrEqual(200); expect(note.endsWith('_')).toBe(false); }); test('normalizes the failure reason string', () => { setJourneyRum({ addAction }); sendToDatadog(sampleEvent({ outcome: 'bad', reason: 'Step Error!!' })); expect(addAction.mock.calls[0][1].journey.reason).toBe('step_error'); }); test('normalizeTagValue matches Datadog metric-tag rules', () => { expect(normalizeTagValue('Tenant A!')).toBe('tenant_a'); expect(normalizeTagValue('Foo@@@Bar___')).toBe('foo_bar'); }); test('drops denied tag keys and normalizes tag keys on the wire', () => { setJourneyRum({ addAction }); sendToDatadog( sampleEvent({ tags: { 'token': 'secret', 'User Email': 'Pat!', }, steps: [ { name: 'Load Draft!!', startMs: 0, durationMs: 3, outcome: 'good', attributes: { token: 'nope', phase: 'Load' }, }, ], }) ); const ctx = addAction.mock.calls[0][1]; expect(ctx.journey.tags.token).toBeUndefined(); expect(ctx.journey.tags.user_email).toBe('pat'); expect(ctx.journey.steps[0].name).toBe('load_draft'); expect(ctx.journey.steps[0].attributes.token).toBeUndefined(); expect(ctx.journey.steps[0].attributes.phase).toBe('load'); }); test('truncates long tag values on the wire', () => { setJourneyRum({ addAction }); const long = `x${'a'.repeat(250)}`; sendToDatadog(sampleEvent({ tags: { note: long } })); const ctx = addAction.mock.calls[0][1]; expect(ctx.journey.tags.note.length).toBeLessThanOrEqual(200); }); test('maps camelCase fields to Datadog snake_case wire keys', () => { setJourneyRum({ addAction }); sendToDatadog( sampleEvent({ steps: [ { name: 'load', startMs: 0, durationMs: 12, outcome: 'good', httpStatus: 502, }, ], }) ); const ctx = addAction.mock.calls[0][1]; expect(ctx.journey.duration_ms).toBe(120); /* eslint-disable @typescript-eslint/naming-convention -- assert Datadog RUM wire format */ expect(ctx.journey.steps).toEqual([ { name: 'load', start_ms: 0, duration_ms: 12, outcome: 'good', http_status: 502, }, ]); /* eslint-enable @typescript-eslint/naming-convention */ }); });