Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/WebTelemetryBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ export abstract class WebTelemetryBase<P, R> {
this.callTransport([...this.events]);
}

protected flushBufferedEvents() {
clearTimeout(this.timer);

if (!this.events.length) {
return;
}

this.sendHandler();
this.events = [];
}

/**
* Планирует отправку данных на сервер
*/
Expand Down
200 changes: 198 additions & 2 deletions src/presets/WebTelemetryResources.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>;
};

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<string, string | number> = {}): 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/,
Expand Down Expand Up @@ -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');
});
});
});
113 changes: 89 additions & 24 deletions src/presets/WebTelemetryResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export class WebTelemetryResources extends WebTelemetryBase<WebTelemetryResource
private static observer: PerformanceObserver | undefined;
private validatePerformanceEntry;
private isObservationStarted: boolean = false;
private isObservationContinuedAfterLoad: boolean = false;
private isFinalized: boolean = false;

private readonly onDocumentReady = () => {
void this.finalizeAfterDocumentReady();
};

constructor(name: string, config: WebTelemetryResourcesConfig, transports?: Array<WebTelemetryTransport>) {
super(config, [], transports);
Expand All @@ -68,6 +74,7 @@ export class WebTelemetryResources extends WebTelemetryBase<WebTelemetryResource
config.resourcesBlackList || [],
);
this.name = name;
this.isObservationContinuedAfterLoad = config.observeAfterLoad ?? false;

const handler = this.handler.bind(this);

Expand All @@ -77,46 +84,90 @@ export class WebTelemetryResources extends WebTelemetryBase<WebTelemetryResource
}

private handler(entryList: PerformanceObserverEntryList) {
if (this.config.disabled) {
if (this.config.disabled || this.isFinalized) {
return;
}

const resources = entryList.getEntriesByType('resource').filter(this.validatePerformanceEntry);
for (const res of resources) {
/**
* Эта проверка необходима чтобы `PerformanceObserver` не тригерился
* на отправку данных в бекенд. Если этого не сделать, то шедулер будет
* бесконечно планировать отправку данных после любой отправки данных
*/
if (res.name && this.config.endpoint && res.name.includes(this.config.endpoint)) {
continue;
}
void this.processResources(entryList.getEntriesByType('resource'));
}

const resData = res.toJSON ? res.toJSON() : {};
private shouldSkipResource(res: PerformanceEntry) {
/**
* Эта проверка необходима чтобы `PerformanceObserver` не тригерился
* на отправку данных в бекенд. Если этого не сделать, то шедулер будет
* бесконечно планировать отправку данных после любой отправки данных
*/
return Boolean(res.name && this.config.endpoint && res.name.includes(this.config.endpoint));
}

const evt: WebTelemetryResourcesData = {
hostname: window.location.hostname,
project: this.name,
path: window.location.href,
};
private createResourcePayload(res: PerformanceEntry) {
const resData = res.toJSON ? res.toJSON() : {};

for (const entryName of FIELDS_TO_EXTRACT) {
const value = resData[entryName];
if (value) {
evt[entryName] = typeof value === 'number' ? Math.round(value) : value;
}
const evt: WebTelemetryResourcesData = {
hostname: window.location.hostname,
project: this.name,
path: window.location.href,
};

for (const entryName of FIELDS_TO_EXTRACT) {
const value = resData[entryName];
if (value) {
evt[entryName] = typeof value === 'number' ? Math.round(value) : value;
}
}

this.push(evt);
return evt;
}

private async processResources(resources: PerformanceEntry[]) {
if (this.config.disabled) {
return;
}

const createdEvents = await Promise.all(
resources
.filter(this.validatePerformanceEntry)
.filter((entry) => !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) {
return payload;
}

public start() {
if (this.isObservationStarted) {
if (this.isObservationStarted || this.isFinalized) {
return;
}
try {
Expand All @@ -126,13 +177,27 @@ export class WebTelemetryResources extends WebTelemetryBase<WebTelemetryResource
buffered: true,
});
this.isObservationStarted = true;

if (document.readyState === 'complete') {
if (!this.isObservationContinuedAfterLoad) {
void this.finalizeAfterDocumentReady();
}
return;
}

if (!this.isObservationContinuedAfterLoad) {
window.addEventListener('load', this.onDocumentReady, { once: true });
}
}
// eslint-disable-next-line no-empty
} catch (_e) {}
}

public end() {
this.removeDocumentReadyListener();

if (WebTelemetryResources.observer) {
this.flushBufferedEvents();
WebTelemetryResources.observer.disconnect();
this.isObservationStarted = false;
}
Expand Down
Loading
Loading