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
172 changes: 167 additions & 5 deletions src/WebTelemetryBase.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';

import type { WebTelemetryAddon } from './types.js';
import type { WebTelemetryAddon, WebTelemetryBaseEvent } from './types.js';
import { WebTelemetryBase } from './WebTelemetryBase.js';

class Addon1 implements WebTelemetryAddon<{ addon1Data: string }, { addon1Metadata: string }> {
Expand All @@ -23,7 +23,7 @@ class Addon2 implements WebTelemetryAddon<{ addon2Data: string }, { addon2Metada
}
}

class WebTelemetry<T> extends WebTelemetryBase<T, T> {
class WebTelemetry<T extends object> extends WebTelemetryBase<T, T> {
protected payloadToJSON(payload: T): T {
return payload;
}
Expand All @@ -32,11 +32,25 @@ class WebTelemetry<T> extends WebTelemetryBase<T, T> {
// do nothing
}

public getEvents() {
return this.events;
public getEvents(): Array<WebTelemetryBaseEvent & T> {
return this.events as Array<WebTelemetryBaseEvent & T>;
}
}

class WebTelemetryBatch<T extends object> extends WebTelemetryBase<T, T> {
protected payloadToJSON(payload: T): T {
return payload;
}
}

function setDocumentVisibility(visibilityState: DocumentVisibilityState) {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: visibilityState,
});
document.dispatchEvent(new Event('visibilitychange'));
}

describe('WebTelemetryBase', () => {
describe('addons', () => {
let instance: WebTelemetry<any>;
Expand Down Expand Up @@ -73,4 +87,152 @@ describe('WebTelemetryBase', () => {
expect(actualMetadata).toMatchObject(expectedMetadata);
});
});

describe('queue overflow', () => {
it('drops oldest events when queue is over maxQueueSize', async () => {
const instance = new WebTelemetry<{ data: string }>(
{
projectName: 'project-name',
debug: true,
maxQueueSize: 2,
queueOverflowStrategy: 'drop_oldest',
},
[],
);

await instance.push({ data: 'first' });
await instance.push({ data: 'second' });
await instance.push({ data: 'third' });

expect(instance.getEvents().map((event) => event.data)).toEqual(['second', 'third']);
expect(instance.droppedEventsCount).toBe(1);
});

it('drops newest events when queue is over maxQueueSize', async () => {
const instance = new WebTelemetry<{ data: string }>(
{
projectName: 'project-name',
debug: true,
maxQueueSize: 2,
queueOverflowStrategy: 'drop_newest',
},
[],
);

await instance.push({ data: 'first' });
await instance.push({ data: 'second' });
await instance.push({ data: 'third' });

expect(instance.getEvents().map((event) => event.data)).toEqual(['first', 'second']);
expect(instance.droppedEventsCount).toBe(1);
});
});

describe('visibility and freeze', () => {
afterEach(() => {
vi.useRealTimers();
setDocumentVisibility('visible');
});

it('sends while document is hidden by default', async () => {
const send = vi.fn();
const transport = { send };

const instance = new WebTelemetryBatch(
{
projectName: 'project-name',
debug: false,
delay: 50,
buffSize: 1,
},
[],
[transport],
);

setDocumentVisibility('hidden');

await instance.push({ data: 'data' });

expect(send).toHaveBeenCalledTimes(1);
});

it('does not call transport while document is hidden; flushes after visible', async () => {
const send = vi.fn();
const transport = { send };

const instance = new WebTelemetryBatch(
{
projectName: 'project-name',
debug: false,
delay: 50,
buffSize: 1,
pauseSendingWhenPageInactive: true,
},
[],
[transport],
);

setDocumentVisibility('hidden');

await instance.push({ data: 'data' });

expect(send).not.toHaveBeenCalled();

setDocumentVisibility('visible');

expect(send).toHaveBeenCalledTimes(1);
});

it('sends pushListAndSend events immediately while document is hidden', async () => {
const send = vi.fn();
const transport = { send };

const instance = new WebTelemetryBatch(
{
projectName: 'project-name',
debug: false,
pauseSendingWhenPageInactive: true,
},
[],
[transport],
);

setDocumentVisibility('hidden');

await instance.pushListAndSend([{ payload: { data: 'data' } }]);

expect(send).toHaveBeenCalledTimes(1);
});

it('clears scheduled send on freeze and sends after resume', async () => {
vi.useFakeTimers();

const send = vi.fn();
const transport = { send };

const instance = new WebTelemetryBatch(
{
projectName: 'project-name',
debug: false,
delay: 100,
buffSize: 100,
pauseSendingWhenPageInactive: true,
},
[],
[transport],
);

await instance.push({ data: 'data' });

document.dispatchEvent(new Event('freeze'));

vi.advanceTimersByTime(500);
expect(send).not.toHaveBeenCalled();

document.dispatchEvent(new Event('resume'));

vi.advanceTimersByTime(100);
expect(send).toHaveBeenCalledTimes(1);
});
});
});
92 changes: 91 additions & 1 deletion src/WebTelemetryBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ export abstract class WebTelemetryBase<P, R> {

private timer: number | undefined;

public droppedEventsCount = 0;

/** Страница в lifecycle-состоянии frozen (Page Lifecycle API). */
private documentFrozen = false;

private readonly onVisibilityChange = () => {
if (typeof document === 'undefined') {
return;
}

if (document.visibilityState === 'hidden') {
clearTimeout(this.timer);
return;
}

if (this.events.length > 0) {
this.scheduleSend();
}
};

private readonly onFreeze = () => {
this.documentFrozen = true;
clearTimeout(this.timer);
};

private readonly onResume = () => {
this.documentFrozen = false;

if (this.events.length > 0) {
this.scheduleSend();
}
};

/**
*
* @param config конфигурация
Expand Down Expand Up @@ -55,6 +88,42 @@ export abstract class WebTelemetryBase<P, R> {
? [new WebTelemetryTransportDebug()]
: [new WebTelemetryTransportDefault(`${this.config.endpoint}/${this.config.projectName}`)];
}

if (this.config.pauseSendingWhenPageInactive && typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
document.addEventListener('freeze', this.onFreeze);
document.addEventListener('resume', this.onResume);
}
}

private canSendTelemetry(): boolean {
if (!this.config.pauseSendingWhenPageInactive || typeof document === 'undefined') {
return true;
}

return document.visibilityState === 'visible' && !this.documentFrozen;
}

protected addEventsToQueue(events: Array<WebTelemetryBaseEvent>) {
this.events.push(...events);
this.dropOverflowingEvents();
}

private dropOverflowingEvents() {
const maxQueueSize = Math.max(0, this.config.maxQueueSize);
const overflow = this.events.length - maxQueueSize;

if (overflow <= 0) {
return;
}

if (this.config.queueOverflowStrategy === 'drop_oldest') {
this.events.splice(0, overflow);
} else {
this.events.length = maxQueueSize;
}

this.droppedEventsCount += overflow;
}

/**
Expand All @@ -78,17 +147,38 @@ export abstract class WebTelemetryBase<P, R> {
this.callTransport([...this.events]);
}

/**
* Немедленно отправляет накопленные события и сбрасывает таймер отложенной отправки.
*/
protected flushBufferedEvents(): void {
clearTimeout(this.timer);
this.timer = undefined;

if (this.events.length > 0) {
this.sendHandler();
this.events = [];
}
}

/**
* Планирует отправку данных на сервер
*/
protected scheduleSend() {
clearTimeout(this.timer);

if (!this.canSendTelemetry()) {
return;
}

if (this.config.buffSize && this.events.length >= this.config.buffSize) {
this.sendHandler();
this.events = [];
} else {
this.timer = window.setTimeout(() => {
if (!this.canSendTelemetry()) {
return;
}

this.sendHandler();
this.events = [];
}, this.config.delay);
Expand Down Expand Up @@ -137,7 +227,7 @@ export abstract class WebTelemetryBase<P, R> {
const evt = this.createEvent(payload, meta);

evt.then((data) => {
this.events.push(data);
this.addEventsToQueue([data]);
this.scheduleSend();
}).catch((error) => {
console.error(error);
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ export const defaultConfig: WebTelemetryBaseConfig = {
debug: false,
delay: 2000,
buffSize: 25,
pauseSendingWhenPageInactive: false,
maxQueueSize: 1000,
queueOverflowStrategy: 'drop_oldest',
frameTime: true,
};
Loading
Loading