diff --git a/src/WebTelemetryBase.ts b/src/WebTelemetryBase.ts index 7e196a6..8bc147d 100644 --- a/src/WebTelemetryBase.ts +++ b/src/WebTelemetryBase.ts @@ -78,6 +78,17 @@ export abstract class WebTelemetryBase { this.callTransport([...this.events]); } + protected flushBufferedEvents() { + clearTimeout(this.timer); + + if (!this.events.length) { + return; + } + + this.sendHandler(); + this.events = []; + } + /** * Планирует отправку данных на сервер */ diff --git a/src/presets/WebTelemetryResources.spec.ts b/src/presets/WebTelemetryResources.spec.ts index 6be2dae..ba226a9 100644 --- a/src/presets/WebTelemetryResources.spec.ts +++ b/src/presets/WebTelemetryResources.spec.ts @@ -1,8 +1,113 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { validatePerformanceEntry, VIDEO_URLs } from './WebTelemetryResources.js'; +import { WebTelemetryResources, validatePerformanceEntry, VIDEO_URLs } from './WebTelemetryResources.js'; + +type ResourceEntry = PerformanceEntry & { + toJSON(): Record; +}; + +class MockPerformanceObserver { + static instance: MockPerformanceObserver | undefined; + static bufferedEntries: PerformanceEntry[] = []; + + disconnected = false; + private callback: PerformanceObserverCallback; + private records: PerformanceEntry[] = []; + + constructor(callback: PerformanceObserverCallback) { + this.callback = callback; + MockPerformanceObserver.instance = this; + } + + observe(options: PerformanceObserverInit) { + if (options.type === 'resource' && options.buffered) { + this.records.push(...MockPerformanceObserver.bufferedEntries); + MockPerformanceObserver.bufferedEntries = []; + } + } + + disconnect() { + this.disconnected = true; + } + + takeRecords() { + const records = [...this.records]; + this.records = []; + return records; + } + + emit(entries: PerformanceEntry[]) { + if (this.disconnected) { + return; + } + + const entryList = { + getEntries: () => entries, + getEntriesByName: () => [], + getEntriesByType: (type: string) => (type === 'resource' ? entries : []), + } as PerformanceObserverEntryList; + + this.callback(entryList, this as unknown as PerformanceObserver); + } + + queue(entries: PerformanceEntry[]) { + this.records.push(...entries); + } +} + +const originalPerformanceObserver = window.PerformanceObserver; + +function setPerformanceObserver(value: typeof PerformanceObserver | undefined) { + Object.defineProperty(window, 'PerformanceObserver', { + configurable: true, + writable: true, + value, + }); +} + +function setReadyState(value: DocumentReadyState) { + Object.defineProperty(document, 'readyState', { + configurable: true, + value, + }); +} + +function createResourceEntry(name: string, values: Record = {}): ResourceEntry { + return { + name, + entryType: 'resource', + startTime: 0, + duration: 0, + toJSON: () => ({ + name, + entryType: 'resource', + ...values, + }), + } as ResourceEntry; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} describe('presets', () => { + beforeEach(() => { + MockPerformanceObserver.instance = undefined; + MockPerformanceObserver.bufferedEntries = []; + setPerformanceObserver(MockPerformanceObserver as unknown as typeof PerformanceObserver); + (WebTelemetryResources as unknown as { observer?: PerformanceObserver }).observer = undefined; + }); + + afterEach(() => { + vi.useRealTimers(); + Reflect.deleteProperty(document, 'readyState'); + setPerformanceObserver(originalPerformanceObserver); + (WebTelemetryResources as unknown as { observer?: PerformanceObserver }).observer = undefined; + }); + describe('WebTelemetryResources:validatePerformanceEntry', () => { const firstBlackList = [ /api\.amplitude\.com/, @@ -59,4 +164,95 @@ describe('presets', () => { expect(mixedEntres.filter(validator)).toEqual(desiredEntres); }); }); + + describe('WebTelemetryResources:document ready', () => { + it('flushes collected resources on load and stops further observation', async () => { + vi.useFakeTimers(); + setReadyState('loading'); + + const send = vi.fn(); + const telemetry = new WebTelemetryResources( + 'test-project', + { + projectName: 'test-project-resources', + endpoint: 'https://telemetry.example.com', + delay: 1_000, + buffSize: 10, + debug: false, + }, + [{ send }], + ); + + telemetry.start(); + + const observer = MockPerformanceObserver.instance; + + expect(observer).toBeDefined(); + + observer?.emit([createResourceEntry('https://static.example.com/app.js', { duration: 21 })]); + await flushMicrotasks(); + + observer?.queue([createResourceEntry('https://static.example.com/app.css', { duration: 31 })]); + + expect(send).not.toHaveBeenCalled(); + + setReadyState('complete'); + window.dispatchEvent(new Event('load')); + await flushMicrotasks(); + + expect(observer?.disconnected).toBe(true); + expect(send).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(send.mock.calls[0][0]); + + expect(payload).toHaveLength(2); + expect(payload.map((entry: { name: string }) => entry.name)).toEqual([ + 'https://static.example.com/app.js', + 'https://static.example.com/app.css', + ]); + + vi.advanceTimersByTime(1_000); + await flushMicrotasks(); + + expect(send).toHaveBeenCalledTimes(1); + + observer?.emit([createResourceEntry('https://static.example.com/late.js', { duration: 12 })]); + await flushMicrotasks(); + + expect(send).toHaveBeenCalledTimes(1); + }); + + it('flushes buffered resources immediately when started after document ready', async () => { + setReadyState('complete'); + MockPerformanceObserver.bufferedEntries = [ + createResourceEntry('https://static.example.com/font.woff2', { duration: 14 }), + ]; + + const send = vi.fn(); + const telemetry = new WebTelemetryResources( + 'test-project', + { + projectName: 'test-project-resources', + endpoint: 'https://telemetry.example.com', + delay: 1_000, + buffSize: 10, + debug: false, + }, + [{ send }], + ); + + telemetry.start(); + await flushMicrotasks(); + + const observer = MockPerformanceObserver.instance; + + expect(observer?.disconnected).toBe(true); + expect(send).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(send.mock.calls[0][0]); + + expect(payload).toHaveLength(1); + expect(payload[0].name).toBe('https://static.example.com/font.woff2'); + }); + }); }); diff --git a/src/presets/WebTelemetryResources.ts b/src/presets/WebTelemetryResources.ts index 2cf724d..e36ecbd 100644 --- a/src/presets/WebTelemetryResources.ts +++ b/src/presets/WebTelemetryResources.ts @@ -59,6 +59,12 @@ export class WebTelemetryResources extends WebTelemetryBase { + void this.finalizeAfterDocumentReady(); + }; constructor(name: string, config: WebTelemetryResourcesConfig, transports?: Array) { super(config, [], transports); @@ -68,6 +74,7 @@ export class WebTelemetryResources extends WebTelemetryBase !this.shouldSkipResource(entry)) + .map((entry) => this.createEvent(this.createResourcePayload(entry))), + ); + + if (!createdEvents.length) { + return; + } + + this.events.push(...createdEvents); + this.scheduleSend(); + } + + private removeDocumentReadyListener() { + window.removeEventListener('load', this.onDocumentReady); + } + + private async finalizeAfterDocumentReady() { + if (this.isFinalized) { + return; + } + + this.isFinalized = true; + this.removeDocumentReadyListener(); + + if (WebTelemetryResources.observer && this.isObservationStarted) { + const bufferedEntries = WebTelemetryResources.observer.takeRecords(); + WebTelemetryResources.observer.disconnect(); + this.isObservationStarted = false; + + await this.processResources(bufferedEntries); + } + + this.flushBufferedEvents(); } payloadToJSON(payload: WebTelemetryResourcesData) { @@ -116,7 +167,7 @@ export class WebTelemetryResources extends WebTelemetryBase