diff --git a/src/constants.ts b/src/constants.ts index bf513a5d..c5788b56 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -139,6 +139,8 @@ const Constants = { identityUrl: 'identity.mparticle.com/v1/', aliasUrl: 'jssdks.mparticle.com/v1/identity/', userAudienceUrl: 'nativesdks.mparticle.com/v1/', + loggingUrl: 'apps.rokt.com/v1/log/', + errorUrl: 'apps.rokt.com/v1/errors/', }, // These are the paths that are used to construct the CNAME urls CNAMEUrlPaths: { @@ -148,6 +150,8 @@ const Constants = { configUrl: '/tags/JS/v2/', identityUrl: '/identity/v1/', aliasUrl: '/webevents/v1/identity/', + loggingUrl: '/v1/log/', + errorUrl: '/v1/errors/', }, Base64CookieKeys: { csm: 1, diff --git a/src/identityApiClient.ts b/src/identityApiClient.ts index e357a645..f75b6507 100644 --- a/src/identityApiClient.ts +++ b/src/identityApiClient.ts @@ -25,6 +25,7 @@ import { IIdentityResponse, } from './identity-user-interfaces'; import { IMParticleWebSDKInstance } from './mp-instance'; +import { ErrorCodes } from './logging/errorCodes'; const { HTTPCodes, Messages, IdentityMethods } = Constants; @@ -300,10 +301,13 @@ export default function IdentityAPIClient( ); } catch (err) { mpInstance._Store.identityCallInFlight = false; - + const errorMessage = (err as Error).message || err.toString(); - Logger.error('Error sending identity request to servers' + ' - ' + errorMessage); + Logger.error( + 'Error sending identity request to servers' + ' - ' + errorMessage, + ErrorCodes.IDENTITY_REQUEST + ); invokeCallback( callback, HTTPCodes.noHttpCoverage, diff --git a/src/logger.ts b/src/logger.ts index 6ec19c05..59e03d0c 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,6 @@ import { LogLevelType, SDKInitConfig, SDKLoggerApi } from './sdkRuntimeModels'; +import { IReportingLogger } from './logging/reportingLogger'; +import { ErrorCodes } from './logging/errorCodes'; export type ILoggerConfig = Pick; export type IConsoleLogger = Partial>; @@ -6,10 +8,14 @@ export type IConsoleLogger = Partial; + +export const ErrorCodes = { + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + UNHANDLED_EXCEPTION: 'UNHANDLED_EXCEPTION', + IDENTITY_REQUEST: 'IDENTITY_REQUEST', +} as const; \ No newline at end of file diff --git a/src/logging/logRequest.ts b/src/logging/logRequest.ts new file mode 100644 index 00000000..04ec1a86 --- /dev/null +++ b/src/logging/logRequest.ts @@ -0,0 +1,23 @@ +import { ErrorCodes } from "./errorCodes"; +export type ErrorCode = ErrorCodes | string; + +export type WSDKErrorSeverity = (typeof WSDKErrorSeverity)[keyof typeof WSDKErrorSeverity]; +export const WSDKErrorSeverity = { + ERROR: 'ERROR', + INFO: 'INFO', + WARNING: 'WARNING', +} as const; + + +export type ErrorsRequestBody = { + additionalInformation?: Record; + code: ErrorCode; + severity: WSDKErrorSeverity; + stackTrace?: string; + deviceInfo?: string; + integration?: string; + reporter?: string; + url?: string; + }; + +export type LogRequestBody = ErrorsRequestBody; \ No newline at end of file diff --git a/src/logging/reportingLogger.ts b/src/logging/reportingLogger.ts new file mode 100644 index 00000000..f28b0fe0 --- /dev/null +++ b/src/logging/reportingLogger.ts @@ -0,0 +1,178 @@ +import { ErrorCodes } from "./errorCodes"; +import { LogRequestBody, WSDKErrorSeverity } from "./logRequest"; +import { FetchUploader, IFetchPayload } from "../uploaders"; +import { IStore, SDKConfig } from "../store"; + +// QUESTION: Should we collapse the interface with the class? +export interface IReportingLogger { + error(msg: string, code?: ErrorCodes, stackTrace?: string): void; + warning(msg: string, code?: ErrorCodes): void; +} + +export class ReportingLogger implements IReportingLogger { + private readonly isEnabled: boolean; + private readonly reporter: string = 'mp-wsdk'; + private readonly integration: string = 'mp-wsdk'; + private readonly rateLimiter: IRateLimiter; + private integrationName: string; + private store: IStore; + + constructor( + private readonly config: SDKConfig, + private readonly sdkVersion: string, + private readonly launcherInstanceGuid?: string, + rateLimiter?: IRateLimiter, + ) { + this.isEnabled = this.isReportingEnabled(); + this.rateLimiter = rateLimiter ?? new RateLimiter(); + } + + public setIntegrationName(integrationName: string) { + this.integrationName = integrationName; + } + + public setStore(store: IStore) { + this.store = store; + } + + public info(msg: string, code?: ErrorCodes) { + this.sendLog(WSDKErrorSeverity.INFO, msg, code); + } + + public error(msg: string, code?: ErrorCodes, stackTrace?: string) { + this.sendError(WSDKErrorSeverity.ERROR, msg, code, stackTrace); + } + + public warning(msg: string, code?: ErrorCodes) { + this.sendError(WSDKErrorSeverity.WARNING, msg, code); + } + + private sendToServer(url: string,severity: WSDKErrorSeverity, msg: string, code?: ErrorCodes, stackTrace?: string): void { + if(!this.canSendLog(severity)) + return; + + const logRequest = this.getLogRequest(severity, msg, code, stackTrace); + const uploader = new FetchUploader(url); + const payload: IFetchPayload = { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(logRequest), + }; + uploader.upload(payload); + } + + private sendLog(severity: WSDKErrorSeverity, msg: string, code?: ErrorCodes, stackTrace?: string): void { + const url = this.getLoggingUrl(); + this.sendToServer(url, severity, msg, code, stackTrace); + } + private sendError(severity: WSDKErrorSeverity, msg: string, code?: ErrorCodes, stackTrace?: string): void { + const url = this.getErrorUrl(); + this.sendToServer(url, severity, msg, code, stackTrace); + } + + private getLogRequest(severity: WSDKErrorSeverity, msg: string, code?: ErrorCodes, stackTrace?: string): LogRequestBody { + return { + additionalInformation: { + message: msg, + version: this.getVersion(), + }, + severity: severity, + code: code ?? ErrorCodes.UNKNOWN_ERROR, + url: this.getUrl(), + deviceInfo: this.getUserAgent(), + stackTrace: stackTrace ?? '', + reporter: this.reporter, + integration: this.integration + }; + } + + private getVersion(): string { + return this.integrationName ?? this.sdkVersion; + } + + private isReportingEnabled(): boolean { + // QUESTION: Should isDebugModeEnabled take precedence over + // isFeatureFlagEnabled and rokt domain present? + return ( + this.isRoktDomainPresent() && + (this.isFeatureFlagEnabled() || + this.isDebugModeEnabled()) + ); + } + + private isRoktDomainPresent(): boolean { + return Boolean(window['ROKT_DOMAIN']); + } + + private isFeatureFlagEnabled(): boolean { + return this.config.isWebSdkLoggingEnabled; + } + + private isDebugModeEnabled(): boolean { + return ( + window. + location?. + search?. + toLowerCase()?. + includes('mp_enable_logging=true') ?? false + ); + } + + private canSendLog(severity: WSDKErrorSeverity): boolean { + return this.isEnabled && !this.isRateLimited(severity); + } + + private isRateLimited(severity: WSDKErrorSeverity): boolean { + return this.rateLimiter.incrementAndCheck(severity); + } + + private getUrl(): string { + return window.location.href; + } + + private getUserAgent(): string { + return window.navigator.userAgent; + } + + private getLoggingUrl = (): string => `https://${this.config.loggingUrl}`; + private getErrorUrl = (): string => `https://${this.config.errorUrl}`; + + private getHeaders(): IFetchPayload['headers'] { + const headers: Record = { + Accept: 'text/plain;charset=UTF-8', + 'Content-Type': 'application/json', + 'rokt-launcher-instance-guid': this.launcherInstanceGuid, + 'rokt-launcher-version': this.getVersion(), + 'rokt-wsdk-version': 'joint', + }; + + if (this.store?.getRoktAccountId()) { + headers['rokt-account-id'] = this.store.getRoktAccountId(); + } + + return headers as IFetchPayload['headers']; + } +} + +export interface IRateLimiter { + incrementAndCheck(severity: WSDKErrorSeverity): boolean; +} + +export class RateLimiter implements IRateLimiter { + private readonly rateLimits: Map = new Map([ + [WSDKErrorSeverity.ERROR, 10], + [WSDKErrorSeverity.WARNING, 10], + [WSDKErrorSeverity.INFO, 10], + ]); + private logCount: Map = new Map(); + + public incrementAndCheck(severity: WSDKErrorSeverity): boolean { + const count = this.logCount.get(severity) || 0; + const limit = this.rateLimits.get(severity) || 10; + + const newCount = count + 1; + this.logCount.set(severity, newCount); + + return newCount > limit; + } +} \ No newline at end of file diff --git a/src/mp-instance.ts b/src/mp-instance.ts index 3ebff5c7..a0fb9d03 100644 --- a/src/mp-instance.ts +++ b/src/mp-instance.ts @@ -41,7 +41,7 @@ import { LocalStorageVault } from './vault'; import { removeExpiredIdentityCacheDates } from './identity-utils'; import IntegrationCapture from './integrationCapture'; import { IPreInit, processReadyQueue } from './pre-init-utils'; -import { BaseEvent, MParticleWebSDK, SDKHelpersApi } from './sdkRuntimeModels'; +import { BaseEvent, MParticleWebSDK, SDKHelpersApi, SDKInitConfig } from './sdkRuntimeModels'; import { Dictionary, SDKEventAttrs } from '@mparticle/web-sdk'; import { IIdentity } from './identity.interfaces'; import { IEvents } from './events.interfaces'; @@ -51,6 +51,8 @@ import { IPersistence } from './persistence.interfaces'; import ForegroundTimer from './foregroundTimeTracker'; import RoktManager, { IRoktOptions } from './roktManager'; import filteredMparticleUser from './filteredMparticleUser'; +import { IReportingLogger, ReportingLogger } from './logging/reportingLogger'; +import { SDKConfigManager } from './sdkConfigManager'; export interface IErrorLogMessage { message?: string; @@ -82,6 +84,7 @@ export interface IMParticleWebSDKInstance extends MParticleWebSDK { _IntegrationCapture: IntegrationCapture; _NativeSdkHelpers: INativeSdkHelpers; _Persistence: IPersistence; + _ReportingLogger: IReportingLogger; _RoktManager: RoktManager; _SessionManager: ISessionManager; _ServerModel: IServerModel; @@ -90,6 +93,7 @@ export interface IMParticleWebSDKInstance extends MParticleWebSDK { _preInit: IPreInit; _timeOnSiteTimer: ForegroundTimer; setLauncherInstanceGuid: () => void; + getLauncherInstanceGuid: () => string; captureTiming(metricName: string); } @@ -223,11 +227,11 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan } }; - this._resetForTests = function(config, keepPersistence, instance) { + this._resetForTests = function(config, keepPersistence, instance, reportingLogger?: IReportingLogger) { if (instance._Store) { delete instance._Store; } - instance.Logger = new Logger(config); + instance.Logger = new Logger(config, reportingLogger); instance._Store = new Store(config, instance); instance._Store.isLocalStorageAvailable = instance._Persistence.determineLocalStorageAvailability( window.localStorage @@ -1352,6 +1356,10 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan window[launcherInstanceGuidKey] = self._Helpers.generateUniqueId(); } }; + + this.getLauncherInstanceGuid = function() { + return window[launcherInstanceGuidKey]; + }; this.captureTiming = function(metricsName) { if (typeof window !== 'undefined' && window.performance?.mark) { @@ -1418,6 +1426,7 @@ function completeSDKInitialization(apiKey, config, mpInstance) { // Configure Rokt Manager with user and filtered user const roktConfig: IKitConfigs = parseConfig(config, 'Rokt', 181); if (roktConfig) { + mpInstance._Store.setRoktAccountId(roktConfig.settings?.accountId ?? undefined); const { userAttributeFilters } = roktConfig; const roktFilteredUser = filteredMparticleUser( currentUserMPID, @@ -1550,9 +1559,21 @@ function createIdentityCache(mpInstance) { } function runPreConfigFetchInitialization(mpInstance, apiKey, config) { - mpInstance.Logger = new Logger(config); - mpInstance._Store = new Store(config, mpInstance, apiKey); + + const sdkConfig = new SDKConfigManager(config, apiKey).getSDKConfig(); + mpInstance._ReportingLogger = new ReportingLogger( + sdkConfig, + Constants.sdkVersion, + mpInstance.getLauncherInstanceGuid(), + ); + mpInstance.Logger = new Logger(config, mpInstance._ReportingLogger); + mpInstance._Store = new Store( + { ...config, ...sdkConfig } as SDKInitConfig, + mpInstance, + apiKey + ); window.mParticle.Store = mpInstance._Store; + mpInstance._ReportingLogger.setStore(mpInstance._Store); mpInstance.Logger.verbose(StartingInitialization); // Check to see if localStorage is available before main configuration runs diff --git a/src/roktManager.ts b/src/roktManager.ts index 0039d7d3..38cbbafc 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -155,6 +155,7 @@ export default class RoktManager { } public attachKit(kit: IRoktKit): void { + // TODO: Pass back integrationName via kit this.kit = kit; this.processMessageQueue(); } diff --git a/src/sdkConfigManager.ts b/src/sdkConfigManager.ts new file mode 100644 index 00000000..aaf29388 --- /dev/null +++ b/src/sdkConfigManager.ts @@ -0,0 +1,177 @@ +import Constants from "./constants"; +import { SDKInitConfig } from "./sdkRuntimeModels"; +import { IFeatureFlags, SDKConfig } from "./store"; +import { Dictionary, isEmpty, parseNumber, returnConvertedBoolean } from "./utils"; + +export class SDKConfigManager { + private sdkConfig: SDKConfig; + + constructor(config: SDKInitConfig, apiKey: string){ + const sdkConfig = {} as SDKConfig; + + for (const prop in Constants.DefaultConfig) { + if (Constants.DefaultConfig.hasOwnProperty(prop)) { + config[prop] = Constants.DefaultConfig[prop]; + } + } + + if (config) { + for (const prop in config) { + if (config.hasOwnProperty(prop)) { + sdkConfig[prop] = config[prop]; + } + } + } + + for (const prop in Constants.DefaultBaseUrls) { + sdkConfig[prop] = Constants.DefaultBaseUrls[prop]; + + } + + sdkConfig.flags = this.processFlags(config); + sdkConfig.deviceId = config.deviceId ?? undefined; + sdkConfig.isDevelopmentMode = returnConvertedBoolean(config.isDevelopmentMode) ?? false; + sdkConfig.isWebSdkLoggingEnabled = returnConvertedBoolean(config.isWebSdkLoggingEnabled) ?? false; + sdkConfig.logLevel = config.logLevel ?? undefined; + + const baseUrls: Dictionary = processBaseUrls( + config, + sdkConfig.flags, + apiKey, + ); + + for (const baseUrlKeys in baseUrls) { + sdkConfig[baseUrlKeys] = baseUrls[baseUrlKeys]; + } + + this.sdkConfig = sdkConfig; + } + + public getSDKConfig(): SDKConfig { + return this.sdkConfig; + } + + private processFlags(config: SDKInitConfig): IFeatureFlags { + const flags: IFeatureFlags = {}; + const { + ReportBatching, + EventBatchingIntervalMillis, + OfflineStorage, + DirectUrlRouting, + CacheIdentity, + AudienceAPI, + CaptureIntegrationSpecificIds, + CaptureIntegrationSpecificIdsV2, + AstBackgroundEvents + } = Constants.FeatureFlags; + + if (!config.flags) { + return {}; + } + + // https://go.mparticle.com/work/SQDSDKS-6317 + // Passed in config flags take priority over defaults + flags[ReportBatching] = config.flags[ReportBatching] || false; + // The server returns stringified numbers, sowe need to parse + flags[EventBatchingIntervalMillis] = + parseNumber(config.flags[EventBatchingIntervalMillis]) || + Constants.DefaultConfig.uploadInterval; + flags[OfflineStorage] = config.flags[OfflineStorage] || '0'; + flags[DirectUrlRouting] = config.flags[DirectUrlRouting] === 'True'; + flags[CacheIdentity] = config.flags[CacheIdentity] === 'True'; + flags[AudienceAPI] = config.flags[AudienceAPI] === 'True'; + flags[CaptureIntegrationSpecificIds] = config.flags[CaptureIntegrationSpecificIds] === 'True'; + flags[CaptureIntegrationSpecificIdsV2] = (config.flags[CaptureIntegrationSpecificIdsV2] || 'none'); + flags[AstBackgroundEvents] = config.flags[AstBackgroundEvents] === 'True'; + return flags; + } + + +} + +function processBaseUrls(config: SDKInitConfig, flags: IFeatureFlags, apiKey: string): Dictionary { + // an API key is not present in a webview only mode. In this case, no baseUrls are needed + if (!apiKey) { + return {}; + } + + // When direct URL routing is false, update baseUrls based custom urls + // passed to the config + if (flags.directURLRouting) { + return processDirectBaseUrls(config, apiKey); + } else { + return processCustomBaseUrls(config); + } +} + +function processCustomBaseUrls(config: SDKInitConfig): Dictionary { + const defaultBaseUrls: Dictionary = Constants.DefaultBaseUrls; + const CNAMEUrlPaths: Dictionary = Constants.CNAMEUrlPaths; + + // newBaseUrls are default if the customer is not using a CNAME + // If a customer passes either config.domain or config.v3SecureServiceUrl, + // config.identityUrl, etc, the customer is using a CNAME. + // config.domain will take priority if a customer passes both. + const newBaseUrls: Dictionary = {}; + // If config.domain exists, the customer is using a CNAME. We append the url paths to the provided domain. + // This flag is set on the Rokt/MP snippet (starting at version 2.6), meaning config.domain will alwys be empty + // if a customer is using a snippet prior to 2.6. + if (!isEmpty(config.domain)) { + for (let pathKey in CNAMEUrlPaths) { + newBaseUrls[pathKey] = `${config.domain}${CNAMEUrlPaths[pathKey]}`; + } + + return newBaseUrls; + } + + for (let baseUrlKey in defaultBaseUrls) { + newBaseUrls[baseUrlKey] = + config[baseUrlKey] || defaultBaseUrls[baseUrlKey]; + } + + return newBaseUrls; +} + +function processDirectBaseUrls( + config: SDKInitConfig, + apiKey: string +): Dictionary { + const defaultBaseUrls = Constants.DefaultBaseUrls; + const directBaseUrls: Dictionary = {}; + // When Direct URL Routing is true, we create a new set of baseUrls that + // include the silo in the urls. mParticle API keys are prefixed with the + // silo and a hyphen (ex. "us1-", "us2-", "eu1-"). us1 was the first silo, + // and before other silos existed, there were no prefixes and all apiKeys + // were us1. As such, if we split on a '-' and the resulting array length + // is 1, then it is an older APIkey that should route to us1. + // When splitKey.length is greater than 1, then splitKey[0] will be + // us1, us2, eu1, au1, or st1, etc as new silos are added + const DEFAULT_SILO = 'us1'; + const splitKey: Array = apiKey.split('-'); + const routingPrefix: string = + splitKey.length <= 1 ? DEFAULT_SILO : splitKey[0]; + + for (let baseUrlKey in defaultBaseUrls) { + // Any custom endpoints passed to mpConfig will take priority over direct + // mapping to the silo. The most common use case is a customer provided CNAME. + if (baseUrlKey === 'configUrl') { + directBaseUrls[baseUrlKey] = + config[baseUrlKey] || defaultBaseUrls[baseUrlKey]; + continue; + } + + if (config.hasOwnProperty(baseUrlKey)) { + directBaseUrls[baseUrlKey] = config[baseUrlKey]; + } else { + const urlparts = defaultBaseUrls[baseUrlKey].split('.'); + + directBaseUrls[baseUrlKey] = [ + urlparts[0], + routingPrefix, + ...urlparts.slice(1), + ].join('.'); + } + } + + return directBaseUrls; +} \ No newline at end of file diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index b5f47a91..c9e62b40 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -41,6 +41,7 @@ import { IErrorLogMessage, IMParticleWebSDKInstance, IntegrationDelays } from '. import Constants from './constants'; import RoktManager, { IRoktLauncherOptions } from './roktManager'; import { IConsoleLogger } from './logger'; +import { ErrorCodes } from './logging/errorCodes'; // TODO: Resolve this with version in @mparticle/web-sdk export type SDKEventCustomFlags = Dictionary; @@ -311,6 +312,7 @@ export interface SDKInitConfig identityCallback?: IdentityCallback; launcherOptions?: IRoktLauncherOptions; + isWebSdkLoggingEnabled?: boolean; rq?: Function[] | any[]; logger?: IConsoleLogger; @@ -361,9 +363,9 @@ export interface SDKHelpersApi { } export interface SDKLoggerApi { - error(arg0: string): void; - verbose(arg0: string): void; - warning(arg0: string): void; + error(msg: string, code?: ErrorCodes): void; + verbose(msg: string): void; + warning(msg: string, code?: ErrorCodes): void; setLogLevel(logLevel: LogLevelType): void; } diff --git a/src/store.ts b/src/store.ts index 64d560dc..6c28023f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -96,6 +96,9 @@ export interface SDKConfig { webviewBridgeName?: string; workspaceToken?: string; requiredWebviewBridgeName?: string; + loggingUrl?: string; + errorUrl?: string; + isWebSdkLoggingEnabled?: boolean; } function createSDKConfig(config: SDKInitConfig): SDKConfig { @@ -200,6 +203,7 @@ export interface IStore { integrationDelayTimeoutStart: number; // UNIX Timestamp webviewBridgeEnabled?: boolean; wrapperSDKInfo: WrapperSDKInfo; + roktAccountId: string; persistenceData?: IPersistenceMinified; @@ -221,6 +225,8 @@ export interface IStore { setUserAttributes?(mpid: MPID, attributes: UserAttributes): void; getUserIdentities?(mpid: MPID): UserIdentities; setUserIdentities?(mpid: MPID, userIdentities: UserIdentities): void; + getRoktAccountId?(): string; + setRoktAccountId?(accountId: string): void; addMpidToSessionHistory?(mpid: MPID, previousMpid?: MPID): void; hasInvalidIdentifyRequest?: () => boolean; @@ -285,6 +291,7 @@ export default function Store( version: null, isInfoSet: false, }, + roktAccountId: null, // Placeholder for in-memory persistence model persistenceData: { @@ -303,41 +310,6 @@ export default function Store( this.SDKConfig = createSDKConfig(config); if (config) { - if (!config.hasOwnProperty('flags')) { - this.SDKConfig.flags = {}; - } - - // We process the initial config that is passed via the SDK init - // and then we will reprocess the config within the processConfig - // function when the config is updated from the server - // https://go.mparticle.com/work/SQDSDKS-6317 - this.SDKConfig.flags = processFlags(config); - - if (config.deviceId) { - this.deviceId = config.deviceId; - } - if (config.hasOwnProperty('isDevelopmentMode')) { - this.SDKConfig.isDevelopmentMode = returnConvertedBoolean( - config.isDevelopmentMode - ); - } else { - this.SDKConfig.isDevelopmentMode = false; - } - - const baseUrls: Dictionary = processBaseUrls( - config, - this.SDKConfig.flags, - apiKey - ); - - for (const baseUrlKeys in baseUrls) { - this.SDKConfig[baseUrlKeys] = baseUrls[baseUrlKeys]; - } - - if (config.hasOwnProperty('logLevel')) { - this.SDKConfig.logLevel = config.logLevel; - } - this.SDKConfig.useNativeSdk = !!config.useNativeSdk; this.SDKConfig.kits = config.kits || {}; @@ -663,6 +635,11 @@ export default function Store( this.setUserIdentities = (mpid: MPID, userIdentities: UserIdentities) => { this._setPersistence(mpid, 'ui', userIdentities); }; + + this.getRoktAccountId = () => this.roktAccountId; + this.setRoktAccountId = (accountId: string) => { + this.roktAccountId = accountId; + }; this.addMpidToSessionHistory = (mpid: MPID, previousMPID?: MPID): void => { const indexOfMPID = this.currentSessionMPIDs.indexOf(mpid); diff --git a/src/uploaders.ts b/src/uploaders.ts index e28606c4..c4e6b7e4 100644 --- a/src/uploaders.ts +++ b/src/uploaders.ts @@ -5,6 +5,7 @@ export interface IFetchPayload { headers: { Accept: string; 'Content-Type'?: string; + 'rokt-account-id'?: string; }; body?: string; } diff --git a/test/jest/reportingLogger.spec.ts b/test/jest/reportingLogger.spec.ts new file mode 100644 index 00000000..61a7f834 --- /dev/null +++ b/test/jest/reportingLogger.spec.ts @@ -0,0 +1,184 @@ +import { IRateLimiter, RateLimiter, ReportingLogger } from '../../src/logging/reportingLogger'; +import { WSDKErrorSeverity } from '../../src/logging/logRequest'; +import { ErrorCodes } from '../../src/logging/errorCodes'; +import { SDKConfig } from '../../src/store'; + +describe('ReportingLogger', () => { + let logger: ReportingLogger; + const baseUrl = 'https://test-url.com'; + const sdkVersion = '1.2.3'; + let mockFetch: jest.Mock; + const accountId = '1234567890'; + beforeEach(() => { + mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + delete (globalThis as any).location; + (globalThis as any).location = { + href: 'https://e.com', + search: '' + }; + + Object.assign(globalThis, { + navigator: { userAgent: 'ua' }, + mParticle: { config: { isWebSdkLoggingEnabled: true } }, + ROKT_DOMAIN: 'set', + fetch: mockFetch + }); + logger = new ReportingLogger( + { loggingUrl: baseUrl, errorUrl: baseUrl } as SDKConfig, + sdkVersion, + 'test-launcher-instance-guid' + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete (window as any).ROKT_DOMAIN; + delete (window as any).mParticle; + }); + + it('sends error logs with correct params', () => { + logger.error('msg', ErrorCodes.UNHANDLED_EXCEPTION, 'stack'); + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toContain('/v1/log'); + const body = JSON.parse(fetchCall[1].body); + expect(body).toMatchObject({ + severity: WSDKErrorSeverity.ERROR, + code: ErrorCodes.UNHANDLED_EXCEPTION, + stackTrace: 'stack' + }); + }); + + it('sends warning logs with correct params', () => { + logger.warning('warn'); + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toContain('/v1/log'); + const body = JSON.parse(fetchCall[1].body); + expect(body).toMatchObject({ + severity: WSDKErrorSeverity.WARNING + }); + expect(fetchCall[1].headers['rokt-account-id']).toBe(accountId); + }); + + it('does not log if ROKT_DOMAIN missing', () => { + delete (globalThis as any).ROKT_DOMAIN; + logger = new ReportingLogger( + { loggingUrl: baseUrl, errorUrl: baseUrl } as SDKConfig, + sdkVersion, + 'test-launcher-instance-guid' + ); + logger.error('x'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not log if feature flag and debug mode off', () => { + window.mParticle.config.isWebSdkLoggingEnabled = false; + window.location.search = ''; + logger = new ReportingLogger( + { loggingUrl: baseUrl, errorUrl: baseUrl } as SDKConfig, + sdkVersion, + 'test-launcher-instance-guid' + ); + logger.error('x'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('logs if debug mode on even if feature flag off', () => { + window.mParticle.config.isWebSdkLoggingEnabled = false; + window.location.search = '?mp_enable_logging=true'; + logger = new ReportingLogger( + { loggingUrl: baseUrl, errorUrl: baseUrl } as SDKConfig, + sdkVersion, + 'test-launcher-instance-guid' + ); + logger.error('x'); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('rate limits after 3 errors', () => { + let count = 0; + const mockRateLimiter: IRateLimiter = { + incrementAndCheck: jest.fn().mockImplementation((severity) => { + return ++count > 3; + }), + }; + logger = new ReportingLogger( + { loggingUrl: baseUrl, errorUrl: baseUrl } as SDKConfig, + sdkVersion, + 'test-launcher-instance-guid', + mockRateLimiter + ); + + for (let i = 0; i < 5; i++) logger.error('err'); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('uses default account id when accountId is empty', () => { + logger = new ReportingLogger( + { loggingUrl: baseUrl, errorUrl: baseUrl } as SDKConfig, + sdkVersion, + 'test-launcher-instance-guid' + ); + logger.error('msg'); + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[1].headers['rokt-account-id']).toBe('0'); + }); + + it('uses default user agent when user agent is empty', () => { + logger = new ReportingLogger( + { loggingUrl: baseUrl, errorUrl: baseUrl } as SDKConfig, + sdkVersion, + 'test-launcher-instance-guid' + ); + delete (globalThis as any).navigator; + delete (globalThis as any).location; + logger.error('msg'); + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body).toMatchObject({ deviceInfo: 'no-user-agent-set', url: 'no-url-set' }); + }); +}); + +describe('RateLimiter', () => { + let rateLimiter: RateLimiter; + beforeEach(() => { + rateLimiter = new RateLimiter(); + }); + + it('allows up to 10 error logs then rate limits', () => { + for (let i = 0; i < 10; i++) { + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.ERROR)).toBe(false); + } + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.ERROR)).toBe(true); + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.ERROR)).toBe(true); + }); + + it('allows up to 10 warning logs then rate limits', () => { + for (let i = 0; i < 10; i++) { + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.WARNING)).toBe(false); + } + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.WARNING)).toBe(true); + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.WARNING)).toBe(true); + }); + + it('allows up to 10 info logs then rate limits', () => { + for (let i = 0; i < 10; i++) { + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.INFO)).toBe(false); + } + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.INFO)).toBe(true); + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.INFO)).toBe(true); + }); + + it('tracks rate limits independently per severity', () => { + for (let i = 0; i < 10; i++) { + rateLimiter.incrementAndCheck(WSDKErrorSeverity.ERROR); + } + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.ERROR)).toBe(true); + expect(rateLimiter.incrementAndCheck(WSDKErrorSeverity.WARNING)).toBe(false); + }); +});