From 40bd09e9a14c0ac99869c2c6e204fe0e976b4eb3 Mon Sep 17 00:00:00 2001 From: Paul Tagankin Date: Wed, 15 Apr 2026 21:32:06 +0300 Subject: [PATCH 1/3] Do not sending telemetry while on freeze or disabled --- src/WebTelemetryBase.spec.ts | 79 +++++++++++++++++++++++++++++++++++- src/WebTelemetryBase.ts | 57 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/WebTelemetryBase.spec.ts b/src/WebTelemetryBase.spec.ts index 3a7fe7a..6a237b2 100644 --- a/src/WebTelemetryBase.spec.ts +++ b/src/WebTelemetryBase.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import type { WebTelemetryAddon } from './types.js'; import { WebTelemetryBase } from './WebTelemetryBase.js'; @@ -37,6 +37,20 @@ class WebTelemetry extends WebTelemetryBase { } } +class WebTelemetryBatch extends WebTelemetryBase { + 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; @@ -73,4 +87,67 @@ describe('WebTelemetryBase', () => { expect(actualMetadata).toMatchObject(expectedMetadata); }); }); + + describe('visibility and freeze', () => { + afterEach(() => { + vi.useRealTimers(); + setDocumentVisibility('visible'); + }); + + 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, + }, + [], + [transport], + ); + + setDocumentVisibility('hidden'); + + await instance.push({ data: 'data' }); + + expect(send).not.toHaveBeenCalled(); + + setDocumentVisibility('visible'); + + 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, + }, + [], + [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); + }); + }); }); diff --git a/src/WebTelemetryBase.ts b/src/WebTelemetryBase.ts index 7e196a6..29ff32f 100644 --- a/src/WebTelemetryBase.ts +++ b/src/WebTelemetryBase.ts @@ -28,6 +28,37 @@ export abstract class WebTelemetryBase { private timer: number | undefined; + /** Страница в 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 конфигурация @@ -55,6 +86,20 @@ export abstract class WebTelemetryBase { ? [new WebTelemetryTransportDebug()] : [new WebTelemetryTransportDefault(`${this.config.endpoint}/${this.config.projectName}`)]; } + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', this.onVisibilityChange); + document.addEventListener('freeze', this.onFreeze); + document.addEventListener('resume', this.onResume); + } + } + + private canSendTelemetry(): boolean { + if (typeof document === 'undefined') { + return true; + } + + return document.visibilityState === 'visible' && !this.documentFrozen; } /** @@ -68,6 +113,10 @@ export abstract class WebTelemetryBase { return; } + if (!this.canSendTelemetry()) { + return; + } + const body = JSON.stringify(data); this.transports.forEach((t) => { t.send(body); @@ -84,11 +133,19 @@ export abstract class WebTelemetryBase { 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); From bc3dd6469ba5c6d4307cb7024891fa25623d25fa Mon Sep 17 00:00:00 2001 From: Pamellix Date: Mon, 4 May 2026 12:49:26 +0300 Subject: [PATCH 2/3] feat: add queue management and page visibility handling to telemetry --- src/WebTelemetryBase.spec.ts | 95 ++++++++++++++++++++++++++++++++++-- src/WebTelemetryBase.ts | 34 ++++++++++--- src/config.ts | 3 ++ src/types.ts | 17 +++++++ 4 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/WebTelemetryBase.spec.ts b/src/WebTelemetryBase.spec.ts index 6a237b2..cdf9635 100644 --- a/src/WebTelemetryBase.spec.ts +++ b/src/WebTelemetryBase.spec.ts @@ -1,6 +1,6 @@ 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 }> { @@ -23,7 +23,7 @@ class Addon2 implements WebTelemetryAddon<{ addon2Data: string }, { addon2Metada } } -class WebTelemetry extends WebTelemetryBase { +class WebTelemetry extends WebTelemetryBase { protected payloadToJSON(payload: T): T { return payload; } @@ -32,12 +32,12 @@ class WebTelemetry extends WebTelemetryBase { // do nothing } - public getEvents() { - return this.events; + public getEvents(): Array { + return this.events as Array; } } -class WebTelemetryBatch extends WebTelemetryBase { +class WebTelemetryBatch extends WebTelemetryBase { protected payloadToJSON(payload: T): T { return payload; } @@ -88,12 +88,74 @@ describe('WebTelemetryBase', () => { }); }); + 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 }; @@ -104,6 +166,7 @@ describe('WebTelemetryBase', () => { debug: false, delay: 50, buffSize: 1, + pauseSendingWhenPageInactive: true, }, [], [transport], @@ -120,6 +183,27 @@ describe('WebTelemetryBase', () => { 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(); @@ -132,6 +216,7 @@ describe('WebTelemetryBase', () => { debug: false, delay: 100, buffSize: 100, + pauseSendingWhenPageInactive: true, }, [], [transport], diff --git a/src/WebTelemetryBase.ts b/src/WebTelemetryBase.ts index 29ff32f..6d8a8c5 100644 --- a/src/WebTelemetryBase.ts +++ b/src/WebTelemetryBase.ts @@ -28,6 +28,8 @@ export abstract class WebTelemetryBase { private timer: number | undefined; + public droppedEventsCount = 0; + /** Страница в lifecycle-состоянии frozen (Page Lifecycle API). */ private documentFrozen = false; @@ -87,7 +89,7 @@ export abstract class WebTelemetryBase { : [new WebTelemetryTransportDefault(`${this.config.endpoint}/${this.config.projectName}`)]; } - if (typeof document !== 'undefined') { + if (this.config.pauseSendingWhenPageInactive && typeof document !== 'undefined') { document.addEventListener('visibilitychange', this.onVisibilityChange); document.addEventListener('freeze', this.onFreeze); document.addEventListener('resume', this.onResume); @@ -95,13 +97,35 @@ export abstract class WebTelemetryBase { } private canSendTelemetry(): boolean { - if (typeof document === 'undefined') { + if (!this.config.pauseSendingWhenPageInactive || typeof document === 'undefined') { return true; } return document.visibilityState === 'visible' && !this.documentFrozen; } + protected addEventsToQueue(events: Array) { + 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; + } + /** * Метод, который преобразует входной формат данных в формат данных * отдельно взятой таблицы. @@ -113,10 +137,6 @@ export abstract class WebTelemetryBase { return; } - if (!this.canSendTelemetry()) { - return; - } - const body = JSON.stringify(data); this.transports.forEach((t) => { t.send(body); @@ -194,7 +214,7 @@ export abstract class WebTelemetryBase { const evt = this.createEvent(payload, meta); evt.then((data) => { - this.events.push(data); + this.addEventsToQueue([data]); this.scheduleSend(); }).catch((error) => { console.error(error); diff --git a/src/config.ts b/src/config.ts index 84b2b55..864faff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,5 +7,8 @@ export const defaultConfig: WebTelemetryBaseConfig = { debug: false, delay: 2000, buffSize: 25, + pauseSendingWhenPageInactive: false, + maxQueueSize: 1000, + queueOverflowStrategy: 'drop_oldest', frameTime: true, }; diff --git a/src/types.ts b/src/types.ts index e9a843a..cc9ad03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,8 @@ export interface WebTelemetryAddon | M; } +export type QueueOverflowStrategy = 'drop_oldest' | 'drop_newest'; + export type KVDataItem

> = { payload: P; meta?: M; @@ -66,6 +68,21 @@ export interface WebTelemetryBaseConfig { */ disabled: boolean; + /** + * Приостанавливает отправку, когда страница скрыта или заморожена браузером + */ + pauseSendingWhenPageInactive: boolean; + + /** + * Максимальное количество событий, которое может накопиться в очереди + */ + maxQueueSize: number; + + /** + * Политика удаления событий при переполнении очереди + */ + queueOverflowStrategy: QueueOverflowStrategy; + /** * Сбор и отправка frame time */ From c83957ba7d52f6a0b33ec57252a2bc3128ae828b Mon Sep 17 00:00:00 2001 From: Pamellix Date: Wed, 6 May 2026 13:33:05 +0300 Subject: [PATCH 3/3] feat: add support for resource timing observation after page load and implement event flushing mechanism --- src/WebTelemetryBase.ts | 13 ++ src/presets/WebTelemetryResources.spec.ts | 175 +++++++++++++++++++++- src/presets/WebTelemetryResources.ts | 130 +++++++++++++--- src/types.ts | 7 + 4 files changed, 306 insertions(+), 19 deletions(-) diff --git a/src/WebTelemetryBase.ts b/src/WebTelemetryBase.ts index 6d8a8c5..f2c53be 100644 --- a/src/WebTelemetryBase.ts +++ b/src/WebTelemetryBase.ts @@ -147,6 +147,19 @@ export abstract class WebTelemetryBase { this.callTransport([...this.events]); } + /** + * Немедленно отправляет накопленные события и сбрасывает таймер отложенной отправки. + */ + protected flushBufferedEvents(): void { + clearTimeout(this.timer); + this.timer = undefined; + + if (this.events.length > 0) { + this.sendHandler(); + this.events = []; + } + } + /** * Планирует отправку данных на сервер */ diff --git a/src/presets/WebTelemetryResources.spec.ts b/src/presets/WebTelemetryResources.spec.ts index 6be2dae..656b056 100644 --- a/src/presets/WebTelemetryResources.spec.ts +++ b/src/presets/WebTelemetryResources.spec.ts @@ -1,8 +1,179 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { validatePerformanceEntry, VIDEO_URLs } from './WebTelemetryResources.js'; +import { WebTelemetryResources, validatePerformanceEntry, VIDEO_URLs } from './WebTelemetryResources.js'; + +function resetResourcesObserver() { + (WebTelemetryResources as unknown as { observer?: PerformanceObserver }).observer = undefined; +} + +function createMockResourceEntry(overrides: Partial = {}): PerformanceResourceTiming { + const base = { + entryType: 'resource' as const, + name: 'https://cdn.example.com/app.js', + startTime: 10, + duration: 5, + ...overrides, + }; + + return { + ...base, + toJSON() { + return { ...base }; + }, + } as PerformanceResourceTiming; +} describe('presets', () => { + beforeEach(() => { + resetResourcesObserver(); + }); + + afterEach(() => { + resetResourcesObserver(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('WebTelemetryResources', () => { + it('stops observation on load by default, drains takeRecords and flushes', async () => { + const instances: Array<{ takeRecords: ReturnType; disconnect: ReturnType }> = []; + let takeCalls = 0; + + class MockPerformanceObserver { + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => { + takeCalls += 1; + return takeCalls === 1 ? [createMockResourceEntry()] : []; + }); + + cb: PerformanceObserverCallback; + + constructor(cb: PerformanceObserverCallback) { + this.cb = cb; + instances.push(this); + } + } + + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); + + const send = vi.fn(); + const resources = new WebTelemetryResources( + 'proj', + { + projectName: 'proj-resources', + endpoint: '', + buffSize: 100, + delay: 10_000, + }, + [{ send }], + ); + + Object.defineProperty(document, 'readyState', { configurable: true, value: 'loading' }); + resources.start(); + + window.dispatchEvent(new Event('load')); + + await vi.waitFor(() => { + expect(send).toHaveBeenCalled(); + }); + + expect(instances[0].takeRecords).toHaveBeenCalled(); + expect(instances[0].disconnect).toHaveBeenCalled(); + + const body = send.mock.calls.at(-1)?.[0] as string; + expect(body).toContain('cdn.example.com'); + }); + + it('keeps observation after load when observeResourcesAfterLoad is true', async () => { + const instances: Array<{ disconnect: ReturnType }> = []; + + class MockPerformanceObserver { + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + + cb: PerformanceObserverCallback; + + constructor(cb: PerformanceObserverCallback) { + this.cb = cb; + instances.push(this); + } + } + + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); + + const resources = new WebTelemetryResources( + 'proj', + { + projectName: 'proj-resources', + observeResourcesAfterLoad: true, + }, + [], + ); + + Object.defineProperty(document, 'readyState', { configurable: true, value: 'loading' }); + resources.start(); + + window.dispatchEvent(new Event('load')); + + await new Promise((r) => setTimeout(r, 15)); + + expect(instances[0].disconnect).not.toHaveBeenCalled(); + + await resources.end(); + + expect(instances[0].disconnect).toHaveBeenCalled(); + }); + + it('end() processes takeRecords queue before disconnect', async () => { + const queued: PerformanceEntry[] = [createMockResourceEntry({ name: 'https://queued.example/img.png' })]; + const instances: Array<{ takeRecords: ReturnType; disconnect: ReturnType }> = []; + + class MockPerformanceObserver { + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => { + const out = [...queued]; + queued.length = 0; + return out; + }); + + cb: PerformanceObserverCallback; + + constructor(cb: PerformanceObserverCallback) { + this.cb = cb; + instances.push(this); + } + } + + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); + + const send = vi.fn(); + const resources = new WebTelemetryResources( + 'proj', + { + projectName: 'proj-resources', + endpoint: '', + buffSize: 50, + delay: 10_000, + observeResourcesAfterLoad: true, + }, + [{ send }], + ); + + Object.defineProperty(document, 'readyState', { configurable: true, value: 'complete' }); + resources.start(); + + await resources.end(); + + expect(instances[0].takeRecords).toHaveBeenCalled(); + expect(send).toHaveBeenCalled(); + const body = send.mock.calls.at(-1)?.[0] as string; + expect(body).toContain('queued.example'); + }); + }); + describe('WebTelemetryResources:validatePerformanceEntry', () => { const firstBlackList = [ /api\.amplitude\.com/, diff --git a/src/presets/WebTelemetryResources.ts b/src/presets/WebTelemetryResources.ts index 2cf724d..8eed2e7 100644 --- a/src/presets/WebTelemetryResources.ts +++ b/src/presets/WebTelemetryResources.ts @@ -1,4 +1,4 @@ -import type { WebTelemetryResourcesConfig, WebTelemetryTransport } from '../types.js'; +import type { WebTelemetryBaseEvent, WebTelemetryResourcesConfig, WebTelemetryTransport } from '../types.js'; import { WebTelemetryBase } from '../WebTelemetryBase.js'; const FIELDS_TO_EXTRACT = [ @@ -59,6 +59,19 @@ export class WebTelemetryResources extends WebTelemetryBase | null = null; + + private readonly boundLoadFinalizer = () => { + void this.finalizeShutdown(); + }; + + private readonly onPerformanceObserver = (entryList: PerformanceObserverEntryList) => { + void this.processResourceEntries(entryList.getEntries()).catch((error) => { + console.error(error); + }); + }; constructor(name: string, config: WebTelemetryResourcesConfig, transports?: Array) { super(config, [], transports); @@ -68,20 +81,27 @@ export class WebTelemetryResources extends WebTelemetryBase { if (this.config.disabled) { return; } - const resources = entryList.getEntriesByType('resource').filter(this.validatePerformanceEntry); + const resources = entries + .filter((e): e is PerformanceResourceTiming => e.entryType === 'resource') + .filter(this.validatePerformanceEntry); + + const pendingPushes: Array> = []; + for (const res of resources) { /** * Эта проверка необходима чтобы `PerformanceObserver` не тригерился @@ -92,33 +112,111 @@ export class WebTelemetryResources extends WebTelemetryBase) + : (res as unknown as Record); const evt: WebTelemetryResourcesData = { - hostname: window.location.hostname, + hostname: typeof window !== 'undefined' ? window.location.hostname : '', project: this.name, - path: window.location.href, + path: typeof window !== 'undefined' ? 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; + evt[entryName] = typeof value === 'number' ? Math.round(value) : (value as string); } } - this.push(evt); + pendingPushes.push(this.push(evt)); } + + await Promise.all(pendingPushes); } payloadToJSON(payload: WebTelemetryResourcesData) { return payload; } + /** + * После `load` останавливаем наблюдение за ресурсами, если не запрошено продолжение. + */ + private finalizeAfterDocumentReady(): void { + if (this.observeResourcesAfterLoad || typeof window === 'undefined') { + return; + } + + if (document.readyState === 'complete') { + window.setTimeout(() => void this.finalizeShutdown(), 0); + } else { + window.addEventListener('load', this.boundLoadFinalizer, { once: true }); + } + } + + private async finalizeShutdown(): Promise { + if (this.sharedShutdownPromise) { + await this.sharedShutdownPromise; + this.flushBufferedEvents(); + return; + } + + this.sharedShutdownPromise = (async () => { + try { + await this.shutdownObservation(); + } catch (error) { + console.error(error); + } + })(); + + try { + await this.sharedShutdownPromise; + } finally { + this.sharedShutdownPromise = null; + } + } + + /** + * Единый путь остановки: снять слушатель `load`, дочитать очередь observer, + * обработать записи, сбросить буфер событий, отключить observer. + */ + private async shutdownObservation(): Promise { + if (this.hasShutDown) { + this.flushBufferedEvents(); + return; + } + + if (typeof window !== 'undefined') { + window.removeEventListener('load', this.boundLoadFinalizer); + } + + const obs = WebTelemetryResources.observer; + + if (obs && this.isObservationStarted) { + let batch: PerformanceEntry[]; + do { + batch = obs.takeRecords(); + if (batch.length > 0) { + await this.processResourceEntries(batch); + } + } while (batch.length > 0); + + obs.disconnect(); + this.isObservationStarted = false; + } + + this.flushBufferedEvents(); + this.hasShutDown = true; + } + public start() { if (this.isObservationStarted) { return; } + + this.hasShutDown = false; + try { if (WebTelemetryResources.observer) { WebTelemetryResources.observer.observe({ @@ -126,15 +224,13 @@ export class WebTelemetryResources extends WebTelemetryBase { + await this.finalizeShutdown(); } } diff --git a/src/types.ts b/src/types.ts index cc9ad03..6eb6238 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,6 +99,13 @@ type ResourcesBlackList = { * Экономим трафик пользователя и наши ресурсы. */ resourcesBlackList?: RegExp[]; + + /** + * Продолжать наблюдение за resource timing после события `load`. + * По умолчанию `false`: после загрузки документа очередь observer дочитывается, + * буфер отправляется, наблюдение останавливается. + */ + observeResourcesAfterLoad?: boolean; }; export type WebTelemetryResourcesConfig = WebTelemetryConfig & ResourcesBlackList;