Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ mParticle is a Customer Data Platform that collects, validates, and forwards eve
- **No Unnecessary Features**: Don't add error handling for scenarios that can't happen
- **Trust Internal Code**: Only validate at system boundaries (user input, external APIs)

### Logging and PII Obfuscation

**PII (Personally Identifiable Information)** is data that can identify a specific person, for example: email, name or phone number, and similar attributes. Logging raw payloads can expose PII in production.

- **When utilizing the Logger to log payloads or data that may contain PII:** Only when `isDevelopmentMode` is true allow raw data, otherwise always obfuscate. Use `obfuscateDevData(data, isDevelopmentMode)` from `utils`. Get the flag from the store or instance (e.g. `SDKConfig?.isDevelopmentMode`).

### Testing Requirements

- Run the full test suite before committing
Expand Down
41 changes: 24 additions & 17 deletions src/batchUploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, obfuscateDevData } from './utils';
import { SessionStorageVault, LocalStorageVault } from './vault';
import {
AsyncUploader,
Expand Down Expand Up @@ -76,17 +76,11 @@ export class BatchUploader {

if (this.offlineStorageEnabled) {
this.eventVault = new SessionStorageVault<SDKEvent[]>(
`${mpInstance._Store.storageName}-events`,
{
logger: mpInstance.Logger,
}
`${mpInstance._Store.storageName}-events`
);

this.batchVault = new LocalStorageVault<Batch[]>(
`${mpInstance._Store.storageName}-batches`,
{
logger: mpInstance.Logger,
}
`${mpInstance._Store.storageName}-batches`
);

// Load Events from Session Storage in case we have any in storage
Expand Down Expand Up @@ -261,10 +255,15 @@ 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)}`);
const eventToLog = obfuscateDevData(event, this.mpInstance._Store.SDKConfig.isDevelopmentMode);
Logger.verbose(`Queuing event: ${JSON.stringify(eventToLog)}`);
Logger.verbose(`Queued event count: ${this.eventsQueuedForProcessing.length}`);

if (this.shouldTriggerImmediateUpload(event.EventDataType)) {
Expand Down Expand Up @@ -377,7 +376,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[] = [];
Expand Down Expand Up @@ -429,10 +432,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) {
Expand All @@ -454,7 +460,8 @@ export class BatchUploader {
return null;
}

logger.verbose(`Uploading batches: ${JSON.stringify(uploads)}`);
const uploadsToLog = obfuscateDevData(uploads, this.mpInstance._Store.SDKConfig.isDevelopmentMode);
logger.verbose(`Uploading batches: ${JSON.stringify(uploadsToLog)}`);
logger.verbose(`Batch count: ${uploads.length}`);

for (let i = 0; i < uploads.length; i++) {
Expand Down
6 changes: 5 additions & 1 deletion src/foregroundTimeTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/identity-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
14 changes: 11 additions & 3 deletions src/identityApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -278,8 +278,16 @@ export default function IdentityAPIClient(
}

} else {
message = 'Received Identity Response from server: ';
message += JSON.stringify(identityResponse.responseText);
const responseText = identityResponse.responseText;
const { isDevelopmentMode } = mpInstance._Store.SDKConfig;
let responseToLog = responseText;
if (!isDevelopmentMode && responseText?.matched_identities) {
responseToLog = {
...responseText,
matched_identities: obfuscateData(responseText.matched_identities)
};
}
message = 'Received Identity Response from server: ' + JSON.stringify(responseToLog);
}

break;
Expand Down
6 changes: 2 additions & 4 deletions src/mp-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,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);
Expand Down Expand Up @@ -1584,9 +1584,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) {
Expand Down
12 changes: 8 additions & 4 deletions src/roktManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isFunction,
AttributeValue,
isEmpty,
obfuscateDevData,
} from "./utils";
import { SDKIdentityApi } from "./identity.interfaces";
import { SDKLoggerApi } from "./sdkRuntimeModels";
Expand Down Expand Up @@ -234,7 +235,8 @@ 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)}`);
const attributesToLog = obfuscateDevData(attributes, this.store?.SDKConfig?.isDevelopmentMode);
this.logger?.verbose(`mParticle.Rokt selectPlacements called with attributes:\n${JSON.stringify(attributesToLog, null, 2)}`);

this.currentUser = this.identityService.getCurrentUser();
const currentUserIdentities = this.currentUser?.getUserIdentities()?.userIdentities || {};
Expand Down Expand Up @@ -273,7 +275,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 {
Expand All @@ -288,7 +290,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);
}
}

Expand Down Expand Up @@ -504,7 +507,8 @@ export default class RoktManager {
return;
}

this.logger?.verbose(`RoktManager: Processing queued message: ${message.methodName} with payload: ${JSON.stringify(message.payload)}`);
const payloadToLog = obfuscateDevData(message.payload, this.store?.SDKConfig?.isDevelopmentMode);
this.logger?.verbose(`RoktManager: Processing queued message: ${message.methodName} with payload: ${JSON.stringify(payloadToLog)}`);

// Capture resolve/reject functions before async processing
const resolve = message.resolve;
Expand Down
65 changes: 63 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,71 @@ const filterDictionaryWithHash = <T>(
}

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<any> = {};
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;
};

/**
* For verbose logging: returns raw data when isDevelopmentMode is true, else obfuscated data.
* Used when logging payloads so PII is not exposed in production.
*
* @param data - The value to log
* @param isDevelopmentMode - When true, returns data unchanged, when false or undefined, returns obfuscated data
* @returns Raw data when isDevelopmentMode is true, otherwise obfuscated structure
*
* @example
* obfuscateDevData({ email: 'user@email.com' }, true)
* // Returns: { email: 'user@email.com' }
*
* obfuscateDevData({ email: 'user@email.com' }, false)
* // Returns: { email: 'string' }
*/
const obfuscateDevData = (data: any, isDevelopmentMode?: boolean): any =>
isDevelopmentMode ? data : obfuscateData(data);

export {
createCookieString,
revertCookieString,
Expand All @@ -440,6 +499,7 @@ export {
AttributeValue,
converted,
decoded,
obfuscateDevData,
filterDictionaryWithHash,
findKeyInObject,
generateDeprecationMessage,
Expand All @@ -449,6 +509,7 @@ export {
inArray,
isObject,
isStringOrNumber,
obfuscateData,
parseConfig,
parseNumber,
parseSettingsString,
Expand Down
Loading
Loading