diff --git a/src/constants.ts b/src/constants.ts index 3072e9bee..bf513a5d1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -139,8 +139,6 @@ 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: { @@ -150,8 +148,6 @@ 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 70387259b..876dc37e7 100644 --- a/src/identityApiClient.ts +++ b/src/identityApiClient.ts @@ -25,7 +25,7 @@ import { IIdentityResponse, } from './identity-user-interfaces'; import { IMParticleWebSDKInstance } from './mp-instance'; -import { ErrorCodes } from './logging/types'; +import { ErrorCodes, WSDKErrorSeverity } from './reporting/types'; const { HTTPCodes, Messages, IdentityMethods } = Constants; @@ -329,10 +329,13 @@ export default function IdentityAPIClient( } const errorMessage = (err as Error).message || err.toString(); - Logger.error( - 'Error sending identity request to servers' + ' - ' + errorMessage, - ErrorCodes.IDENTITY_REQUEST - ); + const msg = 'Error sending identity request to servers' + ' - ' + errorMessage; + Logger.error(msg); + mpInstance._ErrorReportingDispatcher?.report({ + message: msg, + code: ErrorCodes.IDENTITY_REQUEST, + severity: WSDKErrorSeverity.ERROR, + }); mpInstance.processQueueOnIdentityFailure?.(); invokeCallback(callback, HTTPCodes.noHttpCoverage, errorMessage); diff --git a/src/logger.ts b/src/logger.ts index 82af753dd..02800a83e 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,4 @@ import { LogLevelType, SDKInitConfig, SDKLoggerApi } from './sdkRuntimeModels'; -import { ReportingLogger } from './logging/reportingLogger'; -import { ErrorCodes } from './logging/types'; export type ILoggerConfig = Pick; export type IConsoleLogger = Partial>; @@ -8,18 +6,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 will be set to integrationName once the kit connects via RoktManager.attachKit() - integration: this.store?.getIntegrationName() ?? 'mp-wsdk' - }; - } - - 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.isLoggingEnabled; - - 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'] = { - [HEADER_ACCEPT]: 'text/plain;charset=UTF-8', - [HEADER_CONTENT_TYPE]: 'application/json', - [HEADER_ROKT_LAUNCHER_VERSION]: this.getVersion(), - [HEADER_ROKT_WSDK_VERSION]: 'joint', - }; - - if (this.launcherInstanceGuid) { - headers[HEADER_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/mp-instance.ts b/src/mp-instance.ts index 24f31333f..48ea45bed 100644 --- a/src/mp-instance.ts +++ b/src/mp-instance.ts @@ -52,7 +52,9 @@ import ForegroundTimer from './foregroundTimeTracker'; import RoktManager, { IRoktOptions } from './roktManager'; import filteredMparticleUser from './filteredMparticleUser'; import CookieConsentManager, { ICookieConsentManager } from './cookieConsentManager'; -import { ReportingLogger } from './logging/reportingLogger'; +import { ErrorReportingDispatcher } from './reporting/errorReportingDispatcher'; +import { LoggingDispatcher } from './reporting/loggingDispatcher'; +import { IErrorReportingService, ILoggingService } from './reporting/types'; export interface IErrorLogMessage { message?: string; @@ -85,7 +87,8 @@ export interface IMParticleWebSDKInstance extends MParticleWebSDK { _NativeSdkHelpers: INativeSdkHelpers; _Persistence: IPersistence; _CookieConsentManager: ICookieConsentManager; - _ReportingLogger: ReportingLogger; + _ErrorReportingDispatcher: ErrorReportingDispatcher; + _LoggingDispatcher: LoggingDispatcher; _RoktManager: RoktManager; _SessionManager: ISessionManager; _ServerModel: IServerModel; @@ -266,14 +269,14 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan } }; - this._resetForTests = function(config, keepPersistence, instance, reportingLogger?: ReportingLogger) { + this._resetForTests = function(config, keepPersistence, instance) { if (instance._Store) { delete instance._Store; } - instance.Logger = new Logger(config, reportingLogger); + instance._ErrorReportingDispatcher = new ErrorReportingDispatcher(); + instance._LoggingDispatcher = new LoggingDispatcher(); + instance.Logger = new Logger(config); instance._Store = new Store(config, instance); - // Update ReportingLogger with the new Store reference to avoid stale data - reportingLogger?.setStore(instance._Store); instance._Store.isLocalStorageAvailable = instance._Persistence.determineLocalStorageAvailability( window.localStorage ); @@ -1406,6 +1409,14 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan } }; + this.registerErrorReportingService = function(service: IErrorReportingService) { + self._ErrorReportingDispatcher.register(service); + }; + + this.registerLoggingService = function(service: ILoggingService) { + self._LoggingDispatcher.register(service); + }; + const launcherInstanceGuidKey = Constants.Rokt.LauncherInstanceGuidKey; this.setLauncherInstanceGuid = function() { if (!window[launcherInstanceGuidKey] @@ -1630,16 +1641,11 @@ function createIdentityCache(mpInstance) { } function runPreConfigFetchInitialization(mpInstance, apiKey, config) { - mpInstance._ReportingLogger = new ReportingLogger( - config, - Constants.sdkVersion, - undefined, - mpInstance.getLauncherInstanceGuid(), - ); - mpInstance.Logger = new Logger(config, mpInstance._ReportingLogger); + mpInstance._ErrorReportingDispatcher = new ErrorReportingDispatcher(); + mpInstance._LoggingDispatcher = new LoggingDispatcher(); + mpInstance.Logger = new Logger(config); 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/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index ab7fceff7..4b5b1ff75 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -487,6 +487,12 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { this._setWrapperSDKInfo = function(name, version) { self.getInstance()._setWrapperSDKInfo(name, version); }; + this.registerErrorReportingService = function(service) { + self.getInstance().registerErrorReportingService(service); + }; + this.registerLoggingService = function(service) { + self.getInstance().registerLoggingService(service); + }; } const mParticleManager = new mParticleInstanceManager(); diff --git a/src/reporting/errorReportingDispatcher.ts b/src/reporting/errorReportingDispatcher.ts new file mode 100644 index 000000000..afe60f866 --- /dev/null +++ b/src/reporting/errorReportingDispatcher.ts @@ -0,0 +1,13 @@ +import { IErrorReportingService, ISDKError } from './types'; + +export class ErrorReportingDispatcher implements IErrorReportingService { + private services: IErrorReportingService[] = []; + + public register(service: IErrorReportingService): void { + this.services.push(service); + } + + public report(error: ISDKError): void { + this.services.forEach(s => s.report(error)); + } +} diff --git a/src/reporting/loggingDispatcher.ts b/src/reporting/loggingDispatcher.ts new file mode 100644 index 000000000..d9e1d28e1 --- /dev/null +++ b/src/reporting/loggingDispatcher.ts @@ -0,0 +1,13 @@ +import { ILoggingService, ISDKLogEntry } from './types'; + +export class LoggingDispatcher implements ILoggingService { + private services: ILoggingService[] = []; + + public register(service: ILoggingService): void { + this.services.push(service); + } + + public log(entry: ISDKLogEntry): void { + this.services.forEach(s => s.log(entry)); + } +} diff --git a/src/logging/types.ts b/src/reporting/types.ts similarity index 52% rename from src/logging/types.ts rename to src/reporting/types.ts index 46c431158..a012cc98c 100644 --- a/src/logging/types.ts +++ b/src/reporting/types.ts @@ -18,15 +18,28 @@ export const WSDKErrorSeverity = { export type WSDKErrorSeverity = (typeof WSDKErrorSeverity)[keyof typeof WSDKErrorSeverity]; -export type ErrorsRequestBody = { - additionalInformation?: Record; - code: ErrorCode; +/** Structured error object for reporting. */ +export interface ISDKError { + message: string; + code?: ErrorCodes; severity: WSDKErrorSeverity; stackTrace?: string; - deviceInfo?: string; - integration?: string; - reporter?: string; - url?: string; -}; + additionalInformation?: Record; +} + +/** Structured log entry for informational logging. */ +export interface ISDKLogEntry { + message: string; + code?: ErrorCodes; + additionalInformation?: Record; +} + +/** Contract for error/warning reporting services. */ +export interface IErrorReportingService { + report(error: ISDKError): void; +} -export type LogRequestBody = ErrorsRequestBody; +/** Contract for informational logging services. */ +export interface ILoggingService { + log(entry: ISDKLogEntry): void; +} diff --git a/src/sdkRuntimeModels.ts b/src/sdkRuntimeModels.ts index da9c8952d..492964395 100644 --- a/src/sdkRuntimeModels.ts +++ b/src/sdkRuntimeModels.ts @@ -51,7 +51,7 @@ import { import Constants from './constants'; import RoktManager, { IRoktLauncherOptions } from './roktManager'; import { IConsoleLogger } from './logger'; -import { ErrorCodes } from './logging/types'; +import { ErrorCodes, IErrorReportingService, ILoggingService } from './reporting/types'; // TODO: Resolve this with version in @mparticle/web-sdk export type SDKEventCustomFlags = Dictionary; @@ -260,6 +260,8 @@ export interface MParticleWebSDK { ): void; getIntegrationAttributes(integrationModuleId: number): IntegrationAttribute; captureTiming(metricName: string): void; + registerErrorReportingService(service: IErrorReportingService): void; + registerLoggingService(service: ILoggingService): void; } // https://go.mparticle.com/work/SQDSDKS-4805 diff --git a/src/store.ts b/src/store.ts index 9c4fcc18f..504920ab1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -96,8 +96,6 @@ export interface SDKConfig { webviewBridgeName?: string; workspaceToken?: string; requiredWebviewBridgeName?: string; - loggingUrl?: string; - errorUrl?: string; isLoggingEnabled?: boolean; } @@ -862,7 +860,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' || baseUrlKey === 'loggingUrl' || baseUrlKey === 'errorUrl') { + if (baseUrlKey === 'configUrl') { directBaseUrls[baseUrlKey] = config[baseUrlKey] || defaultBaseUrls[baseUrlKey]; continue; diff --git a/test/jest/logger.spec.ts b/test/jest/logger.spec.ts index 6dce7fe1b..15f91fbe1 100644 --- a/test/jest/logger.spec.ts +++ b/test/jest/logger.spec.ts @@ -1,7 +1,5 @@ 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; @@ -56,16 +54,16 @@ describe('Logger', () => { expect(mockConsole.error).not.toHaveBeenCalled(); }); - it('should only call error at error log level', () => { - logger = new Logger({ logLevel: LogLevelType.Error }); - - logger.verbose('message1'); - logger.warning('message2'); - logger.error('message3'); - - expect(mockConsole.info).not.toHaveBeenCalled(); - expect(mockConsole.warn).not.toHaveBeenCalled(); - expect(mockConsole.error).toHaveBeenCalledWith('message3'); + it('should only call error at error log level', () => { + logger = new Logger({ logLevel: LogLevelType.Error }); + + logger.verbose('message1'); + logger.warning('message2'); + logger.error('message3'); + + expect(mockConsole.info).not.toHaveBeenCalled(); + expect(mockConsole.warn).not.toHaveBeenCalled(); + expect(mockConsole.error).toHaveBeenCalledWith('message3'); }); it('should allow providing a custom logger', () => { @@ -106,44 +104,18 @@ describe('Logger', () => { 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 have no reporting side effects from error()', () => { + // Logger.error() should only output to console, no reporting + const customLogger = { + error: jest.fn(), + }; - it('should NOT call reportingLogger when warning is called', () => { - logger = new Logger({ logLevel: LogLevelType.Verbose }, mockReportingLogger); + logger = new Logger({ logLevel: LogLevelType.Verbose, logger: customLogger }); - logger.warning('test warning'); + logger.error('test error'); - expect(mockConsole.warn).toHaveBeenCalledWith('test warning'); - expect(mockReportingLogger.warning).not.toHaveBeenCalled(); - }); + expect(customLogger.error).toHaveBeenCalledWith('test error'); + // No other side effects - Logger is purely console output }); }); diff --git a/test/jest/logging-integration.spec.ts b/test/jest/logging-integration.spec.ts index 7ba4e44e8..4eecfed28 100644 --- a/test/jest/logging-integration.spec.ts +++ b/test/jest/logging-integration.spec.ts @@ -1,250 +1,88 @@ import { Logger } from '../../src/logger'; -import { ReportingLogger } from '../../src/logging/reportingLogger'; -import { ErrorCodes } from '../../src/logging/types'; -import { IStore, SDKConfig } from '../../src/store'; +import { ErrorReportingDispatcher } from '../../src/reporting/errorReportingDispatcher'; +import { LoggingDispatcher } from '../../src/reporting/loggingDispatcher'; +import { ErrorCodes, WSDKErrorSeverity, IErrorReportingService, ILoggingService } from '../../src/reporting/types'; import { LogLevelType } from '../../src/sdkRuntimeModels'; describe('Logging Integration', () => { - let mockFetch: jest.Mock; - const loggingUrl = 'test-url.com/v1/log'; - const errorUrl = 'test-url.com/v1/errors'; - const sdkVersion = '1.2.3'; - const accountId = '9876543210'; - const integrationName = 'test-integration'; + describe('Logger is decoupled from reporting', () => { + it('Logger.error() does not trigger any reporting', () => { + const logger = new Logger({ logLevel: LogLevelType.Warning }); - beforeEach(() => { - mockFetch = jest.fn().mockResolvedValue({ ok: true }); - global.fetch = mockFetch; - - Object.defineProperty(window, 'location', { - writable: true, - value: { - href: 'https://example.com', - search: '' - } - }); - - Object.defineProperty(window, 'navigator', { - writable: true, - value: { userAgent: 'test-user-agent' } + // Logger.error() should only output to console - no reporting side effects + // This verifies the decoupling is complete + expect(() => logger.error('test error')).not.toThrow(); }); - Object.defineProperty(window, 'mParticle', { - writable: true, - value: { config: { isLoggingEnabled: true } } + it('Logger and ErrorReportingDispatcher work independently', () => { + const logger = new Logger({ logLevel: LogLevelType.Warning }); + const dispatcher = new ErrorReportingDispatcher(); + const mockService: IErrorReportingService = { report: jest.fn() }; + dispatcher.register(mockService); + + // Simulate the pattern from identityApiClient: separate Logger.error + dispatcher.report + const msg = 'Error sending identity request to servers - timeout'; + logger.error(msg); + dispatcher.report({ + message: msg, + code: ErrorCodes.IDENTITY_REQUEST, + severity: WSDKErrorSeverity.ERROR, + }); + + expect(mockService.report).toHaveBeenCalledTimes(1); + expect(mockService.report).toHaveBeenCalledWith({ + message: msg, + code: ErrorCodes.IDENTITY_REQUEST, + severity: WSDKErrorSeverity.ERROR, + }); }); - Object.defineProperty(window, 'ROKT_DOMAIN', { - writable: true, - value: 'test-domain' - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('integrates Logger, ReportingLogger, and Store to send error with account ID and integration name', () => { - // Setup Store with account ID and integration name - const mockStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue(accountId), - getIntegrationName: jest.fn().mockReturnValue(integrationName) - }; - - // Create ReportingLogger with Store - const reportingLogger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - mockStore as IStore, - 'test-guid' - ); - - // Create Logger with ReportingLogger - const logger = new Logger( - { logLevel: LogLevelType.Warning }, - reportingLogger - ); - - // Log an error through the Logger - logger.error('Integration test error', ErrorCodes.UNHANDLED_EXCEPTION); - - // Verify the full flow - expect(mockFetch).toHaveBeenCalledTimes(1); - - const fetchCall = mockFetch.mock.calls[0]; - const fetchUrl = fetchCall[0]; - const fetchOptions = fetchCall[1]; - - // Verify URL - expect(fetchUrl).toBe(`https://${errorUrl}`); - - // Verify headers include Store data - expect(fetchOptions.headers['rokt-account-id']).toBe(accountId); - expect(fetchOptions.headers['rokt-launcher-version']).toBe(integrationName); - expect(fetchOptions.headers['rokt-launcher-instance-guid']).toBe('test-guid'); - - // Verify body includes correct data - const body = JSON.parse(fetchOptions.body); - expect(body.additionalInformation.message).toBe('Integration test error'); - expect(body.additionalInformation.version).toBe(integrationName); - expect(body.code).toBe(ErrorCodes.UNHANDLED_EXCEPTION); - expect(body.integration).toBe(integrationName); - expect(body.reporter).toBe('mp-wsdk'); - - // Verify Store methods were called - expect(mockStore.getRoktAccountId).toHaveBeenCalled(); - expect(mockStore.getIntegrationName).toHaveBeenCalled(); - }); - - it('does not send to ReportingLogger when error code is not provided', () => { - const mockStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue(accountId), - getIntegrationName: jest.fn().mockReturnValue(integrationName) - }; - - const reportingLogger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - mockStore as IStore - ); - - const logger = new Logger( - { logLevel: LogLevelType.Warning }, - reportingLogger - ); - - // Log error without error code - should not report to backend - logger.error('Console only error'); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('updates Store reference and uses new data after setStore is called', () => { - const initialStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue('initial-id'), - getIntegrationName: jest.fn().mockReturnValue('initial-integration') - }; - - const reportingLogger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - initialStore as IStore - ); + it('Logger and LoggingDispatcher work independently', () => { + const logger = new Logger({ logLevel: LogLevelType.Verbose }); + const dispatcher = new LoggingDispatcher(); + const mockService: ILoggingService = { log: jest.fn() }; + dispatcher.register(mockService); - const logger = new Logger( - { logLevel: LogLevelType.Warning }, - reportingLogger - ); + logger.verbose('Some verbose message'); + dispatcher.log({ + message: 'Some verbose message', + code: ErrorCodes.UNKNOWN_ERROR, + }); - // Update Store reference (simulating what happens in _resetForTests) - const newStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue('new-id'), - getIntegrationName: jest.fn().mockReturnValue('new-integration') - }; - - reportingLogger.setStore(newStore as IStore); - - // Log error - should use new Store data - logger.error('Test error', ErrorCodes.IDENTITY_REQUEST); - - expect(mockFetch).toHaveBeenCalledTimes(1); - - const fetchCall = mockFetch.mock.calls[0]; - const fetchOptions = fetchCall[1]; - - // Verify new Store data is used - expect(fetchOptions.headers['rokt-account-id']).toBe('new-id'); - expect(fetchOptions.headers['rokt-launcher-version']).toBe('new-integration'); - - const body = JSON.parse(fetchOptions.body); - expect(body.additionalInformation.version).toBe('new-integration'); - expect(body.integration).toBe('new-integration'); - - // Verify new Store was called, not the old one - expect(newStore.getRoktAccountId).toHaveBeenCalled(); - expect(newStore.getIntegrationName).toHaveBeenCalled(); - expect(initialStore.getRoktAccountId).not.toHaveBeenCalled(); - }); - - it('sends info logs to loggingUrl instead of errorUrl', () => { - const mockStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue(accountId), - getIntegrationName: jest.fn().mockReturnValue(integrationName) - }; - - const reportingLogger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - mockStore as IStore - ); - - // Call info directly on ReportingLogger - reportingLogger.info('Info message', ErrorCodes.IDENTITY_REQUEST); - - expect(mockFetch).toHaveBeenCalledTimes(1); - - const fetchCall = mockFetch.mock.calls[0]; - const fetchUrl = fetchCall[0]; - - // Verify it goes to loggingUrl, not errorUrl - expect(fetchUrl).toBe(`https://${loggingUrl}`); - expect(fetchUrl).not.toContain('errors'); - }); - - it('respects LogLevel settings in Logger', () => { - const mockStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue(accountId), - getIntegrationName: jest.fn().mockReturnValue(integrationName) - }; - - const reportingLogger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - mockStore as IStore - ); - - // Set LogLevel to None - should not log anything - const logger = new Logger( - { logLevel: LogLevelType.None }, - reportingLogger - ); - - logger.error('Should not log', ErrorCodes.UNHANDLED_EXCEPTION); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('uses default values when Store methods return null', () => { - const mockStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue(null), - getIntegrationName: jest.fn().mockReturnValue(null) - }; - - const reportingLogger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - mockStore as IStore - ); + expect(mockService.log).toHaveBeenCalledTimes(1); + }); - const logger = new Logger( - { logLevel: LogLevelType.Warning }, - reportingLogger - ); + it('Dispatchers with no registered services are safe no-ops', () => { + const errorDispatcher = new ErrorReportingDispatcher(); + const loggingDispatcher = new LoggingDispatcher(); - logger.error('Test error', ErrorCodes.UNHANDLED_EXCEPTION); + expect(() => errorDispatcher.report({ + message: 'test', + severity: WSDKErrorSeverity.ERROR, + })).not.toThrow(); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(() => loggingDispatcher.log({ + message: 'test', + })).not.toThrow(); + }); - const fetchCall = mockFetch.mock.calls[0]; - const fetchOptions = fetchCall[1]; + it('Multiple services receive reports from ErrorReportingDispatcher', () => { + const dispatcher = new ErrorReportingDispatcher(); + const service1: IErrorReportingService = { report: jest.fn() }; + const service2: IErrorReportingService = { report: jest.fn() }; - // Verify rokt-account-id header is not present - expect(fetchOptions.headers['rokt-account-id']).toBeUndefined(); + dispatcher.register(service1); + dispatcher.register(service2); - // Verify default integration name is used - expect(fetchOptions.headers['rokt-launcher-version']).toBe(`mParticle_wsdkv_${sdkVersion}`); + const error = { + message: 'test', + severity: WSDKErrorSeverity.ERROR as WSDKErrorSeverity, + code: ErrorCodes.UNHANDLED_EXCEPTION as ErrorCodes, + }; + dispatcher.report(error); - const body = JSON.parse(fetchOptions.body); - expect(body.integration).toBe('mp-wsdk'); + expect(service1.report).toHaveBeenCalledWith(error); + expect(service2.report).toHaveBeenCalledWith(error); + }); }); }); diff --git a/test/jest/reportingLogger.spec.ts b/test/jest/reportingLogger.spec.ts index 3d02f8bd7..acc1a5551 100644 --- a/test/jest/reportingLogger.spec.ts +++ b/test/jest/reportingLogger.spec.ts @@ -1,384 +1,126 @@ -import { IRateLimiter, RateLimiter, ReportingLogger } from '../../src/logging/reportingLogger'; -import { WSDKErrorSeverity, ErrorCodes } from '../../src/logging/types'; -import { IStore, SDKConfig } from '../../src/store'; +import { ErrorReportingDispatcher } from '../../src/reporting/errorReportingDispatcher'; +import { LoggingDispatcher } from '../../src/reporting/loggingDispatcher'; +import { IErrorReportingService, ILoggingService, ISDKError, ISDKLogEntry, WSDKErrorSeverity, ErrorCodes } from '../../src/reporting/types'; -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; +describe('ErrorReportingDispatcher', () => { + let dispatcher: ErrorReportingDispatcher; 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: { isLoggingEnabled: true } } - }); - - Object.defineProperty(window, 'ROKT_DOMAIN', { - writable: true, - value: 'set' - }); - - logger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - mockStore as IStore, - 'test-launcher-instance-guid' - ); + dispatcher = new ErrorReportingDispatcher(); }); - 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({ + it('report() is a no-op when no services are registered', () => { + // Should not throw + dispatcher.report({ + message: 'test', 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('sends info logs to correct endpoint with correct params', () => { - logger.info('info message', ErrorCodes.UNHANDLED_EXCEPTION); - 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.INFO, - code: ErrorCodes.UNHANDLED_EXCEPTION - }); - }); - - it('does not log if ROKT_DOMAIN missing', () => { - Object.defineProperty(window, 'ROKT_DOMAIN', { - writable: true, - value: undefined - }); - logger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: 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: { isLoggingEnabled: false } } - }); - Object.defineProperty(window, 'location', { - writable: true, - value: { href: 'https://e.com', search: '' } }); - logger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: 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: { isLoggingEnabled: false } } - }); - Object.defineProperty(window, 'location', { - writable: true, - value: { href: 'https://e.com', search: '?mp_enable_logging=true' } - }); - logger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: 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, isLoggingEnabled: 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; - }), + it('register() adds a service and report() fans out to it', () => { + const mockService: IErrorReportingService = { + report: jest.fn(), }; - logger = new ReportingLogger( - { loggingUrl, errorUrl, isLoggingEnabled: 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); - }); + dispatcher.register(mockService); - 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, isLoggingEnabled: 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, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - undefined, - 'test-launcher-instance-guid' - ); - - const newMockStore: Partial = { - getRoktAccountId: jest.fn().mockReturnValue(accountId), - getIntegrationName: jest.fn().mockReturnValue('custom-integration-name') + const error: ISDKError = { + message: 'test error', + code: ErrorCodes.UNHANDLED_EXCEPTION, + severity: WSDKErrorSeverity.ERROR, + stackTrace: 'stack', }; - loggerWithoutStore.setStore(newMockStore as IStore); - loggerWithoutStore.error('test error'); + dispatcher.report(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'); + expect(mockService.report).toHaveBeenCalledTimes(1); + expect(mockService.report).toHaveBeenCalledWith(error); }); - it('catches and logs errors during upload', () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - mockFetch.mockRejectedValueOnce(new Error('Network failure')); + it('report() fans out to all registered services', () => { + const service1: IErrorReportingService = { report: jest.fn() }; + const service2: IErrorReportingService = { report: jest.fn() }; + const service3: IErrorReportingService = { report: jest.fn() }; - logger.error('test error', ErrorCodes.UNHANDLED_EXCEPTION); + dispatcher.register(service1); + dispatcher.register(service2); + dispatcher.register(service3); - // 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, isLoggingEnabled: true } as SDKConfig, - sdkVersion, - mockStore as IStore, - undefined - ); + const error: ISDKError = { + message: 'broadcast error', + severity: WSDKErrorSeverity.WARNING, + }; - logger.error('test error'); + dispatcher.report(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(); + expect(service1.report).toHaveBeenCalledWith(error); + expect(service2.report).toHaveBeenCalledWith(error); + expect(service3.report).toHaveBeenCalledWith(error); }); - it('includes all required headers', () => { - mockStore.getRoktAccountId = jest.fn().mockReturnValue(accountId); - mockStore.getIntegrationName = jest.fn().mockReturnValue('custom-integration'); + it('passes additionalInformation through to services', () => { + const mockService: IErrorReportingService = { report: jest.fn() }; + dispatcher.register(mockService); - logger.error('test error'); + const error: ISDKError = { + message: 'test', + severity: WSDKErrorSeverity.ERROR, + additionalInformation: { key: 'value' }, + }; - expect(mockFetch).toHaveBeenCalled(); - const fetchCall = mockFetch.mock.calls[0]; - const headers = fetchCall[1].headers; + dispatcher.report(error); - 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); + expect(mockService.report).toHaveBeenCalledWith(error); }); +}); - it('constructs full URL with https prefix', () => { - logger.error('test error'); +describe('LoggingDispatcher', () => { + let dispatcher: LoggingDispatcher; - expect(mockFetch).toHaveBeenCalled(); - const fetchCall = mockFetch.mock.calls[0]; - expect(fetchCall[0]).toBe(`https://${errorUrl}`); + beforeEach(() => { + dispatcher = new LoggingDispatcher(); }); - it('includes all fields in log request body with custom integration name', () => { - 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: 'test-integration' + it('log() is a no-op when no services are registered', () => { + // Should not throw + dispatcher.log({ + message: 'test', }); }); - it('uses default mp-wsdk for integration when integration name is not set', () => { - mockStore.getIntegrationName = jest.fn().mockReturnValue(null); - - logger.error('error message', ErrorCodes.IDENTITY_REQUEST); - - expect(mockFetch).toHaveBeenCalled(); - const fetchCall = mockFetch.mock.calls[0]; - const body = JSON.parse(fetchCall[1].body); + it('register() adds a service and log() fans out to it', () => { + const mockService: ILoggingService = { + log: jest.fn(), + }; - expect(body.reporter).toBe('mp-wsdk'); - expect(body.integration).toBe('mp-wsdk'); - expect(body.additionalInformation.version).toBe(`mParticle_wsdkv_${sdkVersion}`); - }); + dispatcher.register(mockService); - it('uses default error code when code is undefined', () => { - logger.error('test error'); + const entry: ISDKLogEntry = { + message: 'test log', + code: ErrorCodes.IDENTITY_REQUEST, + }; - expect(mockFetch).toHaveBeenCalled(); - const fetchCall = mockFetch.mock.calls[0]; - const body = JSON.parse(fetchCall[1].body); + dispatcher.log(entry); - expect(body.code).toBe(ErrorCodes.UNKNOWN_ERROR); + expect(mockService.log).toHaveBeenCalledTimes(1); + expect(mockService.log).toHaveBeenCalledWith(entry); }); -}); -describe('RateLimiter', () => { - let rateLimiter: RateLimiter; - beforeEach(() => { - rateLimiter = new RateLimiter(); - }); + it('log() fans out to all registered services', () => { + const service1: ILoggingService = { log: jest.fn() }; + const service2: ILoggingService = { log: jest.fn() }; - 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); - }); + dispatcher.register(service1); + dispatcher.register(service2); - 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); - }); + const entry: ISDKLogEntry = { + message: 'broadcast log', + additionalInformation: { detail: 'info' }, + }; - 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); - }); + dispatcher.log(entry); - 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); + expect(service1.log).toHaveBeenCalledWith(entry); + expect(service2.log).toHaveBeenCalledWith(entry); }); }); diff --git a/test/src/tests-mparticle-instance-manager.ts b/test/src/tests-mparticle-instance-manager.ts index abddf2322..33ec4c72e 100644 --- a/test/src/tests-mparticle-instance-manager.ts +++ b/test/src/tests-mparticle-instance-manager.ts @@ -216,6 +216,8 @@ describe('mParticle instance manager', () => { 'upload', 'Rokt', 'captureTiming', + 'registerErrorReportingService', + 'registerLoggingService', ]); }); diff --git a/test/src/tests-store.ts b/test/src/tests-store.ts index 3d2a2baa5..9528c75be 100644 --- a/test/src/tests-store.ts +++ b/test/src/tests-store.ts @@ -1678,15 +1678,13 @@ 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); }); it('should append url paths to domain when config.domain is set', () => { - // This example assumes only the domain is set, and not any of the + // This example assumes only the domain is set, and not any of the // configurable URLs const config = { domain: 'custom.domain.com' @@ -1705,8 +1703,6 @@ 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); @@ -1739,8 +1735,6 @@ 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); @@ -1805,8 +1799,6 @@ 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);