/** @jest-environment jsdom */ import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime'; import { completeJourney, journeyStep } from '../../core'; import { moduleRuntime } from '../../core/runtime'; import { getSharedPolicies } from '../../global'; import { instrumentAxios, type AxiosLikeInstance } from '../../integrations/axios'; const emitMock = jest.fn(); type RequestHandler = (config: any) => any; type ResponseOk = (response: any) => any; type ResponseErr = (error: any) => any; function createAxiosMock(): AxiosLikeInstance & { requestHandlers: RequestHandler[]; responseOkHandlers: ResponseOk[]; responseErrHandlers: ResponseErr[]; } { const request: RequestHandler[] = []; const responseOk: ResponseOk[] = []; const responseErr: ResponseErr[] = []; return { defaults: { baseURL: 'https://app.example.com' }, interceptors: { request: { use(onFulfilled) { request.push(onFulfilled); return request.length - 1; }, eject(id) { if (typeof id === 'number') { request.splice(id, 1); } }, }, response: { use(onFulfilled, onRejected) { responseOk.push(onFulfilled); if (onRejected) { responseErr.push(onRejected); } return responseOk.length - 1; }, eject(id) { if (typeof id === 'number') { responseOk.splice(id, 1); responseErr.splice(id, 1); } }, }, }, requestHandlers: request, responseOkHandlers: responseOk, responseErrHandlers: responseErr, }; } describe('[journey] instrumentAxios', () => { const def = { name: 'axios-journey', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }; useJourneyTestRuntime(emitMock); beforeEach(() => { jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000); }); afterEach(() => { jest.restoreAllMocks(); }); test('is idempotent for the same axios instance', () => { const axios = createAxiosMock(); instrumentAxios(axios); instrumentAxios(axios); expect(axios.requestHandlers).toHaveLength(1); expect(axios.responseOkHandlers).toHaveLength(1); expect(axios.responseErrHandlers).toHaveLength(1); }); test.each([undefined, null])('warns and soft-skips when axios is missing (%p)', bad => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => instrumentAxios(bad)).not.toThrow(); expect(warn).toHaveBeenCalledWith( expect.stringContaining('[journey] instrumentAxios: skipped — missing') ); expect(error).not.toHaveBeenCalled(); }); test.each([{}, { interceptors: {} }, { interceptors: { request: {}, response: {} } }])( 'errors and soft-skips when axios has invalid shape (%p)', bad => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => instrumentAxios(bad as never)).not.toThrow(); expect(error).toHaveBeenCalledWith( expect.stringContaining('[journey] instrumentAxios: skipped — invalid shape (') ); expect(error.mock.calls[0]).toHaveLength(1); expect(warn).not.toHaveBeenCalled(); } ); test('retries instrumentation after a partial wire failure', () => { const axios = createAxiosMock(); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); axios.interceptors.response.use = () => { throw new Error('response interceptor boom'); }; instrumentAxios(axios); expect(axios.requestHandlers).toHaveLength(0); axios.interceptors.response.use = (onFulfilled, onRejected) => { axios.responseOkHandlers.push(onFulfilled); if (onRejected) { axios.responseErrHandlers.push(onRejected); } return axios.responseOkHandlers.length - 1; }; instrumentAxios(axios); expect(axios.requestHandlers).toHaveLength(1); expect(axios.responseOkHandlers).toHaveLength(1); expect(error).toHaveBeenCalled(); }); test('restore ejects interceptors so a later wire does not stack', () => { const axios = createAxiosMock(); const restore = instrumentAxios(axios); expect(axios.requestHandlers).toHaveLength(1); expect(axios.responseOkHandlers).toHaveLength(1); restore(); expect(axios.requestHandlers).toHaveLength(0); expect(axios.responseOkHandlers).toHaveLength(0); instrumentAxios(axios); expect(axios.requestHandlers).toHaveLength(1); expect(axios.responseOkHandlers).toHaveLength(1); }); test('reports a successful response against the ambient step', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await journeyStep(def.name, 'book-job', () => { const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); expect(config.stamp).toBeUndefined(); expect(config.journeyStart).toBe(1_000); (globalThis.performance.now as jest.Mock).mockReturnValue(1_050); const response = axios.responseOkHandlers[0]({ status: 200, config }); expect(response.status).toBe(200); }); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('stamped stamp is JSON-safe (AxiosError.toJSON includes config)', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await journeyStep(def.name, 'book-job', step => { // Ambient stamp — AxiosError.toJSON() embeds config by reference. const ambient = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); expect(ambient.stamp).toBeUndefined(); const ambientSerialized = JSON.stringify({ message: 'Request failed', name: 'AxiosError', config: ambient, status: 500, }); expect(JSON.parse(ambientSerialized).config.stamp).toBeUndefined(); // Explicit step.stamp() stays on config, but serializes safely via toJSON. const tagged = step.stamp(); const explicit = axios.requestHandlers[0]({ url: '/CallScreen/BookJob', stamp: tagged, }); expect(explicit.stamp).toBe(tagged); expect(JSON.parse(JSON.stringify(explicit)).stamp).toBe('[JourneyStepStamp]'); }); completeJourney(def.name); }); test('marks journey bad on 5xx via the rejection interceptor', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await expect( journeyStep(def.name, 'book-job', async () => { const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await axios.responseErrHandlers[0]({ config, response: { status: 500 }, }); }) ).rejects.toMatchObject({ response: { status: 500 } }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('honors an explicit stamp from step.stamp()', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await journeyStep(def.name, 'book-job', async step => { const tagged = step.stamp(); const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob', stamp: tagged, }); expect(config.stamp).toBe(tagged); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await expect( axios.responseErrHandlers[0]({ config, response: { status: 503 }, }) ).rejects.toBeDefined(); }); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('step.stamp score can exclude a 5xx from the response body', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await expect( journeyStep(def.name, 'book-job', async step => { const tagged = step.stamp(ctx => { const data = (ctx.response as { data?: { code?: string } } | undefined)?.data; return data?.code === 'USER_DATA' ? 'exclude' : undefined; }); const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob', stamp: tagged, }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await axios.responseErrHandlers[0]({ config, response: { status: 500, data: { code: 'USER_DATA' } }, }); }) ).rejects.toMatchObject({ response: { status: 500 } }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded'); }); test('ambient auto-attribute does not run a stamp score', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await expect( journeyStep(def.name, 'book-job', async step => { step.stamp(() => 'exclude'); const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await axios.responseErrHandlers[0]({ config, response: { status: 500, data: { code: 'USER_DATA' } }, }); }) ).rejects.toMatchObject({ response: { status: 500 } }); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('step.stamp({ ignore: true }) does not score a 5xx', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await journeyStep(def.name, 'book-job', async step => { const tagged = step.stamp({ ignore: true }); const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob', stamp: tagged, }); expect(config.stamp).toBe(tagged); await expect( axios.responseErrHandlers[0]({ config, response: { status: 500 }, }) ).rejects.toMatchObject({ response: { status: 500 } }); }); completeJourney(def.name); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('ignore: true skips auto-attribute of a 5xx', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await journeyStep(def.name, 'book-job', async () => { const config = axios.requestHandlers[0]({ url: '/telemetry', ignore: true, }); expect(config.stamp).toBeUndefined(); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await expect( axios.responseErrHandlers[0]({ config, response: { status: 500 }, }) ).rejects.toMatchObject({ response: { status: 500 } }); }); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('soft ambient: overlapping steps do not auto-stamp HTTP', async () => { const other = { ...def, name: 'axios-journey-other' }; const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); moduleRuntime.startJourney(other); let releaseA!: () => void; const gateA = new Promise(r => { releaseA = r; }); const stepA = journeyStep(def.name, 'load', async () => { await gateA; }); await journeyStep(other.name, 'book', async () => { const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); expect(config.stamp).toBeUndefined(); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await expect( axios.responseErrHandlers[0]({ config, response: { status: 500 }, }) ).rejects.toMatchObject({ response: { status: 500 } }); }); releaseA(); await stepA; expect(emitMock).not.toHaveBeenCalled(); completeJourney(def.name); completeJourney(other.name); expect(emitMock).toHaveBeenCalledTimes(2); expect(emitMock.mock.calls.every(c => c[0].journey.outcome === 'good')).toBe(true); }); test('soft ambient: step.stamp() still attributes during overlap', async () => { const other = { ...def, name: 'axios-journey-tagged' }; const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); moduleRuntime.startJourney(other); let releaseA!: () => void; const gateA = new Promise(r => { releaseA = r; }); const stepA = journeyStep(def.name, 'load', async () => { await gateA; }); await expect( journeyStep(other.name, 'book', async step => { const tagged = step.stamp(); const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob', stamp: tagged, }); expect(config.stamp).toBe(tagged); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await axios.responseErrHandlers[0]({ config, response: { status: 500 }, }); }) ).rejects.toMatchObject({ response: { status: 500 } }); releaseA(); await stepA; expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.name).toBe(other.name); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); completeJourney(def.name); }); test('keeps explicit stamp attribution when a later interceptor clones config', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await expect( journeyStep(def.name, 'book-job', async step => { const tagged = step.stamp(); const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob', stamp: tagged, }); const clonedConfig = { ...config }; (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await axios.responseErrHandlers[0]({ config: clonedConfig, response: { status: 500 }, }); }) ).rejects.toMatchObject({ response: { status: 500 } }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('ignores responses with no journey step on the config', () => { const axios = createAxiosMock(); instrumentAxios(axios); // Outside a step — request interceptor finds no ambient step. const config = axios.requestHandlers[0]({ url: '/orphan' }); expect(config.stamp).toBeUndefined(); axios.responseOkHandlers[0]({ status: 500, config }); expect(emitMock).not.toHaveBeenCalled(); }); test('non-axios errors without config are rethrown without reporting', async () => { const axios = createAxiosMock(); instrumentAxios(axios); await expect(axios.responseErrHandlers[0](new Error('boom'))).rejects.toThrow('boom'); expect(emitMock).not.toHaveBeenCalled(); }); test('falls back to performance.now when journeyStart is missing', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await journeyStep(def.name, 'book-job', step => { const tagged = step.stamp(); const config = { url: '/CallScreen/BookJob', stamp: tagged, }; (globalThis.performance.now as jest.Mock).mockReturnValue(1_200); axios.responseOkHandlers[0]({ status: 200, config }); }); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('marks request-latency on a slow successful response', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney({ ...def, slowRequestMs: 50 }); await journeyStep(def.name, 'book-job', () => { const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_200); axios.responseOkHandlers[0]({ status: 200, config }); }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-latency'); }); test('does not mark bad on 4xx via the rejection interceptor', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await journeyStep(def.name, 'book-job', async () => { const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await expect( axios.responseErrHandlers[0]({ config, response: { status: 400 }, }) ).rejects.toMatchObject({ response: { status: 400 } }); }); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('marks request-aborted when axios reports a cancel', async () => { getSharedPolicies().httpAbortedRequests = 'bad'; const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await expect( journeyStep(def.name, 'book-job', async () => { const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); const cancelError: any = { config, name: 'CanceledError' }; cancelError.__CANCEL__ = true; await axios.responseErrHandlers[0](cancelError); }) ).rejects.toMatchObject({ name: 'CanceledError' }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-aborted'); }); test('network errors without a response status mark request-error', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney(def); await expect( journeyStep(def.name, 'book-job', async () => { const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await axios.responseErrHandlers[0]({ config }); }) ).rejects.toBeDefined(); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('ok interceptor tolerates a missing response config', () => { const axios = createAxiosMock(); instrumentAxios(axios); expect(() => axios.responseOkHandlers[0]({ status: 200 })).not.toThrow(); expect(emitMock).not.toHaveBeenCalled(); }); test('rejection interceptor tolerates a missing error config', async () => { const axios = createAxiosMock(); instrumentAxios(axios); await expect(axios.responseErrHandlers[0]({ response: { status: 500 } })).rejects.toEqual({ response: { status: 500 }, }); expect(emitMock).not.toHaveBeenCalled(); }); test('uses config.baseURL when building the request key', async () => { const axios = createAxiosMock(); instrumentAxios(axios); moduleRuntime.startJourney({ ...def, endpoints: [{ match: 'api.example.com/invoices', ignore: true }], }); await journeyStep(def.name, 'book-job', async () => { const config = axios.requestHandlers[0]({ url: '/invoices', baseURL: 'https://api.example.com', }); (globalThis.performance.now as jest.Mock).mockReturnValue(1_020); await expect( axios.responseErrHandlers[0]({ config, response: { status: 500 }, }) ).rejects.toBeDefined(); }); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); }); describe('[journey] Axios response correlation', () => { useJourneyTestRuntime(emitMock); test.each(['plain', 'get'])( 'captures %s headers and preserves the rejection identity', async shape => { const axios = createAxiosMock(); axios.defaults.baseURL = 'https://go.servicetitan.com'; const restore = instrumentAxios(axios); const headers = shape === 'plain' ? { 'cf-ray': '0123456789abcdef-IAD' } : { get: jest.fn().mockReturnValue('0123456789abcdef-IAD') }; let error: Error; try { await expect( journeyStep({ ...baseJourneyDef, name: 'axios-ray' }, 'save', async step => { const config = axios.requestHandlers[0]({ url: '/test', stamp: step.stamp(), }); error = Object.assign(new Error('server error'), { config, response: { config, status: 500, headers }, }); await axios.responseErrHandlers[0](error); }) ).rejects.toBe(error!); const event = emitMock.mock.calls[0][0].journey; expect(event.reason).toBe('request-error'); expect(event.steps[0].requests[0].rayId).toBe('0123456789abcdef-iad'); } finally { restore(); } } ); });