diff --git a/examples/sdk/browser/index.html b/examples/sdk/browser/index.html index c98a9ee4..5bcef440 100644 --- a/examples/sdk/browser/index.html +++ b/examples/sdk/browser/index.html @@ -31,6 +31,12 @@

Welcome to the Backtrace demo Send a message +
+ Generate metric +
+
+ Send metrics +

If you have any questions or concerns, please contact us at

diff --git a/examples/sdk/browser/src/index.ts b/examples/sdk/browser/src/index.ts index 7132174f..9da92984 100644 --- a/examples/sdk/browser/src/index.ts +++ b/examples/sdk/browser/src/index.ts @@ -21,6 +21,8 @@ function parseNotExistingDomElement(): string { const sendErrorButton = document.getElementById('send-error') as HTMLElement; const sendMessageButton = document.getElementById('send-message') as HTMLElement; +const generateMetricButton = document.getElementById('generate-metric') as HTMLElement; +const sendMetricsButton = document.getElementById('send-metrics') as HTMLElement; async function sendHandledException() { try { @@ -40,5 +42,25 @@ async function sendMessage() { ]); } +function generateMetric() { + console.log('generate-metric click'); + if (!client.metrics) { + console.log('metrics are unavailable'); + return; + } + client.metrics.addSummedEvent('click'); +} + +function sendMetrics() { + console.log('send-metrics click'); + if (!client.metrics) { + console.log('metrics are unavailable'); + return; + } + client.metrics.send(); +} + sendErrorButton.onclick = sendHandledException; sendMessageButton.onclick = sendMessage; +generateMetricButton.onclick = generateMetric; +sendMetricsButton.onclick = sendMetrics; diff --git a/examples/sdk/node/src/index.ts b/examples/sdk/node/src/index.ts index b3c1fe32..80f8ec48 100644 --- a/examples/sdk/node/src/index.ts +++ b/examples/sdk/node/src/index.ts @@ -38,11 +38,27 @@ async function sendMessage(message: string, attributes: Record) await client.send(message, attributes); } +function addEvent(name: string, attributes: Record) { + if (!client.metrics) { + console.log('metrics are unavailable'); + return; + } + client.metrics.addSummedEvent(name, attributes); +} +function sendMetrics() { + if (!client.metrics) { + console.log('metrics are unavailable'); + return; + } + client.metrics.send(); +} function showMenu() { const menu = `Please pick one of available options:\n` + `1. Send an exception\n` + `2. Send a message\n` + + `3. Add a new summed event\n` + + `4. Send all metrics\n` + `0. Exit\n` + `Type the option number:`; reader.question(menu, async function executeUserOption(optionString: string) { @@ -59,6 +75,14 @@ function showMenu() { await sendMessage('test message', attributes); break; } + case 3: { + addEvent('Option clicked', attributes); + break; + } + case 4: { + sendMetrics(); + break; + } case 0: { reader.close(); return exit(0); diff --git a/packages/browser/src/BacktraceBrowserSessionProvider.ts b/packages/browser/src/BacktraceBrowserSessionProvider.ts new file mode 100644 index 00000000..0ac1c174 --- /dev/null +++ b/packages/browser/src/BacktraceBrowserSessionProvider.ts @@ -0,0 +1,68 @@ +import { BacktraceSessionProvider, IdGenerator } from '@backtrace/sdk-core'; +import { TimeHelper } from '@backtrace/sdk-core/lib/common/TimeHelper'; + +export class BacktraceBrowserSessionProvider implements BacktraceSessionProvider { + /** + * Session persistence interval. If no event was send in the persistence interval + * the session is treaten as an old session. + */ + public static readonly PERSISTENCE_INTERVAL = TimeHelper.convertSecondsToMilliseconds(30 * 60); + private readonly SESSION_LAST_ACTIVE = 'backtrace-last-active'; + private readonly SESSION_GUID = 'backtrace-guid'; + + get lastActive(): number { + return this._lastActive; + } + + public readonly newSession: boolean = true; + + public readonly sessionId: string = IdGenerator.uuid(); + + private _lastActive = 0; + + constructor() { + if (!window.localStorage) { + return; + } + + const lastActive = this.readLastActiveTimestamp(); + if (!lastActive || TimeHelper.now() - lastActive > BacktraceBrowserSessionProvider.PERSISTENCE_INTERVAL) { + this.updateLastActiveTimestamp(); + localStorage.setItem(this.SESSION_GUID, this.sessionId); + return; + } + this._lastActive = lastActive; + this.newSession = false; + this.sessionId = localStorage.getItem(this.SESSION_GUID) as string; + } + + public afterMetricsSubmission(): void { + this.updateLastActiveTimestamp(); + } + + public shouldSend(): boolean { + // if the document is hidden, we shouldn't send metrics, because the open document + // is the one who is being used by the user. This condition makes sure two or more web + // browser tabs of the same app won't report the same metrics or false positive metrics. + return document.hidden === false; + } + + private readLastActiveTimestamp(): number | undefined { + const lastActiveStringTimestamp = localStorage.getItem(this.SESSION_LAST_ACTIVE); + if (!lastActiveStringTimestamp) { + return undefined; + } + + const lastActive = parseInt(lastActiveStringTimestamp, 10); + if (isNaN(lastActive)) { + return undefined; + } + + return lastActive; + } + + public updateLastActiveTimestamp() { + this._lastActive = TimeHelper.now(); + localStorage.setItem(this.SESSION_LAST_ACTIVE, this._lastActive.toString(10)); + } +} diff --git a/packages/browser/src/BacktraceClient.ts b/packages/browser/src/BacktraceClient.ts index 8e6d65fb..c0ee6744 100644 --- a/packages/browser/src/BacktraceClient.ts +++ b/packages/browser/src/BacktraceClient.ts @@ -5,6 +5,7 @@ import { BacktraceStackTraceConverter, } from '@backtrace/sdk-core'; import { AGENT } from './agentDefinition'; +import { BacktraceBrowserSessionProvider } from './BacktraceBrowserSessionProvider'; import { BacktraceConfiguration } from './BacktraceConfiguration'; import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; @@ -15,7 +16,7 @@ export class BacktraceClient extends BacktraceCoreClient { attributeProviders: BacktraceAttributeProvider[], stackTraceConverter: BacktraceStackTraceConverter, ) { - super(options, AGENT, handler, attributeProviders, stackTraceConverter); + super(options, AGENT, handler, attributeProviders, stackTraceConverter, new BacktraceBrowserSessionProvider()); } public static builder(options: BacktraceConfiguration): BacktraceClientBuilder { diff --git a/packages/browser/tests/client/clientTests.spec.ts b/packages/browser/tests/client/clientTests.spec.ts index ecffff00..0606afe6 100644 --- a/packages/browser/tests/client/clientTests.spec.ts +++ b/packages/browser/tests/client/clientTests.spec.ts @@ -7,26 +7,25 @@ describe('Client tests', () => { postError: jest.fn().mockResolvedValue(Promise.resolve()), }; + const defaultClientOptions = { + name: 'test', + version: '1.0.0', + url: 'https://submit.backtrace.io/foo/bar/baz', + metrics: { + enable: false, + }, + }; + let client: BacktraceClient; it('Should create a client', () => { - client = BacktraceClient.builder({ - name: 'test', - version: '1.0.0', - url: 'https://submit.backtrace.io/foo/bar/baz', - }).build(); + client = BacktraceClient.builder(defaultClientOptions).build(); expect(client).toBeDefined(); }); describe('Send tests', () => { beforeEach(() => { - client = BacktraceClient.builder({ - name: 'test', - version: '1.0.0', - url: 'https://submit.backtrace.io/foo/bar/baz', - }) - .useRequestHandler(requestHandler) - .build(); + client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); }); it(`Should not throw an error when sending a message`, async () => { expect(async () => await client.send('test')).not.toThrow(); @@ -48,9 +47,7 @@ describe('Client tests', () => { it(`Should generate an attachment list based on the client options`, async () => { const testedAttachment = new BacktraceUint8ArrayAttachment('client-add-test', new Uint8Array(0)); client = BacktraceClient.builder({ - name: 'test', - version: '1.0.0', - url: 'https://submit.backtrace.io/foo/bar/baz', + ...defaultClientOptions, attachments: [testedAttachment], }) .useRequestHandler(requestHandler) @@ -63,14 +60,7 @@ describe('Client tests', () => { it(`Should allow to add more attachments`, async () => { const testedAttachment = new BacktraceUint8ArrayAttachment('client-add-test', new Uint8Array(0)); - client = BacktraceClient.builder({ - name: 'test', - version: '1.0.0', - url: 'https://submit.backtrace.io/foo/bar/baz', - attachments: [], - }) - .useRequestHandler(requestHandler) - .build(); + client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); client.attachments.push(testedAttachment); expect(client.attachments).toBeDefined(); diff --git a/packages/browser/tests/metrics/persistentSessionProviderTests.spec.ts b/packages/browser/tests/metrics/persistentSessionProviderTests.spec.ts new file mode 100644 index 00000000..4b4850ce --- /dev/null +++ b/packages/browser/tests/metrics/persistentSessionProviderTests.spec.ts @@ -0,0 +1,51 @@ +import { TimeHelper } from '@backtrace/sdk-core/lib/common/TimeHelper'; +import { BacktraceBrowserSessionProvider } from '../../src/BacktraceBrowserSessionProvider'; +describe('Session provider tests', () => { + it('Should generate a new uuid on new session', () => { + const sessionProvider = new BacktraceBrowserSessionProvider(); + + expect(sessionProvider.sessionId).toBeDefined(); + }); + + it('Should reuse the same sessionId', () => { + const sessionProvider1 = new BacktraceBrowserSessionProvider(); + const sessionProvider2 = new BacktraceBrowserSessionProvider(); + expect(sessionProvider1.sessionId).toEqual(sessionProvider2.sessionId); + }); + + it('Should generate a new sessionId if the lastActive timestamp is greater than persistence interval time', () => { + const fakeId = 'test'; + const lastSessionActiveDate = new Date(Date.now() - BacktraceBrowserSessionProvider.PERSISTENCE_INTERVAL - 1); + localStorage.setItem('backtrace-last-active', lastSessionActiveDate.getTime().toString(10)); + localStorage.setItem('backtrace-guid', fakeId); + + const sessionProvider = new BacktraceBrowserSessionProvider(); + expect(sessionProvider.sessionId).toBeDefined(); + expect(sessionProvider.sessionId).not.toEqual(fakeId); + }); + + it('Should not generate a new sessionId if the lastActive timestamp is lower than persistence interval time', () => { + const fakeId = 'test'; + const lastSessionActiveDate = new Date(Date.now() - BacktraceBrowserSessionProvider.PERSISTENCE_INTERVAL + 1); + localStorage.setItem('backtrace-last-active', lastSessionActiveDate.getTime().toString(10)); + localStorage.setItem('backtrace-guid', fakeId); + + const sessionProvider = new BacktraceBrowserSessionProvider(); + expect(sessionProvider.sessionId).toBeDefined(); + expect(sessionProvider.sessionId).toEqual(fakeId); + }); + + it('Should update timestamp', () => { + const timestamp = Date.now(); + jest.spyOn(TimeHelper, 'now').mockImplementation(() => { + return timestamp; + }); + + localStorage.setItem('backtrace-last-active', new Date(2010, 1, 1, 1, 1, 1, 1).getTime().toString(10)); + const sessionProvider = new BacktraceBrowserSessionProvider(); + + sessionProvider.afterMetricsSubmission(); + + expect(sessionProvider.lastActive).toEqual(timestamp); + }); +}); diff --git a/packages/node/tests/client/clientTests.spec.ts b/packages/node/tests/client/clientTests.spec.ts index 1dd327a6..36318377 100644 --- a/packages/node/tests/client/clientTests.spec.ts +++ b/packages/node/tests/client/clientTests.spec.ts @@ -8,22 +8,22 @@ describe('Client tests', () => { postError: jest.fn().mockResolvedValue(Promise.resolve()), }; + const defaultClientOptions = { + url: 'https://submit.backtrace.io/foo/bar/baz', + metrics: { + enable: false, + }, + }; let client: BacktraceClient; it('Should create a client', () => { - client = BacktraceClient.builder({ - url: 'https://submit.backtrace.io/foo/bar/baz', - }).build(); + client = BacktraceClient.builder(defaultClientOptions).build(); expect(client).toBeDefined(); }); describe('Send tests', () => { beforeEach(() => { - client = BacktraceClient.builder({ - url: 'https://submit.backtrace.io/foo/bar/baz', - }) - .useRequestHandler(requestHandler) - .build(); + client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); }); it(`Should not throw an error when sending a message`, async () => { expect(async () => await client.send('test')).not.toThrow(); @@ -46,10 +46,7 @@ describe('Client tests', () => { const fileContent = fs.readFileSync(sampleFile, 'utf8'); it(`Should generate an attachment list based on the client options`, async () => { - client = BacktraceClient.builder({ - url: 'https://submit.backtrace.io/foo/bar/baz', - attachments: [sampleFile], - }) + client = BacktraceClient.builder({ ...defaultClientOptions, attachments: [sampleFile] }) .useRequestHandler(requestHandler) .build(); @@ -66,7 +63,7 @@ describe('Client tests', () => { it(`Should allow to setup bufer attachment`, async () => { const testedBuffer = Buffer.from('test'); client = BacktraceClient.builder({ - url: 'https://submit.backtrace.io/foo/bar/baz', + ...defaultClientOptions, attachments: [new BacktraceBufferAttachment('test', testedBuffer)], }) .useRequestHandler(requestHandler) @@ -79,12 +76,7 @@ describe('Client tests', () => { it(`Should allow to add more attachments`, async () => { const testedAttachment = new BacktraceFileAttachment(sampleFile); - client = BacktraceClient.builder({ - url: 'https://submit.backtrace.io/foo/bar/baz', - attachments: [], - }) - .useRequestHandler(requestHandler) - .build(); + client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); client.attachments.push(testedAttachment); expect(client.attachments).toBeDefined(); diff --git a/packages/sdk-core/src/BacktraceCoreClient.ts b/packages/sdk-core/src/BacktraceCoreClient.ts index 4a507b82..763cc57b 100644 --- a/packages/sdk-core/src/BacktraceCoreClient.ts +++ b/packages/sdk-core/src/BacktraceCoreClient.ts @@ -1,6 +1,10 @@ -import { BacktraceAttachment, BacktraceAttributeProvider, BacktraceStackTraceConverter } from '.'; +import { + BacktraceAttachment, + BacktraceAttributeProvider, + BacktraceSessionProvider, + BacktraceStackTraceConverter, +} from '.'; import { SdkOptions } from './builder/SdkOptions'; -import { IdGenerator } from './common/IdGenerator'; import { BacktraceConfiguration } from './model/configuration/BacktraceConfiguration'; import { AttributeType } from './model/data/BacktraceData'; import { BacktraceReportSubmission } from './model/http/BacktraceReportSubmission'; @@ -10,13 +14,16 @@ import { AttributeManager } from './modules/attribute/AttributeManager'; import { ClientAttributeProvider } from './modules/attribute/ClientAttributeProvider'; import { V8StackTraceConverter } from './modules/converter/V8StackTraceConverter'; import { BacktraceDataBuilder } from './modules/data/BacktraceDataBuilder'; +import { BacktraceMetrics } from './modules/metrics/BacktraceMetrics'; +import { MetricsBuilder } from './modules/metrics/MetricsBuilder'; +import { SingleSessionProvider } from './modules/metrics/SingleSessionProvider'; import { RateLimitWatcher } from './modules/rateLimiter/RateLimitWatcher'; export abstract class BacktraceCoreClient { /** * Current session id */ public get sessionId(): string { - return this._sessionId; + return this._sessionProvider.sessionId; } /** @@ -46,6 +53,10 @@ export abstract class BacktraceCoreClient { return this._attributeProvider.annotations; } + public get metrics(): BacktraceMetrics | undefined { + return this._metrics; + } + /** * Client cached attachments */ @@ -55,11 +66,7 @@ export abstract class BacktraceCoreClient { private readonly _reportSubmission: BacktraceReportSubmission; private readonly _rateLimitWatcher: RateLimitWatcher; private readonly _attributeProvider: AttributeManager; - - /** - * Current session Id - */ - private readonly _sessionId: string = IdGenerator.uuid(); + private readonly _metrics?: BacktraceMetrics; protected constructor( protected readonly options: BacktraceConfiguration, @@ -67,15 +74,25 @@ export abstract class BacktraceCoreClient { requestHandler: BacktraceRequestHandler, attributeProviders: BacktraceAttributeProvider[] = [], stackTraceConverter: BacktraceStackTraceConverter = new V8StackTraceConverter(), + private readonly _sessionProvider: BacktraceSessionProvider = new SingleSessionProvider(), ) { this._dataBuilder = new BacktraceDataBuilder(this._sdkOptions, stackTraceConverter); this._reportSubmission = new BacktraceReportSubmission(options, requestHandler); this._rateLimitWatcher = new RateLimitWatcher(options.rateLimit); this._attributeProvider = new AttributeManager([ - new ClientAttributeProvider(_sdkOptions.agentVersion, this._sessionId, options.userAttributes ?? {}), + new ClientAttributeProvider( + _sdkOptions.agentVersion, + _sessionProvider.sessionId, + options.userAttributes ?? {}, + ), ...(attributeProviders ?? []), ]); this.attachments = options.attachments ?? []; + const metrics = new MetricsBuilder(options, _sessionProvider, this._attributeProvider, requestHandler).build(); + if (metrics) { + this._metrics = metrics; + this._metrics.start(); + } } /** diff --git a/packages/sdk-core/src/common/DelayHelper.ts b/packages/sdk-core/src/common/DelayHelper.ts new file mode 100644 index 00000000..a283e837 --- /dev/null +++ b/packages/sdk-core/src/common/DelayHelper.ts @@ -0,0 +1,22 @@ +export class Delay { + /** + * Promise set timeout wrapper. + * @param timeout timeout in ms + * @param signal abort signal + */ + public static wait(timeout: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + function abortCallback() { + clearTimeout(intervalId); + reject(new Error('Operation cancelled.')); + } + + const intervalId = setTimeout(() => { + signal?.removeEventListener('abort', abortCallback); + resolve(); + }, timeout); + + signal?.addEventListener('abort', abortCallback); + }); + } +} diff --git a/packages/sdk-core/src/common/TimeHelper.ts b/packages/sdk-core/src/common/TimeHelper.ts index f3273f91..51eb3935 100644 --- a/packages/sdk-core/src/common/TimeHelper.ts +++ b/packages/sdk-core/src/common/TimeHelper.ts @@ -6,4 +6,8 @@ export class TimeHelper { public static toTimestampInSec(timestampMs: number): number { return Math.floor(timestampMs / 1000); } + + public static convertSecondsToMilliseconds(timeInSec: number): number { + return timeInSec * 1000; + } } diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index 6aacfb70..bd89b6e6 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -10,3 +10,4 @@ export * from './model/report/BacktraceErrorType'; export * from './model/report/BacktraceReport'; export * from './modules/attribute/BacktraceAttributeProvider'; export * from './modules/converter'; +export * from './modules/metrics/BacktraceSessionProvider'; diff --git a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts index 09221d5f..79972ac5 100644 --- a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts +++ b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts @@ -1,15 +1,27 @@ import { BacktraceAttachment } from '../attachment'; import { BacktraceDatabaseConfiguration } from './BacktraceDatabaseConfiguration'; -export interface BacktraceMetricsSupport { +export interface BacktraceMetricsOptions { + /** + * Metrics server hostname. By default the value is set to https://events.backtrace.io. + */ metricsSubmissionUrl?: string; - enable: boolean; - ignoreSslCertificate?: boolean; /** - * Indicates how often crash free metrics are sent to Backtrace. - * By default, session events are sent on application startup/finish, and every 30 minutes while the game is running. + * Determines if the metrics support is enabled. By default the value is set to true. + */ + enable?: boolean; + /** + * Indicates how often crash free metrics are sent to Backtrace. The interval is a value in ms. + * By default, session events are sent on application startup/finish, and every 30 minutes while the application is running. + * If the value is set to 0. The auto send mode is disabled. In this situation the application needs to maintain send + * mode manually. */ autoSendInterval?: number; + + /** + * Indicates how many events the metrics storage can store before auto submission. + */ + size?: number; } export interface BacktraceConfiguration { @@ -50,7 +62,7 @@ export interface BacktraceConfiguration { /** * Metrics such as crash free users and crash free sessions */ - metrics?: BacktraceMetricsSupport; + metrics?: BacktraceMetricsOptions; /** * Offline database settings */ diff --git a/packages/sdk-core/src/modules/metrics/BacktraceMetrics.ts b/packages/sdk-core/src/modules/metrics/BacktraceMetrics.ts new file mode 100644 index 00000000..d2fada30 --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/BacktraceMetrics.ts @@ -0,0 +1,124 @@ +import { TimeHelper } from '../../common/TimeHelper'; +import { BacktraceMetricsOptions } from '../../model/configuration/BacktraceConfiguration'; +import { AttributeType } from '../../model/data/BacktraceData'; +import { AttributeManager } from '../attribute/AttributeManager'; +import { ReportDataBuilder } from '../attribute/ReportDataBuilder'; +import { BacktraceSessionProvider } from './BacktraceSessionProvider'; +import { MetricsQueue } from './MetricsQueue'; +import { SummedEvent } from './model/SummedEvent'; +import { UniqueEvent } from './model/UniqueEvent'; + +export class BacktraceMetrics { + /** + * Returns current session id. + */ + public get sessionId() { + return this._sessionProvider.sessionId; + } + + /** + * Default metrics submission interval. The variable defines how often metrics will be sent to metrics system. + */ + public readonly DEFAULT_UPDATE_INTERVAL = TimeHelper.convertSecondsToMilliseconds(30 * 60); + public readonly DEFAULT_SERVER_URL = 'https://events.backtrace.io'; + + public readonly metricsHost: string = this._options.metricsSubmissionUrl ?? this.DEFAULT_SERVER_URL; + private readonly _updateInterval: number = this._options.autoSendInterval ?? this.DEFAULT_UPDATE_INTERVAL; + + private _updateIntervalId?: ReturnType; + + constructor( + private readonly _options: BacktraceMetricsOptions, + private readonly _sessionProvider: BacktraceSessionProvider, + private readonly _attributeManager: AttributeManager, + private readonly _summedEventsSubmissionQueue: MetricsQueue, + private readonly _uniqueEventsSubmissionQueue: MetricsQueue, + ) {} + + /** + * Starts metrics submission. + */ + public start() { + if (!this._sessionProvider.newSession) { + return; + } + + this.addSummedEvent('Application Launches'); + this.send(); + + if (this._updateInterval === 0) { + return; + } + this._updateIntervalId = setInterval(() => { + this.send(); + }, this._updateInterval); + } + + /** + * Returns total number of events in the submission queue. + */ + public count() { + return this._summedEventsSubmissionQueue?.total ?? 0 + this._uniqueEventsSubmissionQueue?.total ?? 0; + } + + /** + * Add summed event to next Backtrace Metrics request. + * @param metricName Summed event name. + * @param eventAttributes event attributes. + */ + public addSummedEvent(metricName: string, eventAttributes: Record = {}): boolean { + if (!metricName) { + return false; + } + const attributes = this.convertAttributes({ + ...this._attributeManager.get().attributes, + ...ReportDataBuilder.build(eventAttributes ?? {}).attributes, + }); + + this._summedEventsSubmissionQueue.add(new SummedEvent(metricName, attributes)); + + return true; + } + + /** + * Sends event to the metrics system. + */ + public send() { + if (!this._sessionProvider.shouldSend()) { + return false; + } + this.sendUniqueEvent(); + this._summedEventsSubmissionQueue.send(); + this._sessionProvider.afterMetricsSubmission(); + return true; + } + + /** + * Cleans up metrics interface. + */ + public close() { + if (this._updateIntervalId) { + clearInterval(this._updateIntervalId); + } + } + + private sendUniqueEvent() { + // always add the same unique event before send. + const { attributes } = this._attributeManager.get(); + this._uniqueEventsSubmissionQueue.add(new UniqueEvent(this.convertAttributes(attributes))); + this._uniqueEventsSubmissionQueue.send(); + } + + /** + * Event aggregators expecting to retrieve attributes in a string format. They also + * don't expect to retrieve null/undefined as attribute values. + */ + private convertAttributes(attributes: Record) { + return Object.keys(attributes) + .filter((n) => attributes[n] != null) + .reduce((acc, n) => { + acc[n] = attributes[n]?.toString(); + return acc; + }, {} as Record); + } +} diff --git a/packages/sdk-core/src/modules/metrics/BacktraceSessionProvider.ts b/packages/sdk-core/src/modules/metrics/BacktraceSessionProvider.ts new file mode 100644 index 00000000..abbc5a0a --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/BacktraceSessionProvider.ts @@ -0,0 +1,20 @@ +export interface BacktraceSessionProvider { + /** + * Determines if the session just started + */ + readonly newSession: boolean; + + /** + * Current session id + */ + readonly sessionId: string; + + /** + * Returns last submission timestamp. If 0 it means metrics weren't send + */ + get lastActive(): number; + + afterMetricsSubmission(): void; + + shouldSend(): boolean; +} diff --git a/packages/sdk-core/src/modules/metrics/MetricsBuilder.ts b/packages/sdk-core/src/modules/metrics/MetricsBuilder.ts new file mode 100644 index 00000000..a238b6f1 --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/MetricsBuilder.ts @@ -0,0 +1,137 @@ +import { TimeHelper } from '../../common/TimeHelper'; +import { BacktraceConfiguration, BacktraceMetricsOptions } from '../../model/configuration/BacktraceConfiguration'; +import { BacktraceRequestHandler } from '../../model/http'; +import { AttributeManager } from '../attribute/AttributeManager'; +import { BacktraceMetrics } from './BacktraceMetrics'; +import { BacktraceSessionProvider } from './BacktraceSessionProvider'; +import { MetricsQueue } from './MetricsQueue'; +import { MetricsSubmissionQueue } from './MetricsSubmissionQueue'; +import { MetricsUrlInformation } from './MetricsUrlInformation'; +import { SummedEvent } from './model/SummedEvent'; +import { UniqueEvent } from './model/UniqueEvent'; + +interface ApplicationInfo { + application: string; + applicationVersion: string; +} +export class MetricsBuilder { + /** + * Default metrics submission interval. The variable defines how often metrics will be sent to metrics system + * By default 30 mins. + */ + public readonly DEFAULT_UPDATE_INTERVAL = TimeHelper.convertSecondsToMilliseconds(30 * 60); + + private readonly APPLICATION_VERSION_ATTRIBUTE = 'application.version'; + private readonly APPLICATION_ATTRIBUTE = 'application'; + constructor( + private readonly _options: BacktraceConfiguration, + private readonly _sessionProvider: BacktraceSessionProvider, + private readonly _attributeManager: AttributeManager, + private readonly _requestHandler: BacktraceRequestHandler, + ) {} + + public build( + uniqueEventsSubmissionQueue?: MetricsQueue, + summedEventsSubmissionQueue?: MetricsQueue, + ): BacktraceMetrics | undefined { + const metricsOptions = { + ...this.optionsWithDefaults(), + ...(this._options.metrics ?? {}), + }; + if (!metricsOptions.enable) { + return undefined; + } + const applicationInfo = this.verifyAttributeSetup(); + if (!applicationInfo) { + return undefined; + } + + uniqueEventsSubmissionQueue = + uniqueEventsSubmissionQueue ?? + this.createUniqueEventSubmissionQueue(metricsOptions.metricsSubmissionUrl as string, applicationInfo); + if (!uniqueEventsSubmissionQueue) { + return undefined; + } + + summedEventsSubmissionQueue = + summedEventsSubmissionQueue ?? + this.createSummedEventSubmissionQueue(metricsOptions.metricsSubmissionUrl as string, applicationInfo); + if (!summedEventsSubmissionQueue) { + return undefined; + } + + return new BacktraceMetrics( + metricsOptions, + this._sessionProvider, + this._attributeManager, + summedEventsSubmissionQueue, + uniqueEventsSubmissionQueue, + ); + } + + private verifyAttributeSetup(): ApplicationInfo | undefined { + const { attributes } = this._attributeManager.get(); + const application = attributes[this.APPLICATION_ATTRIBUTE] as string; + const applicationVersion = attributes[this.APPLICATION_VERSION_ATTRIBUTE] as string; + if (!application || !applicationVersion) { + return undefined; + } + + return { application, applicationVersion }; + } + + private createUniqueEventSubmissionQueue(metricsHost: string, applicationInfo: ApplicationInfo) { + const uniqueEventsSubmissionUrl = MetricsUrlInformation.generateUniqueEventsUrl( + metricsHost, + this._options.url, + this._options.token, + ); + + if (!uniqueEventsSubmissionUrl) { + return undefined; + } + + return new MetricsSubmissionQueue( + uniqueEventsSubmissionUrl, + 'unique_events', + this._requestHandler, + { + [this.APPLICATION_ATTRIBUTE]: applicationInfo.application, + appversion: applicationInfo.applicationVersion, + }, + this._options?.metrics?.size, + ); + } + + private createSummedEventSubmissionQueue(metricsHost: string, applicationInfo: ApplicationInfo) { + const summedEventsSubmissionUrl = MetricsUrlInformation.generateSummedEventsUrl( + metricsHost, + this._options.url, + this._options.token, + ); + + if (!summedEventsSubmissionUrl) { + return undefined; + } + + return new MetricsSubmissionQueue( + summedEventsSubmissionUrl, + 'summed_events', + this._requestHandler, + { + [this.APPLICATION_ATTRIBUTE]: applicationInfo.application, + appversion: applicationInfo.applicationVersion, + }, + this._options?.metrics?.size, + ); + } + + private optionsWithDefaults(): BacktraceMetricsOptions { + return { + enable: true, + autoSendInterval: this.DEFAULT_UPDATE_INTERVAL, + metricsSubmissionUrl: 'https://events.backtrace.io', + size: 50, + }; + } +} diff --git a/packages/sdk-core/src/modules/metrics/MetricsQueue.ts b/packages/sdk-core/src/modules/metrics/MetricsQueue.ts new file mode 100644 index 00000000..628c3c00 --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/MetricsQueue.ts @@ -0,0 +1,7 @@ +export interface MetricsQueue { + readonly total: number; + readonly submissionUrl: string; + readonly maximumEvents: number; + add(event: T): void; + send(): Promise; +} diff --git a/packages/sdk-core/src/modules/metrics/MetricsSubmissionQueue.ts b/packages/sdk-core/src/modules/metrics/MetricsSubmissionQueue.ts new file mode 100644 index 00000000..e213131c --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/MetricsSubmissionQueue.ts @@ -0,0 +1,80 @@ +import { Delay } from '../../common/DelayHelper'; +import { TimeHelper } from '../../common/TimeHelper'; +import { BacktraceRequestHandler } from '../../model/http'; +import { MetricsQueue } from './MetricsQueue'; +import { MetricsEvent } from './model/MetricsEvent'; + +export class MetricsSubmissionQueue implements MetricsQueue { + public get total() { + return this._events.length; + } + + public get submissionUrl() { + return this._submissionUrl; + } + + public readonly DELAY_BETWEEN_REQUESTS = TimeHelper.convertSecondsToMilliseconds(10); + + private readonly _events: T[] = []; + private _numberOfDroppedRequests = 0; + + private readonly MAXIMUM_NUMBER_OF_ATTEMPTS = 3; + + constructor( + private readonly _submissionUrl: string, + private readonly _eventName: string, + private readonly _requestHandler: BacktraceRequestHandler, + private readonly _metricMetadata: Record, + public readonly maximumEvents: number = 50, + ) {} + + public add(event: T) { + this._events.push(event); + if (this.reachedLimit()) { + this.send(); + } + } + + public async send() { + const eventsToProcess = this._events.splice(0); + return await this.submit(eventsToProcess); + } + + private async submit(events: T[]) { + for (let attempts = 0; attempts < this.MAXIMUM_NUMBER_OF_ATTEMPTS; attempts++) { + const response = await this._requestHandler.post( + this._submissionUrl, + JSON.stringify({ + ...this._metricMetadata, + [this._eventName]: events, + metadata: { + dropped_events: this._numberOfDroppedRequests, + }, + }), + ); + if (response.status === 'Ok') { + this._numberOfDroppedRequests = 0; + return; + } + + this._numberOfDroppedRequests++; + await Delay.wait(2 ** attempts * this.DELAY_BETWEEN_REQUESTS); + } + // if the code reached this line, it means, we couldn't send data to server + // we need to try to return events to the queue and try to send it once again later. + this.returnEventsIfPossible(events); + } + + private returnEventsIfPossible(events: T[]) { + if (this.maximumEvents < this._events.length + events.length) { + return; + } + + // push events to the beginning of the queue + this._events.unshift(...events); + } + + private reachedLimit() { + return this.maximumEvents === this._events.length && this.maximumEvents !== 0; + } +} diff --git a/packages/sdk-core/src/modules/metrics/MetricsUrlInformation.ts b/packages/sdk-core/src/modules/metrics/MetricsUrlInformation.ts index b8903742..39a47578 100644 --- a/packages/sdk-core/src/modules/metrics/MetricsUrlInformation.ts +++ b/packages/sdk-core/src/modules/metrics/MetricsUrlInformation.ts @@ -4,7 +4,7 @@ export class MetricsUrlInformation { public static generateSummedEventsUrl( hostname: string, submissionUrl: string, - credentialsToken: string | null, + credentialsToken?: string | null, ): string | undefined { const submissionInformation = this.findSubmissionInformation(submissionUrl, credentialsToken); if (!submissionInformation) { @@ -21,7 +21,7 @@ export class MetricsUrlInformation { public static generateUniqueEventsUrl( hostname: string, submissionUrl: string, - credentialsToken: string | null, + credentialsToken?: string | null, ): string | undefined { const submissionInformation = this.findSubmissionInformation(submissionUrl, credentialsToken); if (!submissionInformation) { @@ -47,7 +47,7 @@ export class MetricsUrlInformation { private static findSubmissionInformation( submissionUrl: string, - token: string | null, + token?: string | null, ): { universe: string; token: string } | undefined { const universe = SubmissionUrlInformation.findUniverse(submissionUrl); if (!universe) { diff --git a/packages/sdk-core/src/modules/metrics/SingleSessionProvider.ts b/packages/sdk-core/src/modules/metrics/SingleSessionProvider.ts new file mode 100644 index 00000000..87b6abf1 --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/SingleSessionProvider.ts @@ -0,0 +1,24 @@ +import { IdGenerator } from '../../common/IdGenerator'; +import { TimeHelper } from '../../common/TimeHelper'; +import { BacktraceSessionProvider } from './BacktraceSessionProvider'; + +export class SingleSessionProvider implements BacktraceSessionProvider { + public readonly newSession: boolean = true; + public readonly sessionId: string = IdGenerator.uuid(); + private _lastActive = 0; + + public get lastActive() { + return this._lastActive; + } + + public afterMetricsSubmission(): void { + this._lastActive = TimeHelper.now(); + } + /** + * Allow to alway send metrics - in the single session there is no reason + * to skip sending metrics. + */ + public shouldSend(): boolean { + return true; + } +} diff --git a/packages/sdk-core/src/modules/metrics/model/MetricsEvent.ts b/packages/sdk-core/src/modules/metrics/model/MetricsEvent.ts new file mode 100644 index 00000000..9235ec05 --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/model/MetricsEvent.ts @@ -0,0 +1,20 @@ +import { IdGenerator } from '../../../common/IdGenerator'; +import { TimeHelper } from '../../../common/TimeHelper'; +import { AttributeType } from '../../../model/data/BacktraceData'; + +export class MetricsEvent { + public readonly id = IdGenerator.uuid(); + constructor( + public readonly metricGroupName: string, + public readonly metricGroupValue: string | string[], + public readonly attributes: Record = {}, + ) {} + + public toJSON() { + return { + timestamp: TimeHelper.toTimestampInSec(TimeHelper.now()), + attributes: this.attributes, + [this.metricGroupName]: this.metricGroupValue, + }; + } +} diff --git a/packages/sdk-core/src/modules/metrics/model/SummedEvent.ts b/packages/sdk-core/src/modules/metrics/model/SummedEvent.ts new file mode 100644 index 00000000..4356675f --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/model/SummedEvent.ts @@ -0,0 +1,8 @@ +import { AttributeType } from '../../../model/data/BacktraceData'; +import { MetricsEvent } from './MetricsEvent'; + +export class SummedEvent extends MetricsEvent { + constructor(metricGroupName: string, attributes: Record = {}) { + super('metric_group', metricGroupName, attributes); + } +} diff --git a/packages/sdk-core/src/modules/metrics/model/UniqueEvent.ts b/packages/sdk-core/src/modules/metrics/model/UniqueEvent.ts new file mode 100644 index 00000000..4bcc0e22 --- /dev/null +++ b/packages/sdk-core/src/modules/metrics/model/UniqueEvent.ts @@ -0,0 +1,8 @@ +import { AttributeType } from '../../../model/data/BacktraceData'; +import { MetricsEvent } from './MetricsEvent'; + +export class UniqueEvent extends MetricsEvent { + constructor(attributes: Record) { + super('unique', ['guid'], attributes); + } +} diff --git a/packages/sdk-core/tests/metrics/metricSetupTests.spec.ts b/packages/sdk-core/tests/metrics/metricSetupTests.spec.ts new file mode 100644 index 00000000..7d964a65 --- /dev/null +++ b/packages/sdk-core/tests/metrics/metricSetupTests.spec.ts @@ -0,0 +1,127 @@ +import { AttributeManager } from '../../src/modules/attribute/AttributeManager'; +import { MetricsBuilder } from '../../src/modules/metrics/MetricsBuilder'; +import { SingleSessionProvider } from '../../src/modules/metrics/SingleSessionProvider'; +import { APPLICATION, APPLICATION_VERSION, TEST_SUBMISSION_URL } from '../mocks/BacktraceTestClient'; +import { testHttpClient } from '../mocks/testHttpClient'; + +describe('Metric setup', () => { + let attributeManager: AttributeManager; + + beforeEach(() => { + attributeManager = new AttributeManager([]); + }); + + describe('Enabled metrics', () => { + it('Should successfuly build metrics client', () => { + attributeManager.add({ + application: APPLICATION, + ['application.version']: APPLICATION_VERSION, + }); + + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + expect(metrics).toBeDefined(); + }); + + it('Should successfuly send metrics', () => { + attributeManager.add({ + application: APPLICATION, + ['application.version']: APPLICATION_VERSION, + }); + + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + if (!metrics) { + return fail('Metrics is not defined'); + } + expect(metrics.send()).toBeTruthy(); + }); + }); + describe('Disabled metrics', () => { + it(`Shouldn't build a client with invalid url`, () => { + const metrics = new MetricsBuilder( + { + url: 'https://definitely-different.submission.url', + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + expect(metrics).toBeUndefined(); + }); + + it(`Shouldn't build a client without application name`, () => { + attributeManager.add({ + application: undefined, + ['application.version']: APPLICATION_VERSION, + }); + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + expect(metrics).toBeUndefined(); + }); + + it(`Shouldn't build a client without application version`, () => { + attributeManager.add({ + application: APPLICATION, + ['application.version']: undefined, + }); + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + expect(metrics).toBeUndefined(); + }); + + it('Should not build the metric client if metrics are disabled', () => { + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + enable: false, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + expect(metrics).toBeUndefined(); + }); + }); +}); diff --git a/packages/sdk-core/tests/metrics/mocks/mockSubmissionQueue.ts b/packages/sdk-core/tests/metrics/mocks/mockSubmissionQueue.ts new file mode 100644 index 00000000..9000a8ff --- /dev/null +++ b/packages/sdk-core/tests/metrics/mocks/mockSubmissionQueue.ts @@ -0,0 +1,14 @@ +import { MetricsQueue } from '../../../src/modules/metrics/MetricsQueue'; +import { MetricsEvent } from '../../../src/modules/metrics/model/MetricsEvent'; + +export const mockSubmissionQueue: MetricsQueue = { + total: 0, + submissionUrl: 'fake-http-url', + maximumEvents: 0, + add: () => { + return; + }, + send: () => { + return Promise.resolve(); + }, +}; diff --git a/packages/sdk-core/tests/metrics/summedEventTests.spec.ts b/packages/sdk-core/tests/metrics/summedEventTests.spec.ts new file mode 100644 index 00000000..6473f2cd --- /dev/null +++ b/packages/sdk-core/tests/metrics/summedEventTests.spec.ts @@ -0,0 +1,193 @@ +import { TimeHelper } from '../../src/common/TimeHelper'; +import { AttributeType } from '../../src/model/data/BacktraceData'; +import { AttributeManager } from '../../src/modules/attribute/AttributeManager'; +import { MetricsBuilder } from '../../src/modules/metrics/MetricsBuilder'; +import { MetricsUrlInformation } from '../../src/modules/metrics/MetricsUrlInformation'; +import { SummedEvent } from '../../src/modules/metrics/model/SummedEvent'; +import { SingleSessionProvider } from '../../src/modules/metrics/SingleSessionProvider'; +import { APPLICATION, APPLICATION_VERSION, TEST_SUBMISSION_URL } from '../mocks/BacktraceTestClient'; +import { testHttpClient } from '../mocks/testHttpClient'; +import { mockSubmissionQueue } from './mocks/mockSubmissionQueue'; + +describe('Summed events tests', () => { + let timestamp: number; + const attributeManager = new AttributeManager([]); + + beforeEach(() => { + attributeManager.add({ + application: APPLICATION, + ['application.version']: APPLICATION_VERSION, + }); + timestamp = TimeHelper.now(); + jest.spyOn(TimeHelper, 'now').mockImplementation(() => { + return timestamp; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Should send summed event on the metrics start', () => { + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + const summedEventsSubmissionUrl = MetricsUrlInformation.generateSummedEventsUrl( + metrics.metricsHost, + TEST_SUBMISSION_URL, + ); + + const expectedJson = { + application: APPLICATION, + appversion: APPLICATION_VERSION, + summed_events: [new SummedEvent('Application Launches', attributeManager.attributes)], + metadata: { + dropped_events: 0, + }, + }; + + metrics.start(); + + expect(testHttpClient.post).toBeCalledWith(summedEventsSubmissionUrl, JSON.stringify(expectedJson)); + }); + + it('Should send summed event to overriden submission URL', () => { + const expectedBaseUrl = 'https://test-metrics-submission-url.com'; + + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + metricsSubmissionUrl: expectedBaseUrl, + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + const summedEventsSubmissionUrl = MetricsUrlInformation.generateSummedEventsUrl( + metrics.metricsHost, + TEST_SUBMISSION_URL, + ); + + const expectedJson = { + application: APPLICATION, + appversion: APPLICATION_VERSION, + summed_events: [new SummedEvent('Application Launches', attributeManager.attributes)], + metadata: { + dropped_events: 0, + }, + }; + + metrics.start(); + + expect(testHttpClient.post).toBeCalledWith(summedEventsSubmissionUrl, JSON.stringify(expectedJson)); + }); + + it('Should send summed event with custom attributes to the server', () => { + const customAttributes: Record = { + 'custom-attribute': 'custom-attribute', + 'second-attribute': 'false', + }; + attributeManager.add(customAttributes); + + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + const expectedJson = { + application: APPLICATION, + appversion: APPLICATION_VERSION, + summed_events: [new SummedEvent('Application Launches', attributeManager.attributes)], + metadata: { + dropped_events: 0, + }, + }; + + metrics.start(); + + expect(attributeManager.attributes).toMatchObject(customAttributes); + expect(testHttpClient.post).toBeCalledWith(expect.anything(), JSON.stringify(expectedJson)); + }); + + it('Should add summed event to the submission queue', () => { + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + metrics.start(); + const addResult = metrics.addSummedEvent('test-metric'); + + expect(addResult).toBeTruthy(); + expect(testHttpClient.post).toBeCalledTimes(1); + expect(metrics.count()).toEqual(1); + }); + + it('Should send summed events to server when reached the limit', () => { + const maximumNumberOfEvents = 3; + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + size: maximumNumberOfEvents, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + + metrics.start(); + + for (let index = 0; index < maximumNumberOfEvents; index++) { + const addResult = metrics.addSummedEvent('test-metric'); + expect(addResult).toBeTruthy(); + } + + expect(testHttpClient.post).toBeCalledTimes(2); + }); +}); diff --git a/packages/sdk-core/tests/metrics/uniqueEventTests.spec.ts b/packages/sdk-core/tests/metrics/uniqueEventTests.spec.ts new file mode 100644 index 00000000..73af1bc6 --- /dev/null +++ b/packages/sdk-core/tests/metrics/uniqueEventTests.spec.ts @@ -0,0 +1,172 @@ +import { fail } from 'assert'; +import { TimeHelper } from '../../src/common/TimeHelper'; +import { AttributeType } from '../../src/model/data/BacktraceData'; +import { AttributeManager } from '../../src/modules/attribute/AttributeManager'; +import { MetricsBuilder } from '../../src/modules/metrics/MetricsBuilder'; +import { MetricsUrlInformation } from '../../src/modules/metrics/MetricsUrlInformation'; +import { UniqueEvent } from '../../src/modules/metrics/model/UniqueEvent'; +import { SingleSessionProvider } from '../../src/modules/metrics/SingleSessionProvider'; +import { APPLICATION, APPLICATION_VERSION, TEST_SUBMISSION_URL } from '../mocks/BacktraceTestClient'; +import { testHttpClient } from '../mocks/testHttpClient'; +import { mockSubmissionQueue } from './mocks/mockSubmissionQueue'; + +describe('Unique events tests', () => { + let timestamp: number; + const attributeManager = new AttributeManager([]); + + beforeEach(() => { + attributeManager.add({ + application: APPLICATION, + ['application.version']: APPLICATION_VERSION, + }); + timestamp = TimeHelper.now(); + jest.spyOn(TimeHelper, 'now').mockImplementation(() => { + return timestamp; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Should send unique event on the metrics start', () => { + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(undefined, mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + const uniqueEventsSubmissionUrl = MetricsUrlInformation.generateUniqueEventsUrl( + metrics.metricsHost, + TEST_SUBMISSION_URL, + ); + + const expectedJson = { + application: APPLICATION, + appversion: APPLICATION_VERSION, + unique_events: [new UniqueEvent(attributeManager.attributes)], + metadata: { + dropped_events: 0, + }, + }; + + metrics.start(); + + expect(testHttpClient.post).toBeCalledWith(uniqueEventsSubmissionUrl, JSON.stringify(expectedJson)); + }); + + it('Should send unique event to overriden submission URL', () => { + const expectedBaseUrl = 'https://test-metrics-submission-url.com'; + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + metricsSubmissionUrl: expectedBaseUrl, + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(undefined, mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + + const uniqueEventsSubmissionUrl = MetricsUrlInformation.generateUniqueEventsUrl( + expectedBaseUrl, + TEST_SUBMISSION_URL, + ); + + metrics.start(); + + expect(testHttpClient.post).toBeCalledWith(uniqueEventsSubmissionUrl, expect.anything()); + }); + + it(`Shouldn't build a client with invalid url`, () => { + const metrics = new MetricsBuilder( + { + url: 'https://definitely-different.submission.url', + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + expect(metrics).toBeUndefined(); + }); + + it(`Shouldn't build a client without application name/version`, () => { + attributeManager.add({ + application: undefined, + ['application.version']: undefined, + }); + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(); + + expect(metrics).toBeUndefined(); + }); + + it('Should send unique event with custom attributes to the server', () => { + const customAttributes: Record = { + 'custom-attribute': 'custom-attribute', + 'second-attribute': 'false', + }; + attributeManager.add(customAttributes); + const metrics = new MetricsBuilder( + { + url: TEST_SUBMISSION_URL, + metrics: { + autoSendInterval: 0, + }, + }, + new SingleSessionProvider(), + attributeManager, + testHttpClient, + ).build(undefined, mockSubmissionQueue); + + if (!metrics) { + fail('Metrics are not defined'); + } + const uniqueEventsSubmissionUrl = MetricsUrlInformation.generateUniqueEventsUrl( + metrics.metricsHost, + TEST_SUBMISSION_URL, + ); + + const expectedJson = { + application: APPLICATION, + appversion: APPLICATION_VERSION, + unique_events: [new UniqueEvent(attributeManager.attributes)], + metadata: { + dropped_events: 0, + }, + }; + + metrics.start(); + + expect(attributeManager.attributes).toMatchObject(customAttributes); + expect(testHttpClient.post).toBeCalledWith(uniqueEventsSubmissionUrl, JSON.stringify(expectedJson)); + }); +}); diff --git a/packages/sdk-core/tests/mocks/BacktraceTestClient.ts b/packages/sdk-core/tests/mocks/BacktraceTestClient.ts index e58e7b20..27c47875 100644 --- a/packages/sdk-core/tests/mocks/BacktraceTestClient.ts +++ b/packages/sdk-core/tests/mocks/BacktraceTestClient.ts @@ -2,11 +2,14 @@ import { BacktraceAttachment, BacktraceAttributeProvider, BacktraceCoreClient, + BacktraceReportSubmissionResult, BacktraceRequestHandler, } from '../../src'; export const TOKEN = '590d39eb154cff1d30f2b689f9a928bb592b25e7e7c10192fe208485ea68d91c'; export const UNIVERSE_NAME = 'test'; export const TEST_SUBMISSION_URL = `https://${UNIVERSE_NAME}.sp.backtrace.io:6098/post?format=json&token=${TOKEN}`; +export const APPLICATION = 'test-app'; +export const APPLICATION_VERSION = '5.4.3'; export class BacktraceTestClient extends BacktraceCoreClient { public readonly requestHandler: BacktraceRequestHandler; constructor( @@ -19,6 +22,9 @@ export class BacktraceTestClient extends BacktraceCoreClient { url: TEST_SUBMISSION_URL, token: TOKEN, attachments, + metrics: { + enable: false, + }, }, { agent: 'test', @@ -36,9 +42,18 @@ export class BacktraceTestClient extends BacktraceCoreClient { attributeProviders: BacktraceAttributeProvider[] = [], attachments: BacktraceAttachment[] = [], ) { + attributeProviders.push({ + type: 'scoped', + get() { + return { + application: APPLICATION, + ['application.version']: APPLICATION_VERSION, + }; + }, + }); return new BacktraceTestClient( { - post: jest.fn().mockResolvedValue(Promise.resolve()), + post: jest.fn().mockResolvedValue(Promise.resolve(BacktraceReportSubmissionResult.Ok('Ok'))), postError: jest.fn().mockResolvedValue(Promise.resolve()), }, attributeProviders, diff --git a/packages/sdk-core/tests/mocks/testHttpClient.ts b/packages/sdk-core/tests/mocks/testHttpClient.ts new file mode 100644 index 00000000..ce2610d1 --- /dev/null +++ b/packages/sdk-core/tests/mocks/testHttpClient.ts @@ -0,0 +1,6 @@ +import { BacktraceReportSubmissionResult, BacktraceRequestHandler } from '../../src'; + +export const testHttpClient: BacktraceRequestHandler = { + post: jest.fn().mockResolvedValue(Promise.resolve(BacktraceReportSubmissionResult.Ok('Ok'))), + postError: jest.fn().mockResolvedValue(Promise.resolve()), +};