diff --git a/.eslintrc.json b/.eslintrc.json index f92368f4ec..6ef4c561ba 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -138,8 +138,7 @@ } ] } - ], - "sonarjs/todo-tag": "warn" + ] } }, { diff --git a/packages/analytics-js-common/__tests__/utilities/json.test.ts b/packages/analytics-js-common/__tests__/utilities/json.test.ts index 2de316cb25..4515274032 100644 --- a/packages/analytics-js-common/__tests__/utilities/json.test.ts +++ b/packages/analytics-js-common/__tests__/utilities/json.test.ts @@ -1,250 +1,155 @@ -import { stringifyData, getSanitizedValue } from '../../src/utilities/json'; - -const circularReferenceNotice = '[Circular Reference]'; -const bigIntNotice = '[BigInt]'; - -describe('Common Utils - JSON', () => { - describe('stringifyData', () => { - it('should stringify json excluding null values', () => { - // Define an object with null values in multiple levels along with other data types - const obj = { - key1: 'value1', - key2: null, - key3: { - key4: null, - key5: 'value5', - key10: undefined, - key6: { - key7: null, - key8: 'value8', - key9: undefined, - key11: [1, 2, null, 3], +import { clone } from 'ramda'; +import { stringifyWithoutCircular } from '../../src/utilities/json'; + +const identifyTraitsPayloadMock: Record = { + firstName: 'Dummy Name', + phone: '1234567890', + email: 'dummy@email.com', + custom_flavor: 'chocolate', + custom_date: new Date(2022, 1, 21, 0, 0, 0), + address: [ + { + label: 'office', + city: 'Brussels', + country: 'Belgium', + }, + { + label: 'home', + city: 'Kolkata', + country: 'India', + nested: { + type: 'flat', + rooms: [ + { + name: 'kitchen', + size: 'small', }, - }, - }; - - expect(stringifyData(obj)).toBe( - '{"key1":"value1","key3":{"key5":"value5","key6":{"key8":"value8","key11":[1,2,null,3]}}}', - ); - }); - - it('should stringify json without excluding null values', () => { - // Define an object with null values in multiple levels along with other data types - const obj = { - key1: 'value1', - key2: null, - key3: { - key4: null, - key5: 'value5', - key6: { - key7: null, - key8: 'value8', + { + // eslint-disable-next-line sonarjs/no-duplicate-string + name: 'living room', + size: 'large', }, - }, - }; - - expect(stringifyData(obj, false)).toBe( - '{"key1":"value1","key2":null,"key3":{"key4":null,"key5":"value5","key6":{"key7":null,"key8":"value8"}}}', - ); - }); - - it('should stringify json after excluding certain keys', () => { - // Define an object with null values in multiple levels along with other data types - const obj = { - key1: 'value1', - key2: null, - key3: { - key4: null, - key5: 'value5', - key6: { - key7: null, - key8: 'value8', + { + name: 'bedroom', + size: 'large', }, - }, - }; + ], + }, + }, + { + label: 'work', + city: 'Kolkata', + country: 'India', + }, + ], + stringArray: ['string1', 'string2', 'string3'], + numberArray: [1, 2, 3], +}; - const keysToExclude = ['key1', 'key7']; +const circularReferenceNotice = '[Circular Reference]'; - expect(stringifyData(obj, true, keysToExclude)).toBe( - '{"key3":{"key5":"value5","key6":{"key8":"value8"}}}', - ); +describe('Common Utils - JSON', () => { + describe('stringifyWithoutCircular', () => { + it('should stringify json with circular references', () => { + const objWithCircular = clone(identifyTraitsPayloadMock); + objWithCircular.myself = objWithCircular; - expect(stringifyData(obj, false, keysToExclude)).toBe( - '{"key2":null,"key3":{"key4":null,"key5":"value5","key6":{"key8":"value8"}}}', - ); + const json = stringifyWithoutCircular(objWithCircular); + expect(json).toContain(circularReferenceNotice); }); - }); - describe('getSanitizedValue', () => { - const mockLogger = { - warn: jest.fn(), - }; - - it('should sanitize json without excluding null and undefined values', () => { - const obj = { - a: 1, - b: null, - c: 'value', - d: undefined, - i: () => {}, - e: { - f: 2, - g: null, - h: 'value', - i: undefined, - j: { - k: 3, - l: null, - m: 'value', - n: [1, 2, 3], - o: [1, 2, 3, new Date()], - s: () => {}, - }, - }, - }; + it('should stringify json with circular references and exclude null values', () => { + const objWithCircular = clone(identifyTraitsPayloadMock); + objWithCircular.myself = objWithCircular; + objWithCircular.keyToExclude = null; + objWithCircular.keyToNotExclude = ''; - expect(getSanitizedValue(obj)).toEqual(obj); + const json = stringifyWithoutCircular(objWithCircular, true); + expect(json).toContain(circularReferenceNotice); + expect(json).not.toContain('keyToExclude'); + expect(json).toContain('keyToNotExclude'); }); - it('should sanitize json after replacing BigInt and circular references', () => { - const obj = { - a: BigInt(1), - b: undefined, - c: 'value', - d: { - e: BigInt(2), - f: undefined, - g: 'value', - h: { - i: BigInt(3), - j: undefined, - k: 'value', - }, - }, - }; + it('should stringify json with out circular references', () => { + const objWithoutCircular = clone(identifyTraitsPayloadMock); + objWithoutCircular.myself = {}; - obj.myself = obj; - obj.d.myself2 = obj.d; - obj.d.h.myself3 = obj.d; - - expect(getSanitizedValue(obj, mockLogger)).toEqual({ - a: bigIntNotice, - c: 'value', - b: undefined, - myself: circularReferenceNotice, - d: { - e: bigIntNotice, - g: 'value', - f: undefined, - myself2: circularReferenceNotice, - h: { - i: bigIntNotice, - k: 'value', - j: undefined, - myself3: circularReferenceNotice, - }, - }, - }); - - expect(mockLogger.warn).toHaveBeenCalledTimes(6); - - expect(mockLogger.warn).toHaveBeenNthCalledWith( - 1, - 'JSON:: A bad data (like circular reference, BigInt) has been detected in the object and the property "a" has been dropped from the output.', - ); - - expect(mockLogger.warn).toHaveBeenNthCalledWith( - 2, - 'JSON:: A bad data (like circular reference, BigInt) has been detected in the object and the property "e" has been dropped from the output.', - ); - - expect(mockLogger.warn).toHaveBeenNthCalledWith( - 3, - 'JSON:: A bad data (like circular reference, BigInt) has been detected in the object and the property "i" has been dropped from the output.', - ); - - expect(mockLogger.warn).toHaveBeenNthCalledWith( - 4, - 'JSON:: A bad data (like circular reference, BigInt) has been detected in the object and the property "myself3" has been dropped from the output.', - ); - - expect(mockLogger.warn).toHaveBeenNthCalledWith( - 5, - 'JSON:: A bad data (like circular reference, BigInt) has been detected in the object and the property "myself2" has been dropped from the output.', - ); - - expect(mockLogger.warn).toHaveBeenNthCalledWith( - 6, - 'JSON:: A bad data (like circular reference, BigInt) has been detected in the object and the property "myself" has been dropped from the output.', - ); + const json = stringifyWithoutCircular(objWithoutCircular); + expect(json).not.toContain(circularReferenceNotice); }); - it('should sanitize json even if it contains reused objects', () => { - const obj = { - a: BigInt(1), - b: undefined, - c: 'value', - d: { - e: BigInt(2), - f: undefined, - g: 'value', - h: { - i: BigInt(3), - j: undefined, - k: 'value', - }, - }, - }; - + it('should stringify json with out circular references and reused objects', () => { + const objWithoutCircular = clone(identifyTraitsPayloadMock); const reusableArray = [1, 2, 3]; const reusableObject = { dummy: 'val' }; - obj.reused = reusableArray; - obj.reusedAgain = [1, 2, reusableArray]; - obj.reusedObj = reusableObject; - obj.reusedObjAgain = { reused: reusableObject }; - - obj.d.reused = reusableArray; - obj.d.h.reused = reusableObject; - obj.d.h.reusedAgain = [1, 2, reusableArray]; - - expect(getSanitizedValue(obj)).toEqual({ - a: bigIntNotice, - c: 'value', - b: undefined, - reused: [1, 2, 3], - reusedAgain: [1, 2, [1, 2, 3]], - reusedObj: { dummy: 'val' }, - reusedObjAgain: { reused: { dummy: 'val' } }, - d: { - e: bigIntNotice, - g: 'value', - f: undefined, - reused: [1, 2, 3], - h: { - i: bigIntNotice, - k: 'value', - j: undefined, - reused: { dummy: 'val' }, - reusedAgain: [1, 2, [1, 2, 3]], - }, - }, - }); + objWithoutCircular.reused = reusableArray; + objWithoutCircular.reusedAgain = [1, 2, reusableArray]; + objWithoutCircular.reusedObj = reusableObject; + objWithoutCircular.reusedObjAgain = { reused: reusableObject }; + objWithoutCircular.reusedObjAgainWithItself = { reused: reusableObject }; + + const json = stringifyWithoutCircular(objWithoutCircular); + expect(json).not.toContain(circularReferenceNotice); }); - it('should sanitize all data types', () => { + it('should stringify json with circular references for nested circular objects', () => { + const objWithoutCircular = clone(identifyTraitsPayloadMock); + const reusableObject = { dummy: 'val' }; + const objWithCircular = clone(reusableObject); + objWithCircular.myself = objWithCircular; + objWithoutCircular.reusedObjAgainWithItself = { reused: reusableObject }; + objWithoutCircular.objWithCircular = objWithCircular; + + const json = stringifyWithoutCircular(objWithoutCircular); + expect(json).toContain(circularReferenceNotice); + }); + + it('should stringify json for all input types', () => { const array = [1, 2, 3]; const number = 1; const string = ''; const object = {}; const date = new Date(2023, 1, 20, 0, 0, 0); - expect(getSanitizedValue(array)).toEqual(array); - expect(getSanitizedValue(number)).toEqual(number); - expect(getSanitizedValue(string)).toEqual(string); - expect(getSanitizedValue(object)).toEqual(object); - expect(getSanitizedValue(date)).toEqual(date); - expect(getSanitizedValue(null)).toEqual(null); - expect(getSanitizedValue(undefined)).toEqual(undefined); + const arrayJson = stringifyWithoutCircular(array); + const numberJson = stringifyWithoutCircular(number); + const stringJson = stringifyWithoutCircular(string); + const objectJson = stringifyWithoutCircular(object); + const dateJson = stringifyWithoutCircular(date); + const nullJson = stringifyWithoutCircular(null); + const undefinedJson = stringifyWithoutCircular(undefined); + + expect(arrayJson).toBe('[1,2,3]'); + expect(numberJson).toBe('1'); + expect(stringJson).toBe('""'); + expect(objectJson).toBe('{}'); + expect(dateJson).toBe('"2023-02-19T18:30:00.000Z"'); + expect(nullJson).toBe('null'); + expect(undefinedJson).toBe(undefined); + }); + + it('should stringify json after removing the exclude keys', () => { + const objWithoutCircular = clone(identifyTraitsPayloadMock); + + const json = stringifyWithoutCircular(objWithoutCircular, true, ['size', 'city']); + expect(json).not.toContain('size'); + expect(json).not.toContain('city'); + }); + + it('should return null for input containing BigInt values', () => { + const mockLogger = { + warn: jest.fn(), + }; + + const objWithBigInt = { + bigInt: BigInt(9007199254740991), + }; + const json = stringifyWithoutCircular(objWithBigInt, false, [], mockLogger); + expect(json).toBe(null); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to convert the value to a JSON string.', + new TypeError('Do not know how to serialize a BigInt'), + ); }); }); }); diff --git a/packages/analytics-js-common/src/constants/integrations/integration_cname.js b/packages/analytics-js-common/src/constants/integrations/integration_cname.js index 1b3c6815d2..57ab51ec48 100644 --- a/packages/analytics-js-common/src/constants/integrations/integration_cname.js +++ b/packages/analytics-js-common/src/constants/integrations/integration_cname.js @@ -163,7 +163,7 @@ const commonNames = { ...Sprig, ...SpotifyPixel, ...XPixel, - ...Gainsight_PX, + ...Gainsight_PX }; export { commonNames }; diff --git a/packages/analytics-js-common/src/constants/logMessages.ts b/packages/analytics-js-common/src/constants/logMessages.ts index fbb06c6d8a..91eb20f04a 100644 --- a/packages/analytics-js-common/src/constants/logMessages.ts +++ b/packages/analytics-js-common/src/constants/logMessages.ts @@ -9,13 +9,16 @@ const SCRIPT_LOAD_ERROR = (id: string, url: string): string => const SCRIPT_LOAD_TIMEOUT_ERROR = (id: string, url: string, timeout: number): string => `A timeout of ${timeout} ms occurred while trying to load the script with id "${id}" from URL "${url}".`; -const BAD_DATA_WARNING = (context: string, key: string): string => - `${context}${LOG_CONTEXT_SEPARATOR}A bad data (like circular reference, BigInt) has been detected in the object and the property "${key}" has been dropped from the output.`; +const CIRCULAR_REFERENCE_WARNING = (context: string, key: string): string => + `${context}${LOG_CONTEXT_SEPARATOR}A circular reference has been detected in the object and the property "${key}" has been dropped from the output.`; + +const JSON_STRINGIFY_WARNING = `Failed to convert the value to a JSON string.`; export { LOG_CONTEXT_SEPARATOR, SCRIPT_ALREADY_EXISTS_ERROR, SCRIPT_LOAD_ERROR, SCRIPT_LOAD_TIMEOUT_ERROR, - BAD_DATA_WARNING, -}; + CIRCULAR_REFERENCE_WARNING, + JSON_STRINGIFY_WARNING +} diff --git a/packages/analytics-js-common/src/utilities/checks.ts b/packages/analytics-js-common/src/utilities/checks.ts index fc973a34c5..afb4def565 100644 --- a/packages/analytics-js-common/src/utilities/checks.ts +++ b/packages/analytics-js-common/src/utilities/checks.ts @@ -35,13 +35,6 @@ const isUndefined = (value: any): value is undefined => typeof value === 'undefi */ const isNullOrUndefined = (value: any): boolean => isNull(value) || isUndefined(value); -/** - * Checks if the input is a BigInt - * @param value input value - * @returns True if the input is a BigInt - */ -const isBigInt = (value: any): value is bigint => typeof value === 'bigint'; - /** * A function to check given value is defined * @param value input value @@ -81,5 +74,4 @@ export { isDefined, isDefinedAndNotNull, isDefinedNotNullAndNotEmptyString, - isBigInt, }; diff --git a/packages/analytics-js-common/src/utilities/errors.ts b/packages/analytics-js-common/src/utilities/errors.ts index 59bf78b1ca..5e806a6dce 100644 --- a/packages/analytics-js-common/src/utilities/errors.ts +++ b/packages/analytics-js-common/src/utilities/errors.ts @@ -1,5 +1,5 @@ import { isTypeOfError } from './checks'; -import { stringifyData } from './json'; +import { stringifyWithoutCircular } from './json'; /** * Get mutated error with issue prepended to error message @@ -10,7 +10,7 @@ import { stringifyData } from './json'; const getMutatedError = (err: any, issue: string): Error => { let finalError = err; if (!isTypeOfError(err)) { - finalError = new Error(`${issue}: ${stringifyData(err as Record)}`); + finalError = new Error(`${issue}: ${stringifyWithoutCircular(err as Record)}`); } else { (finalError as Error).message = `${issue}: ${err.message}`; } diff --git a/packages/analytics-js-common/src/utilities/json.ts b/packages/analytics-js-common/src/utilities/json.ts index 2a12b96ad0..128d9edc55 100644 --- a/packages/analytics-js-common/src/utilities/json.ts +++ b/packages/analytics-js-common/src/utilities/json.ts @@ -1,103 +1,69 @@ -import { BAD_DATA_WARNING } from '../constants/logMessages'; import type { ILogger } from '../types/Logger'; import type { Nullable } from '../types/Nullable'; -import { isBigInt, isNull } from './checks'; -import { isObjectLiteralAndNotNull } from './object'; - -const JSON_UTIL = 'JSON'; - -/** - * Utility method for JSON stringify object excluding null values & circular references - * - * @param {*} value input value - * @param {boolean} excludeNull optional flag to exclude null values - * @param {string[]} excludeKeys optional array of keys to exclude - * @returns string - */ -const stringifyData = | any[] | number | string>( - value?: Nullable, - excludeNull: boolean = true, - excludeKeys: string[] = [], -): string => - JSON.stringify(value, (key: string, value: any): any => { - if ((excludeNull && isNull(value)) || excludeKeys.includes(key)) { +import { isNull, isNullOrUndefined } from './checks'; +import { CIRCULAR_REFERENCE_WARNING, JSON_STRINGIFY_WARNING } from '../constants/logMessages'; + +const JSON_STRINGIFY = 'JSONStringify'; + +const getCircularReplacer = ( + excludeNull?: boolean, + excludeKeys?: string[], + logger?: ILogger, +): ((key: string, value: any) => any) => { + const ancestors: any[] = []; + + // Here we do not want to use arrow function to use "this" in function context + // eslint-disable-next-line func-names + return function (key, value): any { + if (excludeKeys?.includes(key)) { return undefined; } - return value; - }); -const getReplacer = (logger?: ILogger): ((key: string, value: any) => any) => { - const ancestors: any[] = []; // Array to track ancestor objects + if (excludeNull && isNullOrUndefined(value)) { + return undefined; + } - // Using a regular function to use `this` for the parent context - return function replacer(key, value): any { - if (isBigInt(value)) { - logger?.warn(BAD_DATA_WARNING(JSON_UTIL, key)); - return '[BigInt]'; // Replace BigInt values + if (typeof value !== 'object' || isNull(value)) { + return value; } // `this` is the object that value is contained in, i.e., its direct parent. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-next-line while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { - ancestors.pop(); // Remove ancestors that are no longer part of the chain + ancestors.pop(); } - // Check for circular references (if the value is already in the ancestors) if (ancestors.includes(value)) { - logger?.warn(BAD_DATA_WARNING(JSON_UTIL, key)); + logger?.warn(CIRCULAR_REFERENCE_WARNING(JSON_STRINGIFY, key)); return '[Circular Reference]'; } - // Add current value to ancestors ancestors.push(value); - return value; }; }; -const traverseWithThis = (obj: any, replacer: (key: string, value: any) => any): any => { - // Create a new result object or array - const result = Array.isArray(obj) ? [] : {}; - - // Traverse object properties or array elements - // eslint-disable-next-line no-restricted-syntax - for (const key in obj) { - if (Object.hasOwnProperty.call(obj, key)) { - const value = obj[key]; - - // Recursively apply the replacer and traversal - const sanitizedValue = replacer.call(obj, key, value); - - // If the value is an object or array, continue traversal - if (isObjectLiteralAndNotNull(sanitizedValue) || Array.isArray(sanitizedValue)) { - (result as any)[key] = traverseWithThis(sanitizedValue, replacer); - } else { - (result as any)[key] = sanitizedValue; - } - } - } - - return result; -}; - /** - * Recursively traverses an object similar to JSON.stringify, - * sanitizing BigInts and circular references - * @param value Input object - * @param logger Logger instance - * @returns Sanitized value + * Utility method for JSON stringify object excluding null values & circular references + * + * @param {*} value input + * @param {boolean} excludeNull if it should exclude nul or not + * @param {function} logger optional logger methods for warning + * @returns string */ -const getSanitizedValue = (value: T, logger?: ILogger): T => { - const replacer = getReplacer(logger); - - // This is needed for registering the first ancestor - const newValue = replacer.call(value, '', value); - - if (isObjectLiteralAndNotNull(value) || Array.isArray(value)) { - return traverseWithThis(value, replacer); +const stringifyWithoutCircular = | any[] | number | string>( + value?: Nullable, + excludeNull?: boolean, + excludeKeys?: string[], + logger?: ILogger, +): Nullable => { + try { + return JSON.stringify(value, getCircularReplacer(excludeNull, excludeKeys, logger)); + } catch (err) { + logger?.warn(JSON_STRINGIFY_WARNING, err); + return null; } - return newValue; }; -export { stringifyData, getSanitizedValue }; +export { stringifyWithoutCircular }; diff --git a/packages/analytics-js-common/src/utilities/object.ts b/packages/analytics-js-common/src/utilities/object.ts index 366e605a00..276aac1184 100644 --- a/packages/analytics-js-common/src/utilities/object.ts +++ b/packages/analytics-js-common/src/utilities/object.ts @@ -9,15 +9,13 @@ const getValueByPath = (obj: Record, keyPath: string): any => { const hasValueByPath = (obj: Record, path: string): boolean => Boolean(getValueByPath(obj, path)); -const isObject = (value: any): value is object => typeof value === 'object'; - /** * Checks if the input is an object literal or built-in object type and not null * @param value Input value * @returns true if the input is an object and not null */ const isObjectAndNotNull = (value: any): value is object => - !isNull(value) && isObject(value) && !Array.isArray(value); + !isNull(value) && typeof value === 'object' && !Array.isArray(value); /** * Checks if the input is an object literal and not null @@ -118,5 +116,4 @@ export { removeUndefinedValues, removeUndefinedAndNullValues, getObjectValues, - isObject, }; diff --git a/packages/analytics-js-cookies/__tests__/cookieUtilities.test.ts b/packages/analytics-js-cookies/__tests__/cookieUtilities.test.ts index 14691053c9..538303972c 100644 --- a/packages/analytics-js-cookies/__tests__/cookieUtilities.test.ts +++ b/packages/analytics-js-cookies/__tests__/cookieUtilities.test.ts @@ -173,6 +173,11 @@ describe('Cookie Utilities', () => { ); }); + it('should return null if the input cannot be json stringified', () => { + const inputVal = { testKey: BigInt(123) }; + expect(getEncryptedValueBrowser(inputVal)).toBeNull(); + }); + it('should return encoded value if the input contains unicode characters', () => { const inputVal = { testKey: '✓' }; expect(getEncryptedValueBrowser(inputVal)).toBe('RS_ENC_v3_eyJ0ZXN0S2V5Ijoi4pyTIn0='); @@ -244,6 +249,11 @@ describe('Cookie Utilities', () => { ); }); + it('should return null if the input cannot be json stringified', () => { + const inputVal = { testKey: BigInt(123) }; + expect(getEncryptedValue(inputVal)).toBeNull(); + }); + it('should return encoded value if the input contains unicode characters', () => { const inputVal = { testKey: '✓' }; expect(getEncryptedValue(inputVal)).toBe('RS_ENC_v3_eyJ0ZXN0S2V5Ijoi4pyTIn0='); diff --git a/packages/analytics-js-cookies/src/cookiesUtilities.ts b/packages/analytics-js-cookies/src/cookiesUtilities.ts index 68652e7e92..8d7254b1bf 100644 --- a/packages/analytics-js-cookies/src/cookiesUtilities.ts +++ b/packages/analytics-js-cookies/src/cookiesUtilities.ts @@ -2,7 +2,7 @@ import { fromBase64, toBase64 } from '@rudderstack/analytics-js-common/utilities import type { Nullable } from '@rudderstack/analytics-js-common/types/Nullable'; import { isNull, isNullOrUndefined } from '@rudderstack/analytics-js-common/utilities/checks'; import type { ApiObject } from '@rudderstack/analytics-js-common/types/ApiObject'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import { COOKIE_KEYS, ENCRYPTION_PREFIX_V3 } from './constants/cookies'; import { cookie } from './component-cookie'; @@ -13,7 +13,7 @@ const getEncryptedValueInternal = ( ): Nullable => { const fallbackValue = null; try { - const strValue = stringifyData(value, false); + const strValue = stringifyWithoutCircular(value, false); if (isNull(strValue)) { return null; } diff --git a/packages/analytics-js-plugins/__tests__/bugsnag/utils.test.ts b/packages/analytics-js-plugins/__tests__/bugsnag/utils.test.ts index e83671df87..6d58890430 100644 --- a/packages/analytics-js-plugins/__tests__/bugsnag/utils.test.ts +++ b/packages/analytics-js-plugins/__tests__/bugsnag/utils.test.ts @@ -695,6 +695,13 @@ describe('Bugsnag utilities', () => { }, ['key4', 'key6'], // excluded keys ], + [ + { + someKey: BigInt(123), + }, + undefined, + [], + ], ]; it.each(tcData)('should convert signals to JSON %#', (input, expected, excludes) => { diff --git a/packages/analytics-js-plugins/__tests__/errorReporting/utils.test.ts b/packages/analytics-js-plugins/__tests__/errorReporting/utils.test.ts index 0e0ecb8dbe..1853279900 100644 --- a/packages/analytics-js-plugins/__tests__/errorReporting/utils.test.ts +++ b/packages/analytics-js-plugins/__tests__/errorReporting/utils.test.ts @@ -275,6 +275,13 @@ describe('Error Reporting utilities', () => { }, ['key4', 'key6'], // excluded keys ], + [ + { + someKey: BigInt(123), + }, + {}, + [], + ], ]; it.each(tcData)('should convert signals to JSON %#', (input, expected, excludes) => { diff --git a/packages/analytics-js-plugins/__tests__/utilities/queue.test.ts b/packages/analytics-js-plugins/__tests__/utilities/queue.test.ts index f98262da13..0f60b8eac1 100644 --- a/packages/analytics-js-plugins/__tests__/utilities/queue.test.ts +++ b/packages/analytics-js-plugins/__tests__/utilities/queue.test.ts @@ -158,6 +158,92 @@ describe('Queue Plugins Utilities', () => { '{"channel":"test","type":"track","anonymousId":"test","context":{"traits":{"trait_1":"trait_1","trait_2":"trait_2"},"sessionId":1,"sessionStart":true,"ua-ch":{"test":"test"},"app":{"name":"test","version":"test","namespace":"test"},"library":{"name":"test","version":"test"},"userAgent":"test","os":{"name":"test","version":"test"},"locale":"test","screen":{"width":1,"height":1,"density":1,"innerWidth":1,"innerHeight":1}},"originalTimestamp":"test","integrations":{"All":true},"messageId":"test","previousId":"test","sentAt":"test","category":"test","groupId":"test","event":"test","userId":"test","properties":{"test":"test"}}', ); }); + + it('should return string with circular dependencies replaced with static string', () => { + const event = { + channel: 'test', + type: 'track', + anonymousId: 'test', + context: { + traits: { + trait_1: 'trait_1', + trait_2: 'trait_2', + }, + sessionId: 1, + sessionStart: true, + consentManagement: { + deniedConsentIds: ['1', '2', '3'], + }, + 'ua-ch': { + test: 'test', + }, + app: { + name: 'test', + version: 'test', + namespace: 'test', + }, + library: { + name: 'test', + version: 'test', + }, + userAgent: 'test', + os: { + name: 'test', + version: 'test', + }, + locale: 'test', + screen: { + width: 1, + height: 1, + density: 1, + innerWidth: 1, + innerHeight: 1, + }, + campaign: { + source: 'test', + medium: 'test', + name: 'test', + term: 'test', + content: 'test', + }, + }, + originalTimestamp: 'test', + integrations: { + All: true, + }, + messageId: 'test', + previousId: 'test', + sentAt: 'test', + category: 'test', + traits: { + trait_1: 'trait_11', + trait_2: 'trait_12', + }, + groupId: 'test', + event: 'test', + userId: 'test', + properties: { + test: 'test', + }, + } as RudderEvent; + + event.traits = event.context.traits; + event.context.traits.newTraits = event.traits; + + expect(getDeliveryPayload(event, mockLogger)).toContain('[Circular Reference]'); + }); + + it('should return null if the payload cannot be stringified', () => { + const event = { + channel: 'test', + type: 'track', + properties: { + someBigInt: BigInt(9007199254740991), + }, + } as unknown as RudderEvent; + + expect(getDeliveryPayload(event, mockLogger)).toBeNull(); + }); }); describe('validateEventPayloadSize', () => { @@ -215,5 +301,27 @@ describe('Queue Plugins Utilities', () => { expect(mockLogger.warn).not.toHaveBeenCalled(); }); + + it('should log a warning if the payload size could not be calculated', () => { + const event = { + channel: 'test', + type: 'track', + traits: { + trait_1: 'trait_1', + trait_2: 'trait_2', + }, + userId: 'test', + properties: { + test: 'test', + test1: BigInt(9007199254740991), + }, + } as unknown as RudderEvent; + + validateEventPayloadSize(event, mockLogger); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'QueueUtilities:: Failed to validate event payload size. Please make sure that the event payload is within the size limit and is a valid JSON object.', + ); + }); }); }); diff --git a/packages/analytics-js-plugins/__tests__/xhrQueue/utilities.test.ts b/packages/analytics-js-plugins/__tests__/xhrQueue/utilities.test.ts index 7cfb073bac..9b1a15bdf4 100644 --- a/packages/analytics-js-plugins/__tests__/xhrQueue/utilities.test.ts +++ b/packages/analytics-js-plugins/__tests__/xhrQueue/utilities.test.ts @@ -328,5 +328,61 @@ describe('xhrQueue Plugin Utilities', () => { '{"batch":[{"channel":"test","type":"track","anonymousId":"test","properties":{"test":"test"}},{"channel":"test","type":"track","anonymousId":"test","properties":{"test1":"test1","test3":{}}}],"sentAt":"2021-01-01T00:00:00.000Z"}', ); }); + + it('should return string with circular dependencies replaced with static string', () => { + const events = [ + { + channel: 'test', + type: 'track', + anonymousId: 'test', + userId: null, + properties: { + test: 'test', + test2: null, + }, + } as unknown as RudderEvent, + { + channel: 'test', + type: 'track', + anonymousId: 'test', + groupId: null, + properties: { + test1: 'test1', + test3: { + test4: null, + }, + }, + } as unknown as RudderEvent, + ]; + + events[1].properties.test5 = events[1]; + + expect(getBatchDeliveryPayload(events, currentTime, mockLogger)).toContain( + '[Circular Reference]', + ); + }); + + it('should return null if the payload cannot be stringified', () => { + const events = [ + { + channel: 'test', + type: 'track', + anonymousId: 'test', + properties: { + someBigInt: BigInt(9007199254740991), + }, + } as unknown as RudderEvent, + { + channel: 'test', + type: 'track', + anonymousId: 'test', + properties: { + test1: 'test1', + }, + } as unknown as RudderEvent, + ]; + + expect(getBatchDeliveryPayload(events, currentTime, mockLogger)).toBeNull(); + }); }); }); diff --git a/packages/analytics-js-plugins/src/beaconQueue/utilities.ts b/packages/analytics-js-plugins/src/beaconQueue/utilities.ts index 2e1e83c5ef..049196b2cf 100644 --- a/packages/analytics-js-plugins/src/beaconQueue/utilities.ts +++ b/packages/analytics-js-plugins/src/beaconQueue/utilities.ts @@ -31,7 +31,7 @@ const getBatchDeliveryPayload = ( }; try { - const blobPayload = json.stringifyData(data); + const blobPayload = json.stringifyWithoutCircular(data, true); const blobOptions: BlobPropertyBag = { type: 'text/plain' }; if (blobPayload) { diff --git a/packages/analytics-js-plugins/src/bugsnag/constants.ts b/packages/analytics-js-plugins/src/bugsnag/constants.ts index 5b45e385e7..931af3da70 100644 --- a/packages/analytics-js-plugins/src/bugsnag/constants.ts +++ b/packages/analytics-js-plugins/src/bugsnag/constants.ts @@ -31,7 +31,6 @@ const APP_STATE_EXCLUDE_KEYS = [ 'instance', // destination instance objects 'eventBuffer', // pre-load event buffer (may contain PII) 'traits', - 'authToken', ]; const BUGSNAG_PLUGIN = 'BugsnagPlugin'; diff --git a/packages/analytics-js-plugins/src/bugsnag/utils.ts b/packages/analytics-js-plugins/src/bugsnag/utils.ts index 8407876b4c..2dfa8b8aff 100644 --- a/packages/analytics-js-plugins/src/bugsnag/utils.ts +++ b/packages/analytics-js-plugins/src/bugsnag/utils.ts @@ -65,8 +65,8 @@ const isRudderSDKError = (event: BugsnagLib.Report) => { }; const getAppStateForMetadata = (state: ApplicationState): Record | undefined => { - const stateStr = json.stringifyData(state, true, APP_STATE_EXCLUDE_KEYS); - return JSON.parse(stateStr); + const stateStr = json.stringifyWithoutCircular(state, false, APP_STATE_EXCLUDE_KEYS); + return stateStr !== null ? JSON.parse(stateStr) : undefined; }; const enhanceErrorEventMutator = (state: ApplicationState, event: BugsnagLib.Report): void => { diff --git a/packages/analytics-js-plugins/src/errorReporting/constants.ts b/packages/analytics-js-plugins/src/errorReporting/constants.ts index 6fa61398c0..9765e672c6 100644 --- a/packages/analytics-js-plugins/src/errorReporting/constants.ts +++ b/packages/analytics-js-plugins/src/errorReporting/constants.ts @@ -17,7 +17,6 @@ const APP_STATE_EXCLUDE_KEYS = [ 'instance', // destination instance objects 'eventBuffer', // pre-load event buffer (may contain PII) 'traits', - 'authToken', ]; const REQUEST_TIMEOUT_MS = 10 * 1000; // 10 seconds const NOTIFIER_NAME = 'RudderStack JavaScript SDK Error Notifier'; diff --git a/packages/analytics-js-plugins/src/errorReporting/event/event.ts b/packages/analytics-js-plugins/src/errorReporting/event/event.ts index 3af8e3f73c..50f09c5c95 100644 --- a/packages/analytics-js-plugins/src/errorReporting/event/event.ts +++ b/packages/analytics-js-plugins/src/errorReporting/event/event.ts @@ -1,7 +1,8 @@ +import type { ErrorState } from '@rudderstack/analytics-js-common/types/ErrorHandler'; import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; import ErrorStackParser from 'error-stack-parser'; import type { Exception, Stackframe } from '@rudderstack/analytics-js-common/types/Metrics'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import type { FrameType, IErrorFormat } from '../types'; import { hasStack, isError } from './utils'; import { ERROR_REPORTING_PLUGIN } from '../constants'; @@ -68,7 +69,7 @@ const normaliseError = (maybeError: any, component: string, logger?: ILogger) => error = maybeError; } else { logger?.warn( - `${ERROR_REPORTING_PLUGIN}:: ${component} received a non-error: ${stringifyData(error, false)}`, + `${ERROR_REPORTING_PLUGIN}:: ${component} received a non-error: ${stringifyWithoutCircular(error)}`, ); error = undefined; } diff --git a/packages/analytics-js-plugins/src/errorReporting/utils.ts b/packages/analytics-js-plugins/src/errorReporting/utils.ts index 7a8fcb8c92..3beeef3add 100644 --- a/packages/analytics-js-plugins/src/errorReporting/utils.ts +++ b/packages/analytics-js-plugins/src/errorReporting/utils.ts @@ -16,7 +16,7 @@ import type { } from '@rudderstack/analytics-js-common/types/Metrics'; import { generateUUID } from '@rudderstack/analytics-js-common/utilities/uuId'; import { METRICS_PAYLOAD_VERSION } from '@rudderstack/analytics-js-common/constants/metrics'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import { ERROR_MESSAGES_TO_BE_FILTERED } from '@rudderstack/analytics-js-common/constants/errors'; import { APP_STATE_EXCLUDE_KEYS, @@ -67,8 +67,8 @@ const getReleaseStage = () => { }; const getAppStateForMetadata = (state: ApplicationState): Record => { - const stateStr = json.stringifyData(state, true, APP_STATE_EXCLUDE_KEYS); - return JSON.parse(stateStr); + const stateStr = json.stringifyWithoutCircular(state, false, APP_STATE_EXCLUDE_KEYS); + return stateStr !== null ? JSON.parse(stateStr) : {}; }; const getURLWithoutQueryString = () => { @@ -188,7 +188,7 @@ const getErrorDeliveryPayload = (payload: ErrorEventPayload, state: ApplicationS }, errors: payload, }; - return stringifyData(data) as string; + return stringifyWithoutCircular(data) as string; }; export { diff --git a/packages/analytics-js-plugins/src/utilities/eventsDelivery.ts b/packages/analytics-js-plugins/src/utilities/eventsDelivery.ts index ad963f2b82..f6bbbedaeb 100644 --- a/packages/analytics-js-plugins/src/utilities/eventsDelivery.ts +++ b/packages/analytics-js-plugins/src/utilities/eventsDelivery.ts @@ -3,7 +3,7 @@ import { clone } from 'ramda'; import { LOG_CONTEXT_SEPARATOR } from '@rudderstack/analytics-js-common/constants/logMessages'; import type { Nullable } from '@rudderstack/analytics-js-common/types/Nullable'; import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import { EVENT_PAYLOAD_SIZE_BYTES_LIMIT } from './constants'; import type { TransformationRequestPayload } from '../deviceModeTransformation/types'; @@ -14,6 +14,9 @@ const EVENT_PAYLOAD_SIZE_CHECK_FAIL_WARNING = ( ): string => `${context}${LOG_CONTEXT_SEPARATOR}The size of the event payload (${payloadSize} bytes) exceeds the maximum limit of ${sizeLimit} bytes. Events with large payloads may be dropped in the future. Please review your instrumentation to ensure that event payloads are within the size limit.`; +const EVENT_PAYLOAD_SIZE_VALIDATION_WARNING = (context: string): string => + `${context}${LOG_CONTEXT_SEPARATOR}Failed to validate event payload size. Please make sure that the event payload is within the size limit and is a valid JSON object.`; + const QUEUE_UTILITIES = 'QueueUtilities'; /** @@ -22,11 +25,19 @@ const QUEUE_UTILITIES = 'QueueUtilities'; * @param logger Logger instance * @returns stringified event payload. Empty string if error occurs. */ -const getDeliveryPayload = (event: RudderEvent): string => - stringifyData(event) as string; +const getDeliveryPayload = (event: RudderEvent, logger?: ILogger): Nullable => + stringifyWithoutCircular(event, true, undefined, logger); -const getDMTDeliveryPayload = (dmtRequestPayload: TransformationRequestPayload): Nullable => - stringifyData(dmtRequestPayload); +const getDMTDeliveryPayload = ( + dmtRequestPayload: TransformationRequestPayload, + logger?: ILogger, +): Nullable => + stringifyWithoutCircular( + dmtRequestPayload, + true, + undefined, + logger, + ); /** * Utility to validate final payload size before sending to server @@ -34,16 +45,20 @@ const getDMTDeliveryPayload = (dmtRequestPayload: TransformationRequestPayload): * @param logger Logger instance */ const validateEventPayloadSize = (event: RudderEvent, logger?: ILogger) => { - const payloadStr = getDeliveryPayload(event); - const payloadSize = payloadStr.length; - if (payloadSize > EVENT_PAYLOAD_SIZE_BYTES_LIMIT) { - logger?.warn( - EVENT_PAYLOAD_SIZE_CHECK_FAIL_WARNING( - QUEUE_UTILITIES, - payloadSize, - EVENT_PAYLOAD_SIZE_BYTES_LIMIT, - ), - ); + const payloadStr = getDeliveryPayload(event, logger); + if (payloadStr) { + const payloadSize = payloadStr.length; + if (payloadSize > EVENT_PAYLOAD_SIZE_BYTES_LIMIT) { + logger?.warn( + EVENT_PAYLOAD_SIZE_CHECK_FAIL_WARNING( + QUEUE_UTILITIES, + payloadSize, + EVENT_PAYLOAD_SIZE_BYTES_LIMIT, + ), + ); + } + } else { + logger?.warn(EVENT_PAYLOAD_SIZE_VALIDATION_WARNING(QUEUE_UTILITIES)); } }; diff --git a/packages/analytics-js-plugins/src/xhrQueue/index.ts b/packages/analytics-js-plugins/src/xhrQueue/index.ts index c194e5a9ac..13978bc499 100644 --- a/packages/analytics-js-plugins/src/xhrQueue/index.ts +++ b/packages/analytics-js-plugins/src/xhrQueue/index.ts @@ -66,7 +66,11 @@ const XhrQueue = (): ExtensionPlugin => ({ maxRetryAttempts?: number, willBeRetried?: boolean, ) => { - const { data, url, headers } = getRequestInfo(itemData as XHRRetryQueueItemData, state); + const { data, url, headers } = getRequestInfo( + itemData as XHRRetryQueueItemData, + state, + logger, + ); httpClient.getAsyncData({ url, @@ -102,7 +106,7 @@ const XhrQueue = (): ExtensionPlugin => ({ const currentTime = getCurrentTimeFormatted(); const events = itemData.map((queueItemData: XHRQueueItemData) => queueItemData.event); // type casting to string as we know that the event has already been validated prior to enqueue - return (getBatchDeliveryPayload(events, currentTime) as string)?.length; + return (getBatchDeliveryPayload(events, currentTime, logger) as string)?.length; }, ); diff --git a/packages/analytics-js-plugins/src/xhrQueue/utilities.ts b/packages/analytics-js-plugins/src/xhrQueue/utilities.ts index 9f1ecf3bf7..4a3df97a3b 100644 --- a/packages/analytics-js-plugins/src/xhrQueue/utilities.ts +++ b/packages/analytics-js-plugins/src/xhrQueue/utilities.ts @@ -4,6 +4,7 @@ import type { ResponseDetails } from '@rudderstack/analytics-js-common/types/Htt import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; import type { ApplicationState } from '@rudderstack/analytics-js-common/types/ApplicationState'; import type { RudderEvent } from '@rudderstack/analytics-js-common/types/Event'; +import type { Nullable } from '@rudderstack/analytics-js-common/types/Nullable'; import { clone } from 'ramda'; import { getCurrentTimeFormatted } from '@rudderstack/analytics-js-common/utilities/timestamp'; import { checks, http, url, json, eventsDelivery } from '../shared-chunks/common'; @@ -11,9 +12,13 @@ import { DATA_PLANE_API_VERSION, DEFAULT_RETRY_QUEUE_OPTIONS, XHR_QUEUE_PLUGIN } import type { XHRRetryQueueItemData, XHRQueueItemData, XHRBatchPayload } from './types'; import { EVENT_DELIVERY_FAILURE_ERROR_PREFIX } from './logMessages'; -const getBatchDeliveryPayload = (events: RudderEvent[], currentTime: string): string => { +const getBatchDeliveryPayload = ( + events: RudderEvent[], + currentTime: string, + logger?: ILogger, +): Nullable => { const batchPayload: XHRBatchPayload = { batch: events, sentAt: currentTime }; - return json.stringifyData(batchPayload); + return json.stringifyWithoutCircular(batchPayload, true, undefined, logger); }; const getNormalizedQueueOptions = (queueOpts: QueueOpts): QueueOpts => @@ -61,7 +66,11 @@ const logErrorOnFailure = ( logger?.error(errMsg); }; -const getRequestInfo = (itemData: XHRRetryQueueItemData, state: ApplicationState) => { +const getRequestInfo = ( + itemData: XHRRetryQueueItemData, + state: ApplicationState, + logger?: ILogger, +) => { let data; let headers; let url: string; @@ -70,14 +79,14 @@ const getRequestInfo = (itemData: XHRRetryQueueItemData, state: ApplicationState const finalEvents = itemData.map((queueItemData: XHRQueueItemData) => eventsDelivery.getFinalEventForDeliveryMutator(queueItemData.event, currentTime), ); - data = getBatchDeliveryPayload(finalEvents, currentTime); + data = getBatchDeliveryPayload(finalEvents, currentTime, logger); headers = itemData[0] ? clone(itemData[0].headers) : {}; url = getBatchDeliveryUrl(state.lifecycle.activeDataplaneUrl.value as string); } else { const { url: eventUrl, event, headers: eventHeaders } = itemData; const finalEvent = eventsDelivery.getFinalEventForDeliveryMutator(event, currentTime); - data = eventsDelivery.getDeliveryPayload(finalEvent); + data = eventsDelivery.getDeliveryPayload(finalEvent, logger); headers = clone(eventHeaders); url = eventUrl; } diff --git a/packages/analytics-js/__tests__/components/configManager/ConfigManager.test.ts b/packages/analytics-js/__tests__/components/configManager/ConfigManager.test.ts index 9f1fc09154..df73dd4f04 100644 --- a/packages/analytics-js/__tests__/components/configManager/ConfigManager.test.ts +++ b/packages/analytics-js/__tests__/components/configManager/ConfigManager.test.ts @@ -85,6 +85,23 @@ describe('ConfigManager', () => { server.close(); }); + it('should throw an error for invalid writeKey', () => { + state.lifecycle.writeKey.value = ' '; + expect(() => { + configManagerInstance.init(); + }).toThrow(errorMsg); + }); + + it('should throw error for invalid data plane url', () => { + state.lifecycle.writeKey.value = sampleWriteKey; + state.lifecycle.dataPlaneUrl.value = ' '; + expect(() => { + configManagerInstance.init(); + }).toThrow( + 'The data plane URL " " is invalid. It must be a valid URL string. Please check that the data plane URL is correct and try again.', + ); + }); + it('should update lifecycle state with proper values', () => { getSDKUrl.mockImplementation(() => sampleScriptURL); diff --git a/packages/analytics-js/__tests__/components/configManager/validate.test.ts b/packages/analytics-js/__tests__/components/configManager/validate.test.ts index ccaa68f39a..2176388a7b 100644 --- a/packages/analytics-js/__tests__/components/configManager/validate.test.ts +++ b/packages/analytics-js/__tests__/components/configManager/validate.test.ts @@ -1,10 +1,38 @@ import { + validateLoadArgs, getTopDomainUrl, getDataServiceUrl, isWebpageTopLevelDomain, } from '../../../src/components/configManager/util/validate'; describe('Config manager util - validate load arguments', () => { + const sampleWriteKey = 'dummyWriteKey'; + const sampleDataPlaneUrl = 'https://www.dummy.url'; + const errorMsg = + 'The write key " " is invalid. It must be a non-empty string. Please check that the write key is correct and try again.'; + + it('should not throw error for valid write key', () => { + expect(() => { + validateLoadArgs(sampleWriteKey); + }).not.toThrow(errorMsg); + }); + it('should not throw error for valid data plane url', () => { + expect(() => { + validateLoadArgs(sampleWriteKey, sampleDataPlaneUrl); + }).not.toThrow('Unable to load the SDK due to invalid data plane URL: " "'); + }); + it('should throw error for invalid write key', () => { + expect(() => { + validateLoadArgs(' '); + }).toThrow(errorMsg); + }); + it('should throw error for invalid data plane url', () => { + expect(() => { + validateLoadArgs(sampleWriteKey, ' '); + }).toThrow( + 'The data plane URL " " is invalid. It must be a valid URL string. Please check that the data plane URL is correct and try again.', + ); + }); describe('getTopDomainUrl', () => { const testCaseData = [ ['https://sub.example.com', 'https://example.com'], diff --git a/packages/analytics-js/__tests__/components/core/Analytics.test.ts b/packages/analytics-js/__tests__/components/core/Analytics.test.ts index f10e2ff0fa..e85709a4d6 100644 --- a/packages/analytics-js/__tests__/components/core/Analytics.test.ts +++ b/packages/analytics-js/__tests__/components/core/Analytics.test.ts @@ -128,77 +128,12 @@ describe('Core - Analytics', () => { expect(setMinLogLevelSpy).toHaveBeenCalledWith('ERROR'); expect(setExposedGlobal).toHaveBeenCalledWith('state', state, dummyWriteKey); }); - - it('should not load if the write key is invalid', () => { - const startLifecycleSpy = jest.spyOn(analytics, 'startLifecycle'); - const errorSpy = jest.spyOn(analytics.logger, 'error'); - - analytics.load('', sampleDataPlaneUrl, { logLevel: 'ERROR' }); - - expect(state.lifecycle.status.value).toBeUndefined(); - expect(startLifecycleSpy).not.toHaveBeenCalled(); - - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenNthCalledWith( - 1, - 'AnalyticsCore:: The write key "" is invalid. It must be a non-empty string. Please check that the write key is correct and try again.', - ); - - // Try with different invalid write key - errorSpy.mockClear(); - analytics.load(' ', sampleDataPlaneUrl, { logLevel: 'ERROR' }); - - expect(errorSpy).toHaveBeenNthCalledWith( - 1, - 'AnalyticsCore:: The write key " " is invalid. It must be a non-empty string. Please check that the write key is correct and try again.', - ); - - // Try with different invalid write key - errorSpy.mockClear(); - analytics.load({} as any, sampleDataPlaneUrl, { logLevel: 'ERROR' }); - - expect(errorSpy).toHaveBeenNthCalledWith( - 1, - 'AnalyticsCore:: The write key "[object Object]" is invalid. It must be a non-empty string. Please check that the write key is correct and try again.', - ); - - errorSpy.mockRestore(); - }); - - it('should not load if the data plane URL is invalid', () => { + it('should load the analytics script without dataPlaneUrl with the given options', () => { const startLifecycleSpy = jest.spyOn(analytics, 'startLifecycle'); - const errorSpy = jest.spyOn(analytics.logger, 'error'); - - analytics.load(dummyWriteKey, '', { logLevel: 'ERROR' }); - - expect(state.lifecycle.status.value).toBeUndefined(); - expect(startLifecycleSpy).not.toHaveBeenCalled(); - - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenNthCalledWith( - 1, - 'AnalyticsCore:: The data plane URL "" is invalid. It must be a valid URL string. Please check that the data plane URL is correct and try again.', - ); - - // Try with different invalid data plane URL - errorSpy.mockClear(); - analytics.load(dummyWriteKey, undefined as any, { logLevel: 'ERROR' }); - - expect(errorSpy).toHaveBeenNthCalledWith( - 1, - 'AnalyticsCore:: The data plane URL "undefined" is invalid. It must be a valid URL string. Please check that the data plane URL is correct and try again.', - ); - - // Try with different invalid data plane URL - errorSpy.mockClear(); - analytics.load(dummyWriteKey, 'https:///someinvalidurl', { logLevel: 'ERROR' }); - - expect(errorSpy).toHaveBeenNthCalledWith( - 1, - 'AnalyticsCore:: The data plane URL "https:///someinvalidurl" is invalid. It must be a valid URL string. Please check that the data plane URL is correct and try again.', - ); - - errorSpy.mockRestore(); + analytics.load(dummyWriteKey, { logLevel: 'ERROR' }); + expect(state.lifecycle.status.value).toBe('browserCapabilitiesReady'); + expect(startLifecycleSpy).toHaveBeenCalledTimes(1); + expect(setExposedGlobal).toHaveBeenCalledWith('state', state, dummyWriteKey); }); }); diff --git a/packages/analytics-js/__tests__/components/userSessionManager/UserSessionManager.test.ts b/packages/analytics-js/__tests__/components/userSessionManager/UserSessionManager.test.ts index 62e967731e..f7b0571eaa 100644 --- a/packages/analytics-js/__tests__/components/userSessionManager/UserSessionManager.test.ts +++ b/packages/analytics-js/__tests__/components/userSessionManager/UserSessionManager.test.ts @@ -1,5 +1,5 @@ import type { IPluginsManager } from '@rudderstack/analytics-js-common/types/PluginsManager'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import { COOKIE_KEYS } from '@rudderstack/analytics-js-cookies/constants/cookies'; import { UserSessionManager } from '../../../src/components/userSessionManager'; import { DEFAULT_USER_SESSION_VALUES } from '../../../src/components/userSessionManager/constants'; @@ -27,7 +27,7 @@ jest.mock('@rudderstack/analytics-js-common/utilities/uuId', () => ({ })); jest.mock('@rudderstack/analytics-js-common/utilities/json', () => ({ - stringifyData: jest.fn(d => JSON.stringify(d)), + stringifyWithoutCircular: jest.fn(d => JSON.stringify(d)), })); describe('User session manager', () => { @@ -1713,7 +1713,7 @@ describe('User session manager', () => { prop2: 12345678, prop3: { city: 'Kolkata', zip: '700001' }, }); - expect(stringifyData).toHaveBeenCalled(); + expect(stringifyWithoutCircular).toHaveBeenCalled(); expect(defaultLogger.error).not.toHaveBeenCalledWith( 'The server failed to set the key cookie. As a fallback, the cookies will be set client side.', ); @@ -1739,7 +1739,7 @@ describe('User session manager', () => { ); setTimeout(() => { expect(mockCookieStore.get).toHaveBeenCalledWith('key'); - expect(stringifyData).toHaveBeenCalled(); + expect(stringifyWithoutCircular).toHaveBeenCalled(); expect(defaultLogger.error).toHaveBeenCalledWith( 'The server failed to set the key cookie. As a fallback, the cookies will be set client side.', ); diff --git a/packages/analytics-js/__tests__/services/HttpClient/HttpClient.test.ts b/packages/analytics-js/__tests__/services/HttpClient/HttpClient.test.ts index fd0bbe2af0..2e7349d8df 100644 --- a/packages/analytics-js/__tests__/services/HttpClient/HttpClient.test.ts +++ b/packages/analytics-js/__tests__/services/HttpClient/HttpClient.test.ts @@ -230,4 +230,26 @@ describe('HttpClient', () => { url: `${dummyDataplaneHost}/emptyJsonSample`, }); }); + + it('should handle if input data contains non-stringifiable values', done => { + const callback = (response: any) => { + expect(response).toBeUndefined(); + expect(defaultErrorHandler.onError).toHaveBeenCalledTimes(1); + expect(defaultErrorHandler.onError).toHaveBeenCalledWith( + new Error('Failed to prepare data for the request.'), + 'HttpClient', + ); + done(); + }; + clientInstance.getAsyncData({ + callback, + url: `${dummyDataplaneHost}/nonStringifiableDataSample`, + options: { + data: { + a: 1, + b: BigInt(1), + }, + }, + }); + }); }); diff --git a/packages/analytics-js/src/app/RudderAnalytics.ts b/packages/analytics-js/src/app/RudderAnalytics.ts index 7d8de7add9..dabf2b1918 100644 --- a/packages/analytics-js/src/app/RudderAnalytics.ts +++ b/packages/analytics-js/src/app/RudderAnalytics.ts @@ -18,11 +18,10 @@ import { import type { ApiCallback, ApiOptions } from '@rudderstack/analytics-js-common/types/EventApi'; import type { ApiObject } from '@rudderstack/analytics-js-common/types/ApiObject'; import { RS_APP } from '@rudderstack/analytics-js-common/constants/loggerContexts'; +import { isString } from '@rudderstack/analytics-js-common/utilities/checks'; import type { IdentifyTraits } from '@rudderstack/analytics-js-common/types/traits'; -import { getSanitizedValue } from '@rudderstack/analytics-js-common/utilities/json'; import { generateUUID } from '@rudderstack/analytics-js-common/utilities/uuId'; import { onPageLeave } from '@rudderstack/analytics-js-common/utilities/page'; -import { isString } from '@rudderstack/analytics-js-common/utilities/checks'; import { getFormattedTimestamp } from '@rudderstack/analytics-js-common/utilities/timestamp'; import { GLOBAL_PRELOAD_BUFFER } from '../constants/app'; import { @@ -37,6 +36,7 @@ import { defaultLogger } from '../services/Logger/Logger'; import { EMPTY_GROUP_CALL_ERROR, PAGE_UNLOAD_ON_BEACON_DISABLED_WARNING, + WRITE_KEY_NOT_A_STRING_ERROR, } from '../constants/logMessages'; import { defaultErrorHandler } from '../services/ErrorHandler'; import { state } from '../state'; @@ -107,7 +107,7 @@ class RudderAnalytics implements IRudderAnalytics { * TODO: to support multiple analytics instances in the near future */ setDefaultInstanceKey(writeKey: string) { - if (isString(writeKey) && writeKey) { + if (writeKey) { this.defaultAnalyticsKey = writeKey; } } @@ -116,10 +116,7 @@ class RudderAnalytics implements IRudderAnalytics { * Retrieve an existing analytics instance */ getAnalyticsInstance(writeKey?: string): IAnalytics { - let instanceId = writeKey; - if (!isString(instanceId) || !instanceId) { - instanceId = this.defaultAnalyticsKey; - } + const instanceId = writeKey ?? this.defaultAnalyticsKey; const analyticsInstanceExists = Boolean(this.analyticsInstances[instanceId]); @@ -131,13 +128,14 @@ class RudderAnalytics implements IRudderAnalytics { } /** - * Loads the SDK - * @param writeKey Source write key - * @param dataPlaneUrl Data plane URL - * @param loadOptions Additional options for loading the SDK - * @returns none + * Create new analytics instance and trigger application lifecycle start */ load(writeKey: string, dataPlaneUrl: string, loadOptions?: Partial) { + if (!isString(writeKey)) { + this.logger.error(WRITE_KEY_NOT_A_STRING_ERROR(RS_APP, writeKey)); + return; + } + if (this.analyticsInstances[writeKey]) { return; } @@ -154,11 +152,7 @@ class RudderAnalytics implements IRudderAnalytics { setExposedGlobal(GLOBAL_PRELOAD_BUFFER, clone(preloadedEventsArray)); this.analyticsInstances[writeKey] = new Analytics(); - this.getAnalyticsInstance(writeKey).load( - writeKey, - dataPlaneUrl, - getSanitizedValue(loadOptions), - ); + this.getAnalyticsInstance(writeKey).load(writeKey, dataPlaneUrl, loadOptions); } /** @@ -348,13 +342,7 @@ class RudderAnalytics implements IRudderAnalytics { callback?: ApiCallback, ) { this.getAnalyticsInstance().page( - pageArgumentsToCallOptions( - getSanitizedValue(category), - getSanitizedValue(name), - getSanitizedValue(properties), - getSanitizedValue(options), - callback, - ), + pageArgumentsToCallOptions(category, name, properties, options, callback), ); } @@ -377,12 +365,7 @@ class RudderAnalytics implements IRudderAnalytics { callback?: ApiCallback, ) { this.getAnalyticsInstance().track( - trackArgumentsToCallOptions( - getSanitizedValue(event), - getSanitizedValue(properties), - getSanitizedValue(options), - callback, - ), + trackArgumentsToCallOptions(event, properties, options, callback), ); } @@ -411,12 +394,7 @@ class RudderAnalytics implements IRudderAnalytics { callback?: ApiCallback, ) { this.getAnalyticsInstance().identify( - identifyArgumentsToCallOptions( - getSanitizedValue(userId), - getSanitizedValue(traits), - getSanitizedValue(options), - callback, - ), + identifyArgumentsToCallOptions(userId, traits, options, callback), ); } @@ -434,14 +412,7 @@ class RudderAnalytics implements IRudderAnalytics { options?: Nullable | ApiCallback, callback?: ApiCallback, ) { - this.getAnalyticsInstance().alias( - aliasArgumentsToCallOptions( - getSanitizedValue(to), - getSanitizedValue(from), - getSanitizedValue(options), - callback, - ), - ); + this.getAnalyticsInstance().alias(aliasArgumentsToCallOptions(to, from, options, callback)); } /** @@ -474,28 +445,20 @@ class RudderAnalytics implements IRudderAnalytics { } this.getAnalyticsInstance().group( - groupArgumentsToCallOptions( - getSanitizedValue(groupId), - getSanitizedValue(traits), - getSanitizedValue(options), - callback, - ), + groupArgumentsToCallOptions(groupId, traits, options, callback), ); } reset(resetAnonymousId?: boolean) { - this.getAnalyticsInstance().reset(getSanitizedValue(resetAnonymousId)); + this.getAnalyticsInstance().reset(resetAnonymousId); } getAnonymousId(options?: AnonymousIdOptions) { - return this.getAnalyticsInstance().getAnonymousId(getSanitizedValue(options)); + return this.getAnalyticsInstance().getAnonymousId(options); } setAnonymousId(anonymousId?: string, rudderAmpLinkerParam?: string) { - this.getAnalyticsInstance().setAnonymousId( - getSanitizedValue(anonymousId), - getSanitizedValue(rudderAmpLinkerParam), - ); + this.getAnalyticsInstance().setAnonymousId(anonymousId, rudderAmpLinkerParam); } getUserId() { @@ -527,11 +490,11 @@ class RudderAnalytics implements IRudderAnalytics { } setAuthToken(token: string) { - return this.getAnalyticsInstance().setAuthToken(getSanitizedValue(token)); + return this.getAnalyticsInstance().setAuthToken(token); } consent(options?: ConsentOptions) { - return this.getAnalyticsInstance().consent(getSanitizedValue(options)); + return this.getAnalyticsInstance().consent(options); } } diff --git a/packages/analytics-js/src/components/configManager/ConfigManager.ts b/packages/analytics-js/src/components/configManager/ConfigManager.ts index 315f2ab5a5..fec3eacbb3 100644 --- a/packages/analytics-js/src/components/configManager/ConfigManager.ts +++ b/packages/analytics-js/src/components/configManager/ConfigManager.ts @@ -10,7 +10,7 @@ import type { Destination } from '@rudderstack/analytics-js-common/types/Destina import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; import { CONFIG_MANAGER } from '@rudderstack/analytics-js-common/constants/loggerContexts'; import type { IntegrationOpts } from '@rudderstack/analytics-js-common/types/Integration'; -import { isValidSourceConfig } from './util/validate'; +import { isValidSourceConfig, validateLoadArgs } from './util/validate'; import { SOURCE_CONFIG_FETCH_ERROR, SOURCE_CONFIG_OPTION_ERROR, @@ -62,6 +62,8 @@ class ConfigManager implements IConfigManager { init() { this.attachEffects(); + validateLoadArgs(state.lifecycle.writeKey.value, state.lifecycle.dataPlaneUrl.value); + const { logLevel, configUrl, diff --git a/packages/analytics-js/src/components/configManager/util/validate.ts b/packages/analytics-js/src/components/configManager/util/validate.ts index fc22c51405..6090e231cd 100644 --- a/packages/analytics-js/src/components/configManager/util/validate.ts +++ b/packages/analytics-js/src/components/configManager/util/validate.ts @@ -1,9 +1,31 @@ import { isObjectLiteralAndNotNull } from '@rudderstack/analytics-js-common/utilities/object'; -import { isNullOrUndefined } from '@rudderstack/analytics-js-common/utilities/checks'; +import { isNullOrUndefined, isString } from '@rudderstack/analytics-js-common/utilities/checks'; import { SUPPORTED_STORAGE_TYPES, type StorageType, } from '@rudderstack/analytics-js-common/types/Storage'; +import { isValidURL } from '@rudderstack/analytics-js-common/utilities/url'; +import { + WRITE_KEY_VALIDATION_ERROR, + DATA_PLANE_URL_VALIDATION_ERROR, +} from '../../../constants/logMessages'; + +const validateWriteKey = (writeKey?: string) => { + if (!isString(writeKey) || (writeKey as string).trim().length === 0) { + throw new Error(WRITE_KEY_VALIDATION_ERROR(writeKey)); + } +}; + +const validateDataPlaneUrl = (dataPlaneUrl?: string) => { + if (!isValidURL(dataPlaneUrl)) { + throw new Error(DATA_PLANE_URL_VALIDATION_ERROR(dataPlaneUrl)); + } +}; + +const validateLoadArgs = (writeKey?: string, dataPlaneUrl?: string) => { + validateWriteKey(writeKey); + validateDataPlaneUrl(dataPlaneUrl); +}; const isValidSourceConfig = (res: any): boolean => isObjectLiteralAndNotNull(res) && @@ -53,8 +75,11 @@ const isWebpageTopLevelDomain = (providedDomain: string): boolean => { }; export { + validateLoadArgs, isValidSourceConfig, isValidStorageType, + validateWriteKey, + validateDataPlaneUrl, getTopDomainUrl, getDataServiceUrl, isWebpageTopLevelDomain, diff --git a/packages/analytics-js/src/components/core/Analytics.ts b/packages/analytics-js/src/components/core/Analytics.ts index 8421dda6cf..31948370f2 100644 --- a/packages/analytics-js/src/components/core/Analytics.ts +++ b/packages/analytics-js/src/components/core/Analytics.ts @@ -18,6 +18,7 @@ import type { LoadOptions, } from '@rudderstack/analytics-js-common/types/LoadOptions'; import type { ApiCallback } from '@rudderstack/analytics-js-common/types/EventApi'; +import { isObjectAndNotNull } from '@rudderstack/analytics-js-common/utilities/object'; import { ANALYTICS_CORE, READY_API, @@ -59,15 +60,10 @@ import { ADBLOCK_PAGE_PATH, CONSENT_TRACK_EVENT_NAME, } from '../../constants/app'; -import { - DATA_PLANE_URL_VALIDATION_ERROR, - READY_API_CALLBACK_ERROR, - READY_CALLBACK_INVOKE_ERROR, - WRITE_KEY_VALIDATION_ERROR, -} from '../../constants/logMessages'; +import { READY_API_CALLBACK_ERROR, READY_CALLBACK_INVOKE_ERROR } from '../../constants/logMessages'; import type { IAnalytics } from './IAnalytics'; import { getConsentManagementData, getValidPostConsentOptions } from '../utilities/consent'; -import { dispatchSDKEvent, isDataPlaneUrlValid, isWriteKeyValid } from './utilities'; +import { dispatchSDKEvent } from './utilities'; /* * Analytics class with lifecycle based on state ad user triggered events @@ -104,26 +100,29 @@ class Analytics implements IAnalytics { /** * Start application lifecycle if not already started */ - load(writeKey: string, dataPlaneUrl: string, loadOptions: Partial = {}) { + load( + writeKey: string, + dataPlaneUrl?: string | Partial, + loadOptions: Partial = {}, + ) { if (state.lifecycle.status.value) { return; } - if (!isWriteKeyValid(writeKey)) { - this.logger.error(WRITE_KEY_VALIDATION_ERROR(ANALYTICS_CORE, writeKey)); - return; - } + let clonedDataPlaneUrl = clone(dataPlaneUrl); + let clonedLoadOptions = clone(loadOptions); - if (!isDataPlaneUrlValid(dataPlaneUrl)) { - this.logger.error(DATA_PLANE_URL_VALIDATION_ERROR(ANALYTICS_CORE, dataPlaneUrl)); - return; + // dataPlaneUrl is not provided + if (isObjectAndNotNull(dataPlaneUrl)) { + clonedLoadOptions = dataPlaneUrl; + clonedDataPlaneUrl = undefined; } // Set initial state values batch(() => { - state.lifecycle.writeKey.value = clone(writeKey); - state.lifecycle.dataPlaneUrl.value = clone(dataPlaneUrl); - state.loadOptions.value = normalizeLoadOptions(state.loadOptions.value, loadOptions); + state.lifecycle.writeKey.value = writeKey; + state.lifecycle.dataPlaneUrl.value = clonedDataPlaneUrl as string | undefined; + state.loadOptions.value = normalizeLoadOptions(state.loadOptions.value, clonedLoadOptions); state.lifecycle.status.value = 'mounted'; }); diff --git a/packages/analytics-js/src/components/core/utilities.ts b/packages/analytics-js/src/components/core/utilities.ts index b9c11e7fd9..ca1dfd0c0b 100644 --- a/packages/analytics-js/src/components/core/utilities.ts +++ b/packages/analytics-js/src/components/core/utilities.ts @@ -1,6 +1,3 @@ -import { isString } from '@rudderstack/analytics-js-common/utilities/checks'; -import { isValidURL } from '@rudderstack/analytics-js-common/utilities/url'; - const dispatchSDKEvent = (event: string): void => { const customEvent = new CustomEvent(event, { detail: { analyticsInstance: (globalThis as typeof window).rudderanalytics }, @@ -12,8 +9,4 @@ const dispatchSDKEvent = (event: string): void => { (globalThis as typeof window).document.dispatchEvent(customEvent); }; -const isWriteKeyValid = (writeKey: string) => isString(writeKey) && writeKey.trim().length > 0; - -const isDataPlaneUrlValid = (dataPlaneUrl: string) => isValidURL(dataPlaneUrl); - -export { dispatchSDKEvent, isWriteKeyValid, isDataPlaneUrlValid }; +export { dispatchSDKEvent }; diff --git a/packages/analytics-js/src/components/userSessionManager/UserSessionManager.ts b/packages/analytics-js/src/components/userSessionManager/UserSessionManager.ts index dd5fd436b7..1bc46c1726 100644 --- a/packages/analytics-js/src/components/userSessionManager/UserSessionManager.ts +++ b/packages/analytics-js/src/components/userSessionManager/UserSessionManager.ts @@ -32,7 +32,7 @@ import type { AsyncRequestCallback, IHttpClient, } from '@rudderstack/analytics-js-common/types/HttpClient'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import { COOKIE_KEYS } from '@rudderstack/analytics-js-cookies/constants/cookies'; import { CLIENT_DATA_STORE_COOKIE, @@ -304,7 +304,9 @@ class UserSessionManager implements IUserSessionManager { getEncryptedCookieData(cookiesData: CookieData[], store?: IStore): EncryptedCookieData[] { const encryptedCookieData: EncryptedCookieData[] = []; cookiesData.forEach(cData => { - const encryptedValue = store?.encrypt(stringifyData(cData.value, false)); + const encryptedValue = store?.encrypt( + stringifyWithoutCircular(cData.value, false, [], this.logger), + ); if (isDefinedAndNotNull(encryptedValue)) { encryptedCookieData.push({ name: cData.name, @@ -328,24 +330,21 @@ class UserSessionManager implements IUserSessionManager { url: state.serverCookies.dataServiceUrl.value as string, options: { method: 'POST', - data: stringifyData( - { - reqType: 'setCookies', - workspaceId: state.source.value?.workspaceId, - data: { - options: { - maxAge: state.storage.cookie.value?.maxage, - path: state.storage.cookie.value?.path, - domain: state.storage.cookie.value?.domain, - sameSite: state.storage.cookie.value?.samesite, - secure: state.storage.cookie.value?.secure, - expires: state.storage.cookie.value?.expires, - }, - cookies: encryptedCookieData, + data: stringifyWithoutCircular({ + reqType: 'setCookies', + workspaceId: state.source.value?.workspaceId, + data: { + options: { + maxAge: state.storage.cookie.value?.maxage, + path: state.storage.cookie.value?.path, + domain: state.storage.cookie.value?.domain, + sameSite: state.storage.cookie.value?.samesite, + secure: state.storage.cookie.value?.secure, + expires: state.storage.cookie.value?.expires, }, + cookies: encryptedCookieData, }, - false, - ), + }) as string, sendRawData: true, withCredentials: true, }, @@ -369,8 +368,8 @@ class UserSessionManager implements IUserSessionManager { if (details?.xhr?.status === 200) { cookiesData.forEach(cData => { const cookieValue = store?.get(cData.name); - const before = stringifyData(cData.value, false); - const after = stringifyData(cookieValue, false); + const before = stringifyWithoutCircular(cData.value, false, []); + const after = stringifyWithoutCircular(cookieValue, false, []); if (after !== before) { this.logger?.error(FAILED_SETTING_COOKIE_FROM_SERVER_ERROR(cData.name)); if (cb) { @@ -469,11 +468,7 @@ class UserSessionManager implements IUserSessionManager { * 3. generateUUID: A new unique id is generated and assigned. */ setAnonymousId(anonymousId?: string, rudderAmpLinkerParam?: string) { - let finalAnonymousId: string | undefined = anonymousId; - if (!isString(anonymousId) || !finalAnonymousId) { - finalAnonymousId = undefined; - } - + let finalAnonymousId: string | undefined | null = anonymousId; if (this.isPersistenceEnabledForStorageEntry('anonymousId')) { if (!finalAnonymousId && rudderAmpLinkerParam) { const linkerPluginsResult = this.pluginsManager?.invokeSingle( @@ -673,7 +668,7 @@ class UserSessionManager implements IUserSessionManager { session.groupTraits.value = DEFAULT_USER_SESSION_VALUES.groupTraits; session.authToken.value = DEFAULT_USER_SESSION_VALUES.authToken; - if (resetAnonymousId === true) { + if (resetAnonymousId) { // This will generate a new anonymous ID this.setAnonymousId(); } diff --git a/packages/analytics-js/src/constants/logMessages.ts b/packages/analytics-js/src/constants/logMessages.ts index a28ee4b12e..8295a634e8 100644 --- a/packages/analytics-js/src/constants/logMessages.ts +++ b/packages/analytics-js/src/constants/logMessages.ts @@ -66,14 +66,14 @@ const STORAGE_UNAVAILABILITY_ERROR_PREFIX = (context: string, storageType: Stora const SOURCE_CONFIG_FETCH_ERROR = (reason: Error | undefined): string => `Failed to fetch the source config. Reason: ${reason}`; -const WRITE_KEY_VALIDATION_ERROR = (context: string, writeKey: string): string => - `${context}${LOG_CONTEXT_SEPARATOR}The write key "${writeKey}" is invalid. It must be a non-empty string. Please check that the write key is correct and try again.`; +const WRITE_KEY_VALIDATION_ERROR = (writeKey?: string): string => + `The write key "${writeKey}" is invalid. It must be a non-empty string. Please check that the write key is correct and try again.`; -const DATA_PLANE_URL_VALIDATION_ERROR = (context: string, dataPlaneUrl: string): string => - `${context}${LOG_CONTEXT_SEPARATOR}The data plane URL "${dataPlaneUrl}" is invalid. It must be a valid URL string. Please check that the data plane URL is correct and try again.`; +const DATA_PLANE_URL_VALIDATION_ERROR = (dataPlaneUrl: string | undefined): string => + `The data plane URL "${dataPlaneUrl}" is invalid. It must be a valid URL string. Please check that the data plane URL is correct and try again.`; const READY_API_CALLBACK_ERROR = (context: string): string => - `${context}${LOG_CONTEXT_SEPARATOR}The provided callback is not a function.`; + `${context}${LOG_CONTEXT_SEPARATOR}The callback is not a function.`; const XHR_DELIVERY_ERROR = ( prefix: string, @@ -193,6 +193,9 @@ const STORAGE_UNAVAILABLE_WARNING = ( ): string => `${context}${LOG_CONTEXT_SEPARATOR}The storage type "${selectedStorageType}" is not available for entry "${entry}". The SDK will initialize the entry with "${finalStorageType}" storage type instead.`; +const WRITE_KEY_NOT_A_STRING_ERROR = (context: string, writeKey: string | undefined): string => + `${context}${LOG_CONTEXT_SEPARATOR}The write key "${writeKey}" is not a string. Please check that the write key is correct and try again.`; + const EMPTY_GROUP_CALL_ERROR = (context: string): string => `${context}${LOG_CONTEXT_SEPARATOR}The group() method must be called with at least one argument.`; @@ -297,6 +300,7 @@ export { PLUGIN_EXT_POINT_MISSING_ERROR, PLUGIN_EXT_POINT_INVALID_ERROR, STORAGE_TYPE_VALIDATION_WARNING, + WRITE_KEY_NOT_A_STRING_ERROR, EMPTY_GROUP_CALL_ERROR, READY_CALLBACK_INVOKE_ERROR, API_CALLBACK_INVOKE_ERROR, diff --git a/packages/analytics-js/src/services/ErrorHandler/processError.ts b/packages/analytics-js/src/services/ErrorHandler/processError.ts index 92e8db6505..bd5588bffd 100644 --- a/packages/analytics-js/src/services/ErrorHandler/processError.ts +++ b/packages/analytics-js/src/services/ErrorHandler/processError.ts @@ -1,4 +1,4 @@ -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import { isString } from '@rudderstack/analytics-js-common/utilities/checks'; import type { ErrorTarget, SDKError } from '@rudderstack/analytics-js-common/types/ErrorHandler'; import { LOAD_ORIGIN } from './constant'; @@ -19,7 +19,7 @@ const processError = (error: SDKError): string => { } else { errorMessage = (error as any).message ? (error as any).message - : stringifyData(error as Record); + : stringifyWithoutCircular(error as Record); } } catch (e) { errorMessage = `Unknown error: ${(e as Error).message}`; diff --git a/packages/analytics-js/src/services/HttpClient/HttpClient.ts b/packages/analytics-js/src/services/HttpClient/HttpClient.ts index 5aab7e3c18..ee3f7e3ada 100644 --- a/packages/analytics-js/src/services/HttpClient/HttpClient.ts +++ b/packages/analytics-js/src/services/HttpClient/HttpClient.ts @@ -44,6 +44,7 @@ class HttpClient implements IHttpClient { const data = await xhrRequest( createXhrRequestOptions(url, options, this.basicAuthHeader), timeout, + this.logger, ); return { data: isRawResponse ? data.response : responseTextToJson(data.response, this.onError), @@ -62,7 +63,7 @@ class HttpClient implements IHttpClient { const { callback, url, options, timeout, isRawResponse } = config; const isFireAndForget = !isFunction(callback); - xhrRequest(createXhrRequestOptions(url, options, this.basicAuthHeader), timeout) + xhrRequest(createXhrRequestOptions(url, options, this.basicAuthHeader), timeout, this.logger) .then((data: ResponseDetails) => { if (!isFireAndForget) { callback( diff --git a/packages/analytics-js/src/services/HttpClient/xhr/xhrRequestHandler.ts b/packages/analytics-js/src/services/HttpClient/xhr/xhrRequestHandler.ts index 182d155b86..c8202d918c 100644 --- a/packages/analytics-js/src/services/HttpClient/xhr/xhrRequestHandler.ts +++ b/packages/analytics-js/src/services/HttpClient/xhr/xhrRequestHandler.ts @@ -1,11 +1,12 @@ /* eslint-disable prefer-promise-reject-errors */ import { mergeDeepRight } from '@rudderstack/analytics-js-common/utilities/object'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import { isNull } from '@rudderstack/analytics-js-common/utilities/checks'; import type { IXHRRequestOptions, ResponseDetails, } from '@rudderstack/analytics-js-common/types/HttpClient'; +import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; import { getMutatedError } from '@rudderstack/analytics-js-common/utilities/errors'; import { FAILED_REQUEST_ERR_MSG_PREFIX } from '@rudderstack/analytics-js-common/constants/errors'; import { DEFAULT_XHR_TIMEOUT_MS } from '../../../constants/timeouts'; @@ -56,13 +57,14 @@ const createXhrRequestOptions = ( const xhrRequest = ( options: IXHRRequestOptions, timeout = DEFAULT_XHR_TIMEOUT_MS, + logger?: ILogger, ): Promise => new Promise((resolve, reject) => { let payload; if (options.sendRawData === true) { payload = options.data; } else { - payload = stringifyData(options.data); + payload = stringifyWithoutCircular(options.data, false, [], logger); if (isNull(payload)) { reject({ error: new Error(XHR_PAYLOAD_PREP_ERROR), diff --git a/packages/analytics-js/src/services/StoreManager/Store.ts b/packages/analytics-js/src/services/StoreManager/Store.ts index f55f0924d7..da079a8a64 100644 --- a/packages/analytics-js/src/services/StoreManager/Store.ts +++ b/packages/analytics-js/src/services/StoreManager/Store.ts @@ -1,6 +1,6 @@ import { trim } from '@rudderstack/analytics-js-common/utilities/string'; import { isNullOrUndefined, isString } from '@rudderstack/analytics-js-common/utilities/checks'; -import { stringifyData } from '@rudderstack/analytics-js-common/utilities/json'; +import { stringifyWithoutCircular } from '@rudderstack/analytics-js-common/utilities/json'; import type { IStorage, IStore, IStoreConfig } from '@rudderstack/analytics-js-common/types/Store'; import type { IErrorHandler } from '@rudderstack/analytics-js-common/types/ErrorHandler'; import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger'; @@ -107,7 +107,10 @@ class Store implements IStore { try { // storejs that is used in localstorage engine already stringifies json - this.engine.setItem(validKey, this.encrypt(stringifyData(value, false))); + this.engine.setItem( + validKey, + this.encrypt(stringifyWithoutCircular(value, false, [], this.logger)), + ); } catch (err) { if (isStorageQuotaExceeded(err)) { this.logger?.warn(STORAGE_QUOTA_EXCEEDED_WARNING(`Store ${this.id}`)); @@ -205,7 +208,7 @@ class Store implements IStore { ? this.pluginsManager.invokeSingle(extensionPointName, value) : value; - return typeof formattedValue === 'undefined' ? value : (formattedValue ?? ''); + return typeof formattedValue === 'undefined' ? value : formattedValue ?? ''; } /**