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