diff --git a/src/constants.ts b/src/constants.ts index bf513a5d1..3072e9bee 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-api.com/v1/log', + errorUrl: 'apps.rokt-api.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 0c686f075..70387259b 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/types'; const { HTTPCodes, Messages, IdentityMethods } = Constants; @@ -326,10 +327,13 @@ export default function IdentityAPIClient( const requestCount = mpInstance._Store.identifyRequestCount; mpInstance.captureTiming(`${requestCount}-identityRequestEnd`); } - + 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 + ); + mpInstance.processQueueOnIdentityFailure?.(); invokeCallback(callback, HTTPCodes.noHttpCoverage, errorMessage); } diff --git a/src/logger.ts b/src/logger.ts index 6ec19c056..8757f46fd 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,6 @@ import { LogLevelType, SDKInitConfig, SDKLoggerApi } from './sdkRuntimeModels'; +import { ReportingLogger } from './logging/reportingLogger'; +import { ErrorCodes } from './logging/types'; export type ILoggerConfig = Pick; export type IConsoleLogger = Partial>; @@ -6,10 +8,14 @@ export type IConsoleLogger = Partial { + console.error('ReportingLogger: Failed to send log', error); + }); + } catch (error) { + console.error('ReportingLogger: Failed to send log', error); + } + } + + private sendLog(severity: WSDKErrorSeverity, msg: string, code?: ErrorCodes, stackTrace?: string): void { + this.sendToServer(this.loggingUrl, severity, msg, code, stackTrace); + } + + private sendError(severity: WSDKErrorSeverity, msg: string, code?: ErrorCodes, stackTrace?: string): void { + this.sendToServer(this.errorUrl, severity, msg, code, stackTrace); + } + + private buildLogRequest(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.store?.getIntegrationName?.() ?? `mParticle_wsdkv_${this.sdkVersion}`; + } + + private isReportingEnabled(): boolean { + return this.isDebugModeEnabled() || + (this.isRoktDomainPresent() && this.isFeatureFlagEnabled()); + } + + private isRoktDomainPresent(): boolean { + return typeof window !== 'undefined' && Boolean(window['ROKT_DOMAIN']); + } + + private isFeatureFlagEnabled = (): boolean => this.isWebSdkLoggingEnabled; + + private isDebugModeEnabled(): boolean { + return ( + typeof window !== 'undefined' && + (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 | undefined { + return typeof window !== 'undefined' ? window.location?.href : undefined; + } + + private getUserAgent(): string | undefined { + return typeof window !== 'undefined' ? window.navigator?.userAgent : undefined; + } + + private getHeaders(): IReportingLoggerPayload['headers'] { + const headers: IReportingLoggerPayload['headers'] = { + Accept: 'text/plain;charset=UTF-8', + 'Content-Type': 'application/json', + 'rokt-launcher-version': this.getVersion(), + 'rokt-wsdk-version': 'joint', + }; + + if (this.launcherInstanceGuid) { + headers['rokt-launcher-instance-guid'] = this.launcherInstanceGuid; + } + + const accountId = this.store?.getRoktAccountId?.(); + if (accountId) { + headers['rokt-account-id'] = accountId; + } + + return 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; + } +} diff --git a/src/logging/types.ts b/src/logging/types.ts new file mode 100644 index 000000000..46c431158 --- /dev/null +++ b/src/logging/types.ts @@ -0,0 +1,32 @@ +import { valueof } from '../utils'; + +export const ErrorCodes = { + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + UNHANDLED_EXCEPTION: 'UNHANDLED_EXCEPTION', + IDENTITY_REQUEST: 'IDENTITY_REQUEST', +} as const; + +export type ErrorCodes = valueof; + +export type ErrorCode = ErrorCodes | string; + +export const WSDKErrorSeverity = { + ERROR: 'ERROR', + INFO: 'INFO', + WARNING: 'WARNING', +} as const; + +export type WSDKErrorSeverity = (typeof WSDKErrorSeverity)[keyof typeof WSDKErrorSeverity]; + +export type ErrorsRequestBody = { + additionalInformation?: Record; + code: ErrorCode; + severity: WSDKErrorSeverity; + stackTrace?: string; + deviceInfo?: string; + integration?: string; + reporter?: string; + url?: string; +}; + +export type LogRequestBody = ErrorsRequestBody; diff --git a/src/mp-instance.ts b/src/mp-instance.ts index 618feac05..86029e26b 100644 --- a/src/mp-instance.ts +++ b/src/mp-instance.ts @@ -52,6 +52,7 @@ import ForegroundTimer from './foregroundTimeTracker'; import RoktManager, { IRoktOptions } from './roktManager'; import filteredMparticleUser from './filteredMparticleUser'; import CookieConsentManager, { ICookieConsentManager } from './cookieConsentManager'; +import { ReportingLogger } from './logging/reportingLogger'; export interface IErrorLogMessage { message?: string; @@ -84,14 +85,16 @@ export interface IMParticleWebSDKInstance extends MParticleWebSDK { _NativeSdkHelpers: INativeSdkHelpers; _Persistence: IPersistence; _CookieConsentManager: ICookieConsentManager; + _ReportingLogger: ReportingLogger; _RoktManager: RoktManager; _SessionManager: ISessionManager; _ServerModel: IServerModel; _Store: IStore; _instanceName: string; _preInit: IPreInit; - _timeOnSiteTimer: ForegroundTimer; + _timeOnSiteTimer: ForegroundTimer; setLauncherInstanceGuid: () => void; + getLauncherInstanceGuid: () => string; captureTiming(metricName: string); processQueueOnIdentityFailure?: () => void; } @@ -245,11 +248,11 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan } }; - this._resetForTests = function(config, keepPersistence, instance) { + this._resetForTests = function(config, keepPersistence, instance, reportingLogger?: ReportingLogger) { 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 @@ -1372,7 +1375,7 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan }; } }; - + const launcherInstanceGuidKey = Constants.Rokt.LauncherInstanceGuidKey; this.setLauncherInstanceGuid = function() { if (!window[launcherInstanceGuidKey] @@ -1381,6 +1384,10 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan } }; + this.getLauncherInstanceGuid = function() { + return window[launcherInstanceGuidKey]; + }; + this.captureTiming = function(metricsName) { if (typeof window !== 'undefined' && window.performance?.mark) { window.performance.mark(metricsName); @@ -1446,6 +1453,8 @@ function completeSDKInitialization(apiKey, config, mpInstance) { // Configure Rokt Manager with user and filtered user const roktConfig: IKitConfigs = parseConfig(config, 'Rokt', 181); if (roktConfig) { + const accountId = roktConfig.settings?.accountId; + mpInstance._Store.setRoktAccountId(accountId); const { userAttributeFilters } = roktConfig; const roktFilteredUser = filteredMparticleUser( currentUserMPID, @@ -1579,9 +1588,16 @@ function createIdentityCache(mpInstance) { } function runPreConfigFetchInitialization(mpInstance, apiKey, config) { - mpInstance.Logger = new Logger(config); + mpInstance._ReportingLogger = new ReportingLogger( + config, + Constants.sdkVersion, + undefined, + mpInstance.getLauncherInstanceGuid(), + ); + mpInstance.Logger = new Logger(config, mpInstance._ReportingLogger); mpInstance._Store = new Store(config, mpInstance, apiKey); window.mParticle.Store = mpInstance._Store; + mpInstance._ReportingLogger.setStore(mpInstance._Store); mpInstance.Logger.verbose(StartingInitialization); // Initialize CookieConsentManager with privacy flags from launcherOptions diff --git a/src/roktManager.ts b/src/roktManager.ts index 2ee27c412..f871a646e 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -59,6 +59,12 @@ export interface RoktKitFilterSettings { filteredUser?: IMParticleUser | null; } +export interface IRoktKitSettings { + accountId?: string; + placementAttributesMapping?: string; + hashedEmailUserIdentityType?: string; +} + export interface IRoktKit { filters: RoktKitFilterSettings; filteredUser: IMParticleUser | null; @@ -69,6 +75,8 @@ export interface IRoktKit { setExtensionData(extensionData: IRoktPartnerExtensionData): void; use: (name: string) => Promise; launcherOptions?: Dictionary; + settings?: IRoktKitSettings; + integrationName?: string; } export interface IRoktOptions { @@ -181,6 +189,15 @@ export default class RoktManager { public attachKit(kit: IRoktKit): void { this.kit = kit; + + if (kit.settings?.accountId) { + this.store.setRoktAccountId(kit.settings.accountId); + } + + if (kit.integrationName) { + this.store?.setIntegrationName(kit.integrationName); + } + this.processMessageQueue(); try { @@ -588,4 +605,4 @@ export default class RoktManager { return false; // Values are the same } -} \ No newline at end of file +} diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index b5f47a919..9406cb5fc 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -7,18 +7,24 @@ import { SDKEventAttrs, Callback, } from '@mparticle/web-sdk'; -import { IntegrationAttribute, IntegrationAttributes, IStore, WrapperSDKTypes } from './store'; +import { + IntegrationAttribute, + IntegrationAttributes, + IStore, + WrapperSDKTypes, +} from './store'; import Validators from './validators'; import { Dictionary, valueof } from './utils'; import { IKitConfigs } from './configAPIClient'; import { SDKConsentApi, SDKConsentState } from './consent'; import MPSideloadedKit from './sideloadedKit'; import { ISessionManager } from './sessionManager'; -import { ConfiguredKit, MPForwarder, UnregisteredKit } from './forwarders.interfaces'; import { - SDKIdentityApi, - IAliasCallback, -} from './identity.interfaces'; + ConfiguredKit, + MPForwarder, + UnregisteredKit, +} from './forwarders.interfaces'; +import { SDKIdentityApi, IAliasCallback } from './identity.interfaces'; import { ISDKUserAttributeChangeData, ISDKUserIdentityChanges, @@ -36,11 +42,16 @@ import { } from './types'; import { IPixelConfiguration } from './cookieSyncManager'; import _BatchValidator from './mockBatchCreator'; -import { SDKECommerceAPI } from './ecommerce.interfaces'; -import { IErrorLogMessage, IMParticleWebSDKInstance, IntegrationDelays } from './mp-instance'; +import { SDKECommerceAPI } from './ecommerce.interfaces'; +import { + IErrorLogMessage, + IMParticleWebSDKInstance, + IntegrationDelays, +} from './mp-instance'; import Constants from './constants'; import RoktManager, { IRoktLauncherOptions } from './roktManager'; import { IConsoleLogger } from './logger'; +import { ErrorCodes } from './logging/types'; // TODO: Resolve this with version in @mparticle/web-sdk export type SDKEventCustomFlags = Dictionary; @@ -178,7 +189,7 @@ export interface MParticleWebSDK { _resetForTests( MPConfig?: SDKInitConfig, keepPersistence?: boolean, - instance?: IMParticleWebSDKInstance, + instance?: IMParticleWebSDKInstance ): void; configurePixel(config: IPixelConfiguration): void; endSession(): void; @@ -207,9 +218,24 @@ export interface MParticleWebSDK { ): void; logBaseEvent(event: BaseEvent, eventOptions?: SDKEventOptions): void; logError(error: IErrorLogMessage, attrs?: SDKEventAttrs): void; - logLink(selector: string, eventName: string, eventType: valueof, eventInfo: SDKEventAttrs): void; - logForm(selector: string, eventName: string, eventType: valueof, eventInfo: SDKEventAttrs): void; - logPageView(eventName?: string, attrs?: SDKEventAttrs, customFlags?: SDKEventCustomFlags, eventOptions?: SDKEventOptions): void; + logLink( + selector: string, + eventName: string, + eventType: valueof, + eventInfo: SDKEventAttrs + ): void; + logForm( + selector: string, + eventName: string, + eventType: valueof, + eventInfo: SDKEventAttrs + ): void; + logPageView( + eventName?: string, + attrs?: SDKEventAttrs, + customFlags?: SDKEventCustomFlags, + eventOptions?: SDKEventOptions + ): void; setOptOut(isOptingOut: boolean): void; eCommerce: SDKECommerceAPI; isInitialized(): boolean; @@ -227,7 +253,10 @@ export interface MParticleWebSDK { stopTrackingLocation(): void; generateHash(value: string): string; - setIntegrationAttribute(integrationModuleId: number, attrs: IntegrationAttribute): void; + setIntegrationAttribute( + integrationModuleId: number, + attrs: IntegrationAttribute + ): void; getIntegrationAttributes(integrationModuleId: number): IntegrationAttribute; captureTiming(metricName: string): void; } @@ -248,14 +277,13 @@ export interface IMParticleInstanceManager extends MParticleWebSDK { MPSideloadedKit: typeof MPSideloadedKit; Rokt: RoktManager; // https://go.mparticle.com/work/SQDSDKS-7060 - sessionManager: Pick; + sessionManager: Pick; Store: IStore; // Public Methods getInstance(instanceName?: string): IMParticleWebSDKInstance; } - // Used in cases where server requires booleans as strings export type BooleanStringLowerCase = 'false' | 'true'; export type BooleanStringTitleCase = 'False' | 'True'; @@ -311,6 +339,7 @@ export interface SDKInitConfig identityCallback?: IdentityCallback; launcherOptions?: IRoktLauncherOptions; + isWebSdkLoggingEnabled?: boolean; rq?: Function[] | any[]; logger?: IConsoleLogger; @@ -361,9 +390,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): void; setLogLevel(logLevel: LogLevelType): void; } diff --git a/src/store.ts b/src/store.ts index 9ea908dbe..3f0a63cc1 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 { @@ -120,6 +123,9 @@ function createSDKConfig(config: SDKInitConfig): SDKConfig { sdkConfig[prop] = Constants.DefaultBaseUrls[prop]; } + // Always initialize flags to at least an empty object to prevent undefined access + sdkConfig.flags = sdkConfig.flags || {}; + return sdkConfig; } @@ -202,6 +208,8 @@ export interface IStore { integrationDelayTimeoutStart: number; // UNIX Timestamp webviewBridgeEnabled?: boolean; wrapperSDKInfo: WrapperSDKInfo; + roktAccountId: string | null; + integrationName: string | null; persistenceData?: IPersistenceMinified; @@ -223,6 +231,10 @@ 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; + getIntegrationName?(): string; + setIntegrationName?(integrationName: string): void; addMpidToSessionHistory?(mpid: MPID, previousMpid?: MPID): void; hasInvalidIdentifyRequest?: () => boolean; @@ -289,6 +301,8 @@ export default function Store( version: null, isInfoSet: false, }, + roktAccountId: null, + integrationName: null, // Placeholder for in-memory persistence model persistenceData: { @@ -311,10 +325,6 @@ export default function Store( 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) { @@ -338,10 +348,6 @@ export default function Store( this.SDKConfig[baseUrlKeys] = baseUrls[baseUrlKeys]; } - if (config.hasOwnProperty('logLevel')) { - this.SDKConfig.logLevel = config.logLevel; - } - this.SDKConfig.useNativeSdk = !!config.useNativeSdk; this.SDKConfig.kits = config.kits || {}; @@ -667,6 +673,16 @@ 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.getIntegrationName = () => this.integrationName; + this.setIntegrationName = (integrationName: string) => { + this.integrationName = integrationName; + }; this.addMpidToSessionHistory = (mpid: MPID, previousMPID?: MPID): void => { const indexOfMPID = this.currentSessionMPIDs.indexOf(mpid); @@ -699,7 +715,10 @@ export default function Store( // We should reprocess the flags and baseUrls in case they have changed when we request an updated config // such as if the SDK is being self-hosted and the flags are different on the server config // https://go.mparticle.com/work/SQDSDKS-6317 - this.SDKConfig.flags = processFlags(config); + // Only update flags if the config actually has flags (don't overwrite with empty object) + if (config.flags) { + this.SDKConfig.flags = processFlags(config); + } const baseUrls: Dictionary = processBaseUrls( config, @@ -842,7 +861,7 @@ function processDirectBaseUrls( 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') { + if (baseUrlKey === 'configUrl' || baseUrlKey === 'loggingUrl' || baseUrlKey === 'errorUrl') { directBaseUrls[baseUrlKey] = config[baseUrlKey] || defaultBaseUrls[baseUrlKey]; continue; diff --git a/src/uploaders.ts b/src/uploaders.ts index e28606c46..c4e6b7e49 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/logger.spec.ts b/test/jest/logger.spec.ts index def3a637d..6dce7fe1b 100644 --- a/test/jest/logger.spec.ts +++ b/test/jest/logger.spec.ts @@ -1,5 +1,7 @@ import { Logger, ConsoleLogger } from '../../src/logger'; import { LogLevelType } from '../../src/sdkRuntimeModels'; +import { ReportingLogger } from '../../src/logging/reportingLogger'; +import { ErrorCodes } from '../../src/logging/types'; describe('Logger', () => { let mockConsole: any; @@ -103,6 +105,46 @@ describe('Logger', () => { expect(mockConsole.warn).toHaveBeenCalledWith('b'); expect(mockConsole.error).toHaveBeenCalledWith('c'); }); + + describe('ReportingLogger integration', () => { + let mockReportingLogger: jest.Mocked; + + beforeEach(() => { + mockReportingLogger = { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + setStore: jest.fn(), + } as any; + }); + + it('should call reportingLogger.error when error is called with error code', () => { + logger = new Logger({ logLevel: LogLevelType.Verbose }, mockReportingLogger); + + logger.error('test error', ErrorCodes.UNHANDLED_EXCEPTION); + + expect(mockConsole.error).toHaveBeenCalledWith('test error'); + expect(mockReportingLogger.error).toHaveBeenCalledWith('test error', ErrorCodes.UNHANDLED_EXCEPTION); + }); + + it('should NOT call reportingLogger.error when error is called without error code', () => { + logger = new Logger({ logLevel: LogLevelType.Verbose }, mockReportingLogger); + + logger.error('test error'); + + expect(mockConsole.error).toHaveBeenCalledWith('test error'); + expect(mockReportingLogger.error).not.toHaveBeenCalled(); + }); + + it('should NOT call reportingLogger when warning is called', () => { + logger = new Logger({ logLevel: LogLevelType.Verbose }, mockReportingLogger); + + logger.warning('test warning'); + + expect(mockConsole.warn).toHaveBeenCalledWith('test warning'); + expect(mockReportingLogger.warning).not.toHaveBeenCalled(); + }); + }); }); describe('ConsoleLogger', () => { diff --git a/test/jest/reportingLogger.spec.ts b/test/jest/reportingLogger.spec.ts new file mode 100644 index 000000000..8bbf86518 --- /dev/null +++ b/test/jest/reportingLogger.spec.ts @@ -0,0 +1,358 @@ +import { IRateLimiter, RateLimiter, ReportingLogger } from '../../src/logging/reportingLogger'; +import { WSDKErrorSeverity, ErrorCodes } from '../../src/logging/types'; +import { IStore, SDKConfig } from '../../src/store'; + +describe('ReportingLogger', () => { + let logger: ReportingLogger; + const loggingUrl = 'test-url.com/v1/log'; + const errorUrl = 'test-url.com/v1/errors'; + const sdkVersion = '1.2.3'; + let mockFetch: jest.Mock; + const accountId = '1234567890'; + let mockStore: Partial; + + beforeEach(() => { + mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + mockStore = { + getRoktAccountId: jest.fn().mockReturnValue(null), + getIntegrationName: jest.fn().mockReturnValue(null) + }; + + Object.defineProperty(window, 'location', { + writable: true, + value: { + href: 'https://e.com', + search: '' + } + }); + + Object.defineProperty(window, 'navigator', { + writable: true, + value: { userAgent: 'ua' } + }); + + Object.defineProperty(window, 'mParticle', { + writable: true, + value: { config: { isWebSdkLoggingEnabled: true } } + }); + + Object.defineProperty(window, 'ROKT_DOMAIN', { + writable: true, + value: 'set' + }); + + logger = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: true } as SDKConfig, + sdkVersion, + mockStore as IStore, + 'test-launcher-instance-guid' + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + 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/errors'); + 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', () => { + mockStore.getRoktAccountId = jest.fn().mockReturnValue(accountId); + logger.warning('warn', ErrorCodes.UNHANDLED_EXCEPTION); + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toContain('/v1/errors'); + 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', () => { + Object.defineProperty(window, 'ROKT_DOMAIN', { + writable: true, + value: undefined + }); + logger = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: true } as SDKConfig, + sdkVersion, + mockStore as IStore, + 'test-launcher-instance-guid' + ); + logger.error('x'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not log if feature flag and debug mode off', () => { + Object.defineProperty(window, 'mParticle', { + writable: true, + value: { config: { isWebSdkLoggingEnabled: false } } + }); + Object.defineProperty(window, 'location', { + writable: true, + value: { href: 'https://e.com', search: '' } + }); + logger = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: false } as SDKConfig, + sdkVersion, + mockStore as IStore, + 'test-launcher-instance-guid' + ); + logger.error('x'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('logs if debug mode on even if feature flag off', () => { + Object.defineProperty(window, 'mParticle', { + writable: true, + value: { config: { isWebSdkLoggingEnabled: false } } + }); + Object.defineProperty(window, 'location', { + writable: true, + value: { href: 'https://e.com', search: '?mp_enable_logging=true' } + }); + logger = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: false } as SDKConfig, + sdkVersion, + mockStore as IStore, + 'test-launcher-instance-guid' + ); + logger.error('x'); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('logs if debug mode on even without ROKT_DOMAIN', () => { + Object.defineProperty(window, 'ROKT_DOMAIN', { + writable: true, + value: undefined + }); + Object.defineProperty(window, 'location', { + writable: true, + value: { href: 'https://e.com', search: '?mp_enable_logging=true' } + }); + logger = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: false } as SDKConfig, + sdkVersion, + mockStore as IStore, + '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, errorUrl, isWebSdkLoggingEnabled: true } as SDKConfig, + sdkVersion, + mockStore as IStore, + 'test-launcher-instance-guid', + mockRateLimiter + ); + + for (let i = 0; i < 5; i++) logger.error('err'); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('does not include account id header when account id is not set', () => { + logger.error('msg'); + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[1].headers['rokt-account-id']).toBeUndefined(); + }); + + it('omits user agent and url fields when navigator/location are undefined', () => { + Object.defineProperty(window, 'navigator', { + writable: true, + value: undefined + }); + Object.defineProperty(window, 'location', { + writable: true, + value: undefined + }); + logger = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: true } as SDKConfig, + sdkVersion, + mockStore as IStore, + 'test-launcher-instance-guid' + ); + logger.error('msg'); + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + // undefined values are omitted from JSON.stringify, so these fields won't be present + expect(body).not.toHaveProperty('deviceInfo'); + expect(body).not.toHaveProperty('url'); + }); + + it('can set store after initialization', () => { + const loggerWithoutStore = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: true } as SDKConfig, + sdkVersion, + undefined, + 'test-launcher-instance-guid' + ); + + const newMockStore: Partial = { + getRoktAccountId: jest.fn().mockReturnValue(accountId), + getIntegrationName: jest.fn().mockReturnValue('custom-integration-name') + }; + + loggerWithoutStore.setStore(newMockStore as IStore); + loggerWithoutStore.error('test error'); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[1].headers['rokt-account-id']).toBe(accountId); + expect(fetchCall[1].headers['rokt-launcher-version']).toBe('custom-integration-name'); + }); + + it('catches and logs errors during upload', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + + logger.error('test error', ErrorCodes.UNHANDLED_EXCEPTION); + + // Wait for the promise to resolve + return new Promise(resolve => setTimeout(resolve, 0)).then(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'ReportingLogger: Failed to send log', + expect.any(Error) + ); + consoleErrorSpy.mockRestore(); + }); + }); + + it('omits rokt-launcher-instance-guid header when launcherInstanceGuid is undefined', () => { + logger = new ReportingLogger( + { loggingUrl, errorUrl, isWebSdkLoggingEnabled: true } as SDKConfig, + sdkVersion, + mockStore as IStore, + undefined + ); + + logger.error('test error'); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[1].headers['rokt-launcher-instance-guid']).toBeUndefined(); + expect(fetchCall[1].headers['rokt-launcher-version']).toBeDefined(); + }); + + it('includes all required headers', () => { + mockStore.getRoktAccountId = jest.fn().mockReturnValue(accountId); + mockStore.getIntegrationName = jest.fn().mockReturnValue('custom-integration'); + + logger.error('test error'); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + const headers = fetchCall[1].headers; + + expect(headers['Accept']).toBe('text/plain;charset=UTF-8'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['rokt-launcher-instance-guid']).toBe('test-launcher-instance-guid'); + expect(headers['rokt-launcher-version']).toBe('custom-integration'); + expect(headers['rokt-wsdk-version']).toBe('joint'); + expect(headers['rokt-account-id']).toBe(accountId); + }); + + it('constructs full URL with https prefix', () => { + logger.error('test error'); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + expect(fetchCall[0]).toBe(`https://${errorUrl}`); + }); + + it('includes all fields in log request body', () => { + mockStore.getIntegrationName = jest.fn().mockReturnValue('test-integration'); + + logger.error('error message', ErrorCodes.IDENTITY_REQUEST, 'stack trace here'); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + + expect(body).toEqual({ + additionalInformation: { + message: 'error message', + version: 'test-integration' + }, + severity: WSDKErrorSeverity.ERROR, + code: ErrorCodes.IDENTITY_REQUEST, + url: 'https://e.com', + deviceInfo: 'ua', + stackTrace: 'stack trace here', + reporter: 'mp-wsdk', + integration: 'mp-wsdk' + }); + }); + + it('uses default error code when code is undefined', () => { + logger.error('test error'); + + expect(mockFetch).toHaveBeenCalled(); + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + + expect(body.code).toBe(ErrorCodes.UNKNOWN_ERROR); + }); +}); + +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); + }); +}); diff --git a/test/src/tests-store.ts b/test/src/tests-store.ts index 774d08222..3d2a2baa5 100644 --- a/test/src/tests-store.ts +++ b/test/src/tests-store.ts @@ -1678,6 +1678,8 @@ describe('Store', () => { v1SecureServiceUrl: 'jssdks.mparticle.com/v1/JS/', v2SecureServiceUrl: 'jssdks.mparticle.com/v2/JS/', v3SecureServiceUrl: 'foo.customer.mp.com/v3/JS/', + loggingUrl: 'apps.rokt-api.com/v1/log', + errorUrl: 'apps.rokt-api.com/v1/errors', }; expect(result).to.deep.equal(expectedResult); @@ -1703,6 +1705,8 @@ describe('Store', () => { v1SecureServiceUrl: 'custom.domain.com/webevents/v1/JS/', v2SecureServiceUrl: 'custom.domain.com/webevents/v2/JS/', v3SecureServiceUrl: 'custom.domain.com/webevents/v3/JS/', + loggingUrl: 'custom.domain.com/v1/log', + errorUrl: 'custom.domain.com/v1/errors', }; expect(result).to.deep.equal(expectedResult); @@ -1735,6 +1739,8 @@ describe('Store', () => { v1SecureServiceUrl: 'custom.domain.com/webevents/v1/JS/', v2SecureServiceUrl: 'custom.domain.com/webevents/v2/JS/', v3SecureServiceUrl: 'custom.domain.com/webevents/v3/JS/', + loggingUrl: 'custom.domain.com/v1/log', + errorUrl: 'custom.domain.com/v1/errors', }; expect(result).to.deep.equal(expectedResult); @@ -1799,6 +1805,8 @@ describe('Store', () => { v1SecureServiceUrl: 'jssdks.us1.mparticle.com/v1/JS/', v2SecureServiceUrl: 'jssdks.us1.mparticle.com/v2/JS/', v3SecureServiceUrl: 'foo.customer.mp.com/v3/JS/', + loggingUrl: 'apps.rokt-api.com/v1/log', + errorUrl: 'apps.rokt-api.com/v1/errors', }; expect(result).to.deep.equal(expectedResult);