diff --git a/src/batchUploader.ts b/src/batchUploader.ts index b4a5233b7..3bc09517a 100644 --- a/src/batchUploader.ts +++ b/src/batchUploader.ts @@ -3,7 +3,7 @@ import Constants from './constants'; import { SDKEvent, SDKEventCustomFlags, SDKLoggerApi } from './sdkRuntimeModels'; import { convertEvents } from './sdkToEventsApiConverter'; import { MessageType, EventType } from './types'; -import { getRampNumber, isEmpty } from './utils'; +import { getRampNumber, isEmpty, obfuscateData } from './utils'; import { SessionStorageVault, LocalStorageVault } from './vault'; import { AsyncUploader, @@ -76,17 +76,11 @@ export class BatchUploader { if (this.offlineStorageEnabled) { this.eventVault = new SessionStorageVault( - `${mpInstance._Store.storageName}-events`, - { - logger: mpInstance.Logger, - } + `${mpInstance._Store.storageName}-events` ); this.batchVault = new LocalStorageVault( - `${mpInstance._Store.storageName}-batches`, - { - logger: mpInstance.Logger, - } + `${mpInstance._Store.storageName}-batches` ); // Load Events from Session Storage in case we have any in storage @@ -261,10 +255,14 @@ export class BatchUploader { this.eventsQueuedForProcessing.push(event); if (this.offlineStorageEnabled && this.eventVault) { - this.eventVault.store(this.eventsQueuedForProcessing); + try { + this.eventVault.store(this.eventsQueuedForProcessing); + } catch (error) { + Logger.error('Failed to store events to offline storage. Events will remain in memory queue.'); + } } - Logger.verbose(`Queuing event: ${JSON.stringify(event)}`); + Logger.verbose(`Queuing event: ${JSON.stringify(obfuscateData(event))}`); Logger.verbose(`Queued event count: ${this.eventsQueuedForProcessing.length}`); if (this.shouldTriggerImmediateUpload(event.EventDataType)) { @@ -377,7 +375,11 @@ export class BatchUploader { this.eventsQueuedForProcessing = []; if (this.offlineStorageEnabled && this.eventVault) { - this.eventVault.store([]); + try { + this.eventVault.store([]); + } catch (error) { + this.mpInstance.Logger.error('Failed to clear events from offline storage.'); + } } let newBatches: Batch[] = []; @@ -429,10 +431,13 @@ export class BatchUploader { // therefore NOT overwrite Offline Storage when beacon returns, so that we can retry // uploading saved batches at a later time. Batches should only be removed from // Local Storage once we can confirm they are successfully uploaded. - this.batchVault.store(this.batchesQueuedForProcessing); - - // Clear batch queue since everything should be in Offline Storage - this.batchesQueuedForProcessing = []; + try { + this.batchVault.store(this.batchesQueuedForProcessing); + // Clear batch queue since everything should be in Offline Storage + this.batchesQueuedForProcessing = []; + } catch (error) { + this.mpInstance.Logger.error('Failed to store batches to offline storage. Batches will remain in memory queue.'); + } } if (triggerFuture) { @@ -454,7 +459,7 @@ export class BatchUploader { return null; } - logger.verbose(`Uploading batches: ${JSON.stringify(uploads)}`); + logger.verbose(`Uploading batches: ${JSON.stringify(obfuscateData(uploads))}`); logger.verbose(`Batch count: ${uploads.length}`); for (let i = 0; i < uploads.length; i++) { diff --git a/src/foregroundTimeTracker.ts b/src/foregroundTimeTracker.ts index c64ca3eff..c872300f3 100644 --- a/src/foregroundTimeTracker.ts +++ b/src/foregroundTimeTracker.ts @@ -64,7 +64,11 @@ export default class ForegroundTimeTracker { public updateTimeInPersistence(): void { if (this.isTrackerActive) { - this.timerVault.store(Math.round(this.totalTime)); + try { + this.timerVault.store(Math.round(this.totalTime)); + } catch (error) { + // Silently fail - time tracking persistence is not critical for SDK functionality + } } } diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 3472d9f08..e37392be4 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -98,7 +98,11 @@ export const cacheIdentityRequest = ( status, expireTimestamp, }; - idCache.store(cache); + try { + idCache.store(cache); + } catch (error) { + // Silently fail - identity caching is an optimization, not critical for functionality + } }; // We need to ensure that identities are concatenated in a deterministic way, so @@ -233,7 +237,11 @@ export const removeExpiredIdentityCacheDates = ( } } - idCache.store(cache); + try { + idCache.store(cache); + } catch (error) { + // Silently fail - identity caching is an optimization, not critical for functionality + } }; export const tryCacheIdentity = ( diff --git a/src/identityApiClient.ts b/src/identityApiClient.ts index 5913caf14..abe12c1bd 100644 --- a/src/identityApiClient.ts +++ b/src/identityApiClient.ts @@ -6,7 +6,7 @@ import { IFetchPayload, } from './uploaders'; import { CACHE_HEADER } from './identity-utils'; -import { parseNumber, valueof } from './utils'; +import { obfuscateData, parseNumber, valueof } from './utils'; import { IAliasCallback, IAliasRequest, @@ -277,8 +277,16 @@ export default function IdentityAPIClient( } } else { - message = 'Received Identity Response from server: '; - message += JSON.stringify(identityResponse.responseText); + const responseText = identityResponse.responseText; + const { matched_identities, ...rest } = responseText || {}; + const obfuscatedMatchedIdentities = obfuscateData(matched_identities); + + // Obfuscate matched_identities to prevent PII exposure + const responseToLog = matched_identities + ? { ...rest, matched_identities: obfuscatedMatchedIdentities } + : responseText; + + message = 'Received Identity Response from server: ' + JSON.stringify(responseToLog); } break; diff --git a/src/mp-instance.ts b/src/mp-instance.ts index 6da3b77b0..7c1a6ff69 100644 --- a/src/mp-instance.ts +++ b/src/mp-instance.ts @@ -1154,7 +1154,7 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan * @method setOptOut * @param {Boolean} isOptingOut boolean to opt out or not. When set to true, opt out of logging. */ - this.setOptOut = function(isOptingOut) { + this.setOptOut = function(isOptingOut: boolean) { const queued = queueIfNotInitialized(function() { self.setOptOut(isOptingOut); }, self); @@ -1547,9 +1547,7 @@ function createKitBlocker(config, mpInstance) { } function createIdentityCache(mpInstance) { - return new LocalStorageVault(`${mpInstance._Store.storageName}-id-cache`, { - logger: mpInstance.Logger, - }); + return new LocalStorageVault(`${mpInstance._Store.storageName}-id-cache`); } function runPreConfigFetchInitialization(mpInstance, apiKey, config) { diff --git a/src/roktManager.ts b/src/roktManager.ts index 44a3edeee..6fa470550 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -9,6 +9,7 @@ import { isFunction, AttributeValue, isEmpty, + obfuscateData, } from "./utils"; import { SDKIdentityApi } from "./identity.interfaces"; import { SDKLoggerApi } from "./sdkRuntimeModels"; @@ -202,7 +203,7 @@ export default class RoktManager { const { attributes } = options; const sandboxValue = attributes?.sandbox || null; const mappedAttributes = this.mapPlacementAttributes(attributes, this.placementAttributesMapping); - this.logger?.verbose(`mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify(attributes, null, 2)}`); + this.logger?.verbose(`mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify(obfuscateData(attributes), null, 2)}`); this.currentUser = this.identityService.getCurrentUser(); const currentUserIdentities = this.currentUser?.getUserIdentities()?.userIdentities || {}; @@ -241,7 +242,7 @@ export default class RoktManager { newIdentities[this.mappedEmailShaIdentityType] = newHashedEmail; this.logger.warning(`emailsha256 mismatch detected. Current mParticle hashedEmail differs from hashedEmail passed to selectPlacements call. Proceeding to call identify with hashedEmail from selectPlacements call. Please verify your implementation.`); } - + if (!isEmpty(newIdentities)) { // Call identify with the new user identities try { @@ -256,7 +257,8 @@ export default class RoktManager { }); }); } catch (error) { - this.logger.error('Failed to identify user with new email: ' + JSON.stringify(error)); + const errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + this.logger.error('Failed to identify user with updated identities: ' + errorMessage); } } @@ -472,7 +474,7 @@ export default class RoktManager { return; } - this.logger?.verbose(`RoktManager: Processing queued message: ${message.methodName} with payload: ${JSON.stringify(message.payload)}`); + this.logger?.verbose(`RoktManager: Processing queued message: ${message.methodName} with payload: ${JSON.stringify(obfuscateData(message.payload))}`); // Capture resolve/reject functions before async processing const resolve = message.resolve; diff --git a/src/utils.ts b/src/utils.ts index 4e44ea914..b1ba5f7fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -426,12 +426,53 @@ const filterDictionaryWithHash = ( } const parseConfig = (config: SDKInitConfig, moduleName: string, moduleId: number): IKitConfigs | null => { - return config.kitConfigs?.find((kitConfig: IKitConfigs) => - kitConfig.name === moduleName && + return config.kitConfigs?.find((kitConfig: IKitConfigs) => + kitConfig.name === moduleName && kitConfig.moduleId === moduleId ) || null; } +/** + * Obfuscates an object by replacing all primitive values with their type names, + * while preserving the structure of nested objects and arrays. + * This is useful for logging data structures without exposing PII. + * + * @param value - The value to obfuscate + * @returns The obfuscated value with types instead of actual values + * + * @example + * obfuscateData({ email: 'user@email.com', age: 30 }) + * // Returns: { email: 'string', age: 'number' } + * + * obfuscateData({ tags: ['premium', 'verified'] }) + * // Returns: { tags: ['string', 'string'] } + */ +const obfuscateData = (value: any): any => { + // Preserve null and undefined + if (value === null || value === undefined) { + return value; + } + + // Handle arrays - recursively obfuscate each element + if (Array.isArray(value)) { + return value.map(item => obfuscateData(item)); + } + + // Handle objects - recursively obfuscate each property + if (isObject(value)) { + const obfuscated: Dictionary = {}; + for (const key in value) { + if (value.hasOwnProperty(key)) { + obfuscated[key] = obfuscateData(value[key]); + } + } + return obfuscated; + } + + // For primitives and other types, return the type as a string + return typeof value; +}; + export { createCookieString, revertCookieString, @@ -449,6 +490,7 @@ export { inArray, isObject, isStringOrNumber, + obfuscateData, parseConfig, parseNumber, parseSettingsString, diff --git a/src/vault.ts b/src/vault.ts index 57d2a6dc1..b680f5166 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -1,38 +1,22 @@ -import { Logger } from '@mparticle/web-sdk'; import { isEmpty, isNumber } from './utils'; -export interface IVaultOptions { - logger?: Logger; - offlineStorageEnabled?: boolean; -} - export abstract class BaseVault { public contents: StorableItem; protected readonly _storageKey: string; - protected logger?: Logger; protected storageObject: Storage; /** * * @param {string} storageKey the local storage key string - * @param {Storage} Web API Storage object that is being used - * @param {IVaultOptions} options A Dictionary of IVaultOptions + * @param {Storage} storageObject Web API Storage object that is being used */ constructor( storageKey: string, - storageObject: Storage, - options?: IVaultOptions + storageObject: Storage ) { this._storageKey = storageKey; this.storageObject = storageObject; - // Add a fake logger in case one is not provided or needed - this.logger = options?.logger || { - verbose: () => {}, - warning: () => {}, - error: () => {}, - }; - this.contents = this.retrieve(); } @@ -53,13 +37,8 @@ export abstract class BaseVault { try { this.storageObject.setItem(this._storageKey, stringifiedItem); - - this.logger.verbose(`Saving item to Storage: ${stringifiedItem}`); } catch (error) { - this.logger.error( - `Cannot Save items to Storage: ${stringifiedItem}` - ); - this.logger.error(error as string); + throw new Error('Cannot Save items to Storage'); } } @@ -75,8 +54,6 @@ export abstract class BaseVault { this.contents = item ? JSON.parse(item) : null; - this.logger.verbose(`Retrieving item from Storage: ${item}`); - return this.contents; } @@ -86,20 +63,19 @@ export abstract class BaseVault { * @method purge */ public purge(): void { - this.logger.verbose('Purging Storage'); this.contents = null; this.storageObject.removeItem(this._storageKey); } } export class LocalStorageVault extends BaseVault { - constructor(storageKey: string, options?: IVaultOptions) { - super(storageKey, window.localStorage, options); + constructor(storageKey: string) { + super(storageKey, window.localStorage); } } export class SessionStorageVault extends BaseVault { - constructor(storageKey: string, options?: IVaultOptions) { - super(storageKey, window.sessionStorage, options); + constructor(storageKey: string) { + super(storageKey, window.sessionStorage); } } \ No newline at end of file diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index 8c6ed118c..6e6641398 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -2389,7 +2389,7 @@ describe('RoktManager', () => { await roktManager.selectPlacements(options); expect(mockMPInstance.Logger.verbose).toHaveBeenCalledWith( - `mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify({ email: 'test@example.com', customAttr: 'value' }, null, 2)}` + `mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify({ email: 'string', customAttr: 'string' }, null, 2)}` ); }); }); diff --git a/test/jest/utils.spec.ts b/test/jest/utils.spec.ts index 4545e691e..961684b63 100644 --- a/test/jest/utils.spec.ts +++ b/test/jest/utils.spec.ts @@ -11,6 +11,7 @@ import { filterDictionaryWithHash, parseConfig, parseSettingsString, + obfuscateData, } from '../../src/utils'; import { deleteAllCookies } from './utils'; @@ -387,4 +388,249 @@ describe('Utils', () => { expect(() => parseSettingsString(settingsString)).toThrow('Settings string contains invalid JSON'); }); }); + + describe('#obfuscateData', () => { + describe('primitives', () => { + it('should replace string values with "string"', () => { + expect(obfuscateData('user@email.com')).toBe('string'); + expect(obfuscateData('John Doe')).toBe('string'); + expect(obfuscateData('')).toBe('string'); + }); + + it('should replace number values with "number"', () => { + expect(obfuscateData(42)).toBe('number'); + expect(obfuscateData(0)).toBe('number'); + expect(obfuscateData(-123.456)).toBe('number'); + expect(obfuscateData(NaN)).toBe('number'); + expect(obfuscateData(Infinity)).toBe('number'); + }); + + it('should replace boolean values with "boolean"', () => { + expect(obfuscateData(true)).toBe('boolean'); + expect(obfuscateData(false)).toBe('boolean'); + }); + + it('should preserve null', () => { + expect(obfuscateData(null)).toBe(null); + }); + + it('should preserve undefined', () => { + expect(obfuscateData(undefined)).toBe(undefined); + }); + + it('should replace function values with "function"', () => { + const fn = () => {}; + expect(obfuscateData(fn)).toBe('function'); + }); + }); + + describe('objects', () => { + it('should obfuscate flat objects', () => { + const input = { + email: 'user@email.com', + age: 30, + verified: true, + }; + + expect(obfuscateData(input)).toEqual({ + email: 'string', + age: 'number', + verified: 'boolean', + }); + }); + + it('should recursively obfuscate nested objects', () => { + const input = { + user: { + name: 'John Doe', + age: 30, + address: { + street: '123 Main St', + city: 'Springfield', + }, + }, + }; + + expect(obfuscateData(input)).toEqual({ + user: { + name: 'string', + age: 'number', + address: { + street: 'string', + city: 'string', + }, + }, + }); + }); + + it('should preserve null values in objects', () => { + const input = { + name: 'John', + metadata: null, + age: 30, + }; + + expect(obfuscateData(input)).toEqual({ + name: 'string', + metadata: null, + age: 'number', + }); + }); + + it('should handle empty objects', () => { + expect(obfuscateData({})).toEqual({}); + }); + }); + + describe('arrays', () => { + it('should obfuscate arrays of primitives', () => { + expect(obfuscateData(['premium', 'verified'])).toEqual(['string', 'string']); + expect(obfuscateData([1, 2, 3])).toEqual(['number', 'number', 'number']); + expect(obfuscateData([true, false])).toEqual(['boolean', 'boolean']); + }); + + it('should obfuscate arrays of objects', () => { + const input = [ + { id: 123, name: 'Product A' }, + { id: 456, name: 'Product B' }, + ]; + + expect(obfuscateData(input)).toEqual([ + { id: 'number', name: 'string' }, + { id: 'number', name: 'string' }, + ]); + }); + + it('should handle arrays with mixed types', () => { + const input = ['text', 42, true, null, undefined]; + + expect(obfuscateData(input)).toEqual(['string', 'number', 'boolean', null, undefined]); + }); + + it('should recursively obfuscate nested arrays', () => { + const input = [ + ['a', 'b'], + ['c', 'd'], + ]; + + expect(obfuscateData(input)).toEqual([ + ['string', 'string'], + ['string', 'string'], + ]); + }); + + it('should handle empty arrays', () => { + expect(obfuscateData([])).toEqual([]); + }); + }); + + describe('complex nested structures', () => { + it('should obfuscate complex event-like structures', () => { + const input = { + eventName: 'purchase', + user: { + email: 'user@email.com', + age: 30, + verified: true, + metadata: null, + }, + tags: ['premium', 'verified'], + items: [ + { id: 123, name: 'Product A', price: 29.99 }, + { id: 456, name: 'Product B', price: 49.99 }, + ], + }; + + expect(obfuscateData(input)).toEqual({ + eventName: 'string', + user: { + email: 'string', + age: 'number', + verified: 'boolean', + metadata: null, + }, + tags: ['string', 'string'], + items: [ + { id: 'number', name: 'string', price: 'number' }, + { id: 'number', name: 'string', price: 'number' }, + ], + }); + }); + + it('should handle objects with arrays of nested objects', () => { + const input = { + users: [ + { + name: 'John', + contacts: [ + { type: 'email', value: 'john@email.com' }, + { type: 'phone', value: '555-1234' }, + ], + }, + { + name: 'Jane', + contacts: [ + { type: 'email', value: 'jane@email.com' }, + ], + }, + ], + }; + + expect(obfuscateData(input)).toEqual({ + users: [ + { + name: 'string', + contacts: [ + { type: 'string', value: 'string' }, + { type: 'string', value: 'string' }, + ], + }, + { + name: 'string', + contacts: [ + { type: 'string', value: 'string' }, + ], + }, + ], + }); + }); + }); + + describe('edge cases', () => { + it('should handle objects with special values', () => { + const input = { + nan: NaN, + infinity: Infinity, + negativeInfinity: -Infinity, + }; + + expect(obfuscateData(input)).toEqual({ + nan: 'number', + infinity: 'number', + negativeInfinity: 'number', + }); + }); + + it('should not mutate the original object', () => { + const input = { + email: 'user@email.com', + age: 30, + }; + + const original = { ...input }; + obfuscateData(input); + + expect(input).toEqual(original); + }); + + it('should not mutate the original array', () => { + const input = ['test', 123, true]; + const original = [...input]; + + obfuscateData(input); + + expect(input).toEqual(original); + }); + }); + }); }); \ No newline at end of file