Skip to content

Commit 7a073b4

Browse files
feat(audience): add AudienceError class and onError callback
Adds AudienceError as the public error type on the Audience SDK, along with an onError callback in AudienceConfig that receives every error the SDK produces — transport failures, consent sync failures, and any other async errors from the queue or consent manager. AudienceError fields: - source: 'transport' | 'consent' | 'queue' - message: human-readable summary - cause: the underlying TransportError or thrown Error - status: HTTP status (0 on network failure) - endpoint: URL that failed (when applicable) DebugLogger.logError() routes AudienceError into the same console output as other debug events, so calling Audience.init with debug: true surfaces errors without the studio needing to wire onError themselves. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c1447d4 commit 7a073b4

6 files changed

Lines changed: 398 additions & 5 deletions

File tree

packages/audience/sdk/src/debug.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import type { Message } from '@imtbl/audience-core';
22
import { DebugLogger } from './debug';
33
import { LOG_PREFIX } from './config';
4+
import { AudienceError } from './types';
45

56
describe('DebugLogger', () => {
67
let logSpy: jest.SpyInstance;
78
let warnSpy: jest.SpyInstance;
9+
let errorSpy: jest.SpyInstance;
810

911
beforeEach(() => {
1012
logSpy = jest.spyOn(console, 'log').mockImplementation();
1113
warnSpy = jest.spyOn(console, 'warn').mockImplementation();
14+
errorSpy = jest.spyOn(console, 'error').mockImplementation();
1215
});
1316

1417
afterEach(() => {
1518
logSpy.mockRestore();
1619
warnSpy.mockRestore();
20+
errorSpy.mockRestore();
1721
});
1822

1923
const stubMessage: Message = {
@@ -27,15 +31,26 @@ describe('DebugLogger', () => {
2731
properties: {},
2832
};
2933

34+
const stubAudienceError = new AudienceError({
35+
code: 'FLUSH_FAILED',
36+
message: 'Flush failed with status 500',
37+
status: 500,
38+
endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages',
39+
responseBody: { code: 'INTERNAL_ERROR', detail: 'bad state' },
40+
cause: undefined,
41+
});
42+
3043
it('should not log when disabled', () => {
3144
const logger = new DebugLogger(false);
3245
logger.logEvent('track', stubMessage);
3346
logger.logFlush(true, 1);
3447
logger.logConsent('none', 'full');
3548
logger.logWarning('test');
49+
logger.logError(stubAudienceError);
3650

3751
expect(logSpy).not.toHaveBeenCalled();
3852
expect(warnSpy).not.toHaveBeenCalled();
53+
expect(errorSpy).not.toHaveBeenCalled();
3954
});
4055

4156
it('should log events when enabled', () => {
@@ -90,4 +105,41 @@ describe('DebugLogger', () => {
90105
`${LOG_PREFIX} something went wrong`,
91106
);
92107
});
108+
109+
it('should log AudienceError with structured context', () => {
110+
const logger = new DebugLogger(true);
111+
logger.logError(stubAudienceError);
112+
113+
expect(errorSpy).toHaveBeenCalledWith(
114+
`${LOG_PREFIX} AudienceError FLUSH_FAILED: Flush failed with status 500`,
115+
{
116+
status: 500,
117+
endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages',
118+
responseBody: { code: 'INTERNAL_ERROR', detail: 'bad state' },
119+
cause: undefined,
120+
},
121+
);
122+
});
123+
124+
it('should log AudienceError with CONSENT_SYNC_FAILED code', () => {
125+
const logger = new DebugLogger(true);
126+
const consentErr = new AudienceError({
127+
code: 'CONSENT_SYNC_FAILED',
128+
message: 'Consent sync failed with status 500',
129+
status: 500,
130+
endpoint: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
131+
responseBody: { code: 'INTERNAL_ERROR' },
132+
});
133+
134+
logger.logError(consentErr);
135+
136+
expect(errorSpy).toHaveBeenCalledWith(
137+
`${LOG_PREFIX} AudienceError CONSENT_SYNC_FAILED: Consent sync failed with status 500`,
138+
expect.objectContaining({
139+
status: 500,
140+
endpoint: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
141+
responseBody: { code: 'INTERNAL_ERROR' },
142+
}),
143+
);
144+
});
93145
});

packages/audience/sdk/src/debug.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ConsentLevel, Message, TransportError } from '@imtbl/audience-core';
22
import { LOG_PREFIX } from './config';
3+
import type { AudienceError } from './types';
34

45
export class DebugLogger {
56
private enabled: boolean;
@@ -44,4 +45,18 @@ export class DebugLogger {
4445
// eslint-disable-next-line no-console
4546
console.warn(`${LOG_PREFIX} ${msg}`);
4647
}
48+
49+
logError(err: AudienceError): void {
50+
if (!this.enabled) return;
51+
// eslint-disable-next-line no-console
52+
console.error(
53+
`${LOG_PREFIX} AudienceError ${err.code}: ${err.message}`,
54+
{
55+
status: err.status,
56+
endpoint: err.endpoint,
57+
responseBody: err.responseBody,
58+
cause: err.cause,
59+
},
60+
);
61+
}
4762
}

packages/audience/sdk/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Audience } from './sdk';
22
export { IdentityType } from '@imtbl/audience-core';
3-
export type { AudienceConfig } from './types';
3+
export { AudienceError } from './types';
4+
export type { AudienceConfig, AudienceErrorCode } from './types';
45
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';

packages/audience/sdk/src/sdk.test.ts

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ const fetchCalls: { url: string; init: RequestInit }[] = [];
2323
const mockFetch = jest.fn().mockImplementation(
2424
async (url: string, init?: RequestInit) => {
2525
fetchCalls.push({ url: url as string, init: init ?? {} });
26-
return { ok: true, json: async () => ({}) };
26+
return {
27+
ok: true,
28+
status: 200,
29+
headers: { get: () => 'application/json' },
30+
json: async () => ({ success: true, accepted: 1, rejected: 0 }),
31+
text: async () => '',
32+
};
2733
},
2834
);
2935
global.fetch = mockFetch;
@@ -718,6 +724,213 @@ describe('Audience', () => {
718724
});
719725
});
720726

727+
describe('onError callback', () => {
728+
// Each test in this block overrides global.fetch with a specific failing mock.
729+
// Restore the original mock after each test so later tests (reset, etc.) are
730+
// unaffected. Also use real timers so Promise microtasks settle predictably.
731+
const savedFetch = global.fetch;
732+
beforeEach(() => {
733+
jest.useRealTimers();
734+
});
735+
afterEach(() => {
736+
global.fetch = savedFetch;
737+
jest.useFakeTimers();
738+
});
739+
740+
function mockFailingFetch(status: number, body: unknown) {
741+
global.fetch = jest.fn().mockResolvedValue({
742+
ok: false,
743+
status,
744+
headers: {
745+
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null),
746+
},
747+
json: async () => body,
748+
text: async () => JSON.stringify(body),
749+
} as unknown as Response);
750+
}
751+
752+
function mockNetworkError(err: Error) {
753+
global.fetch = jest.fn().mockRejectedValue(err);
754+
}
755+
756+
it('calls onError with FLUSH_FAILED on 400', async () => {
757+
mockFailingFetch(400, { code: 'VALIDATION_ERROR', message: 'invalid identityType' });
758+
const onError = jest.fn();
759+
const sdk = createSDK({ consent: 'anonymous', onError });
760+
761+
sdk.track('invalid_event');
762+
await sdk.flush();
763+
764+
expect(onError).toHaveBeenCalledTimes(1);
765+
const err = onError.mock.calls[0][0];
766+
expect(err.code).toBe('FLUSH_FAILED');
767+
expect(err.status).toBe(400);
768+
expect(err.responseBody).toEqual({ code: 'VALIDATION_ERROR', message: 'invalid identityType' });
769+
expect(err).toBeInstanceOf(Error);
770+
771+
sdk.shutdown();
772+
});
773+
774+
it('calls onError with NETWORK_ERROR on fetch rejection', async () => {
775+
mockNetworkError(new TypeError('Failed to fetch'));
776+
const onError = jest.fn();
777+
const sdk = createSDK({ consent: 'anonymous', onError });
778+
779+
sdk.track('offline_event');
780+
await sdk.flush();
781+
782+
expect(onError).toHaveBeenCalledTimes(1);
783+
const err = onError.mock.calls[0][0];
784+
expect(err.code).toBe('NETWORK_ERROR');
785+
expect(err.status).toBe(0);
786+
expect(err.cause).toBeInstanceOf(TypeError);
787+
788+
sdk.shutdown();
789+
});
790+
791+
it('calls onError with CONSENT_SYNC_FAILED on consent PUT 500', async () => {
792+
mockFailingFetch(500, { code: 'INTERNAL_ERROR' });
793+
const onError = jest.fn();
794+
const sdk = createSDK({ consent: 'none', onError });
795+
796+
sdk.setConsent('anonymous');
797+
798+
// consent PUT is fire-and-forget; wait for microtasks to settle.
799+
// The chain is: fetch → parseBody → httpSend returns → .then → onConsentError
800+
// Each await in an async function is at least one microtask tick.
801+
for (let i = 0; i < 10; i += 1) {
802+
// eslint-disable-next-line no-await-in-loop
803+
await Promise.resolve();
804+
}
805+
806+
expect(onError).toHaveBeenCalled();
807+
const err = onError.mock.calls.find(
808+
(call: any[]) => call[0].code === 'CONSENT_SYNC_FAILED',
809+
);
810+
expect(err).toBeDefined();
811+
expect(err![0].status).toBe(500);
812+
813+
sdk.shutdown();
814+
});
815+
816+
it('swallows errors thrown by the onError callback', async () => {
817+
mockFailingFetch(400, null);
818+
const throwingCallback = jest.fn().mockImplementation(() => {
819+
throw new Error('handler boom');
820+
});
821+
const sdk = createSDK({ consent: 'anonymous', onError: throwingCallback });
822+
823+
sdk.track('will_fail');
824+
// Must not throw — SDK error handling must be resilient.
825+
await expect(sdk.flush()).resolves.toBeUndefined();
826+
827+
expect(throwingCallback).toHaveBeenCalled();
828+
829+
sdk.shutdown();
830+
});
831+
832+
it('does not call onError on success', async () => {
833+
global.fetch = jest.fn().mockResolvedValue({
834+
ok: true,
835+
status: 200,
836+
headers: {
837+
get: () => 'application/json',
838+
},
839+
json: async () => ({ success: true, accepted: 1, rejected: 0 }),
840+
text: async () => '',
841+
} as unknown as Response);
842+
843+
const onError = jest.fn();
844+
const sdk = createSDK({ consent: 'anonymous', onError });
845+
846+
sdk.track('ok_event');
847+
await sdk.flush();
848+
849+
expect(onError).not.toHaveBeenCalled();
850+
851+
sdk.shutdown();
852+
});
853+
854+
it('logs FLUSH_FAILED errors to console when debug is enabled', async () => {
855+
mockFailingFetch(500, { code: 'INTERNAL_ERROR' });
856+
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
857+
const logSpy = jest.spyOn(console, 'log').mockImplementation();
858+
try {
859+
const sdk = createSDK({ consent: 'anonymous', debug: true });
860+
861+
sdk.track('will_fail');
862+
await sdk.flush();
863+
864+
const errorCall = errorSpy.mock.calls.find(
865+
(call) => typeof call[0] === 'string'
866+
&& call[0].includes('AudienceError FLUSH_FAILED'),
867+
);
868+
expect(errorCall).toBeDefined();
869+
expect(errorCall![1]).toMatchObject({
870+
status: 500,
871+
responseBody: { code: 'INTERNAL_ERROR' },
872+
});
873+
874+
sdk.shutdown();
875+
} finally {
876+
errorSpy.mockRestore();
877+
logSpy.mockRestore();
878+
}
879+
});
880+
881+
it('logs CONSENT_SYNC_FAILED errors to console when debug is enabled', async () => {
882+
mockFailingFetch(500, { code: 'INTERNAL_ERROR' });
883+
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
884+
const logSpy = jest.spyOn(console, 'log').mockImplementation();
885+
try {
886+
const sdk = createSDK({ consent: 'none', debug: true });
887+
888+
sdk.setConsent('anonymous');
889+
890+
// consent PUT is fire-and-forget; drain microtasks so .then() runs.
891+
for (let i = 0; i < 10; i += 1) {
892+
// eslint-disable-next-line no-await-in-loop
893+
await Promise.resolve();
894+
}
895+
896+
const errorCall = errorSpy.mock.calls.find(
897+
(call) => typeof call[0] === 'string'
898+
&& call[0].includes('AudienceError CONSENT_SYNC_FAILED'),
899+
);
900+
expect(errorCall).toBeDefined();
901+
expect(errorCall![1]).toMatchObject({
902+
status: 500,
903+
});
904+
905+
sdk.shutdown();
906+
} finally {
907+
errorSpy.mockRestore();
908+
logSpy.mockRestore();
909+
}
910+
});
911+
912+
it('does not log AudienceError to console when debug is disabled', async () => {
913+
mockFailingFetch(500, { code: 'INTERNAL_ERROR' });
914+
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
915+
try {
916+
const sdk = createSDK({ consent: 'anonymous', debug: false });
917+
918+
sdk.track('will_fail');
919+
await sdk.flush();
920+
921+
const errorCall = errorSpy.mock.calls.find(
922+
(call) => typeof call[0] === 'string'
923+
&& call[0].includes('AudienceError'),
924+
);
925+
expect(errorCall).toBeUndefined();
926+
927+
sdk.shutdown();
928+
} finally {
929+
errorSpy.mockRestore();
930+
}
931+
});
932+
});
933+
721934
describe('reset', () => {
722935
it('clears pending messages from the queue', async () => {
723936
const sdk = createSDK({ consent: 'full' });

0 commit comments

Comments
 (0)