From 19b6b5850a58cc88fed30b0da9f7a5ea6bd6b299 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:48:24 +0300 Subject: [PATCH 1/6] feat: add Yandex Metrica message processor and corresponding test --- packages/browser/package.json | 2 +- .../yandex-metrica-addon-message-processor.ts | 83 +++++++++++++++++++ packages/browser/src/catcher.ts | 2 + .../yandex-metrica-message-processor.test.ts | 80 ++++++++++++++++++ packages/browser/tests/catcher.addons.test.ts | 20 +++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 packages/browser/src/addons/yandex-metrica-addon-message-processor.ts create mode 100644 packages/browser/tests/addons/yandex-metrica-message-processor.test.ts diff --git a/packages/browser/package.json b/packages/browser/package.json index ed7b7546..83d1aff8 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/browser", - "version": "3.3.6", + "version": "3.4.0", "description": "JavaScript Browser errors tracking for Hawk.so", "files": [ "dist" diff --git a/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts b/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts new file mode 100644 index 00000000..15ab8ea5 --- /dev/null +++ b/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts @@ -0,0 +1,83 @@ +import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; + +/** + * Addon key used to attach Yandex Metrica identifiers. + */ +export const YANDEX_METRICA_ADDON_KEY = 'yandexMetrica'; + +interface YandexMetricaFunction { + ( + counterId: number, + method: 'getClientID', + callback: (clientId: unknown) => void + ): void; + a?: ArrayLike>; +} + +type WindowWithYandexMetrica = Window & { + ym?: YandexMetricaFunction; +}; + +/** + * Reads Yandex Metrica counter ID, requests ClientID once during initialization, + * and attaches both identifiers to subsequent events. + * + * Important: `window.ym.a[0][0]` relies on the Metrica initialization queue + * and is not a public API contract. This is acceptable for the MVP, but the SDK + * should accept `counterId` explicitly in the future because a page may have + * multiple Yandex Metrica counters. + * + * @see https://yandex.ru/support/metrica/ru/objects/get-client-id + */ +export class YandexMetricaAddonMessageProcessor implements MessageProcessor<'errors/javascript'> { + /** + * Cached Yandex Metrica identifiers. + */ + private identifiers: { + counterId: number; + clientId: string; + } | null = null; + + /** + * Reads the first initialized counter and requests its ClientID. + */ + constructor() { + const ym = (window as WindowWithYandexMetrica).ym; + const counterId = Number(ym?.a?.[0]?.[0]); + + if (typeof ym !== 'function' || !Number.isSafeInteger(counterId) || counterId <= 0) { + return; + } + + try { + ym(counterId, 'getClientID', (clientId) => { + if (typeof clientId === 'string' && clientId.length > 0) { + this.identifiers = { + counterId, + clientId, + }; + } + }); + } catch { + /** + * Yandex Metrica integration must not affect error reporting. + */ + } + } + + /** + * Attaches cached Yandex Metrica identifiers when they are available. + * + * @param payload - event message payload to enrich + * @returns {ProcessingPayload<'errors/javascript'>} enriched or original payload + */ + public apply( + payload: ProcessingPayload<'errors/javascript'> + ): ProcessingPayload<'errors/javascript'> { + if (this.identifiers) { + (payload.addons as Record)[YANDEX_METRICA_ADDON_KEY] = this.identifiers; + } + + return payload; + } +} diff --git a/packages/browser/src/catcher.ts b/packages/browser/src/catcher.ts index 5890adcc..fcf6a3ed 100644 --- a/packages/browser/src/catcher.ts +++ b/packages/browser/src/catcher.ts @@ -16,6 +16,7 @@ import { ConsoleOutputAddonMessageProcessor } from './addons/console-output-addo import { DebugAddonMessageProcessor } from './addons/debug-addon-message-processor'; import { BrowserBreadcrumbsMessageProcessor } from './addons/browser-breadcrumbs-message-processor'; import { PerformanceIssuesMonitor } from './addons/performance-issues'; +import { YandexMetricaAddonMessageProcessor } from './addons/yandex-metrica-addon-message-processor'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -151,6 +152,7 @@ export default class Catcher extends BaseCatcher { } this.addMessageProcessor(new BrowserAddonMessageProcessor()); + this.addMessageProcessor(new YandexMetricaAddonMessageProcessor()); if (this.consoleTracking) { this.consoleCatcher = ConsoleCatcher.getInstance(); diff --git a/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts b/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts new file mode 100644 index 00000000..4b144633 --- /dev/null +++ b/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + YANDEX_METRICA_ADDON_KEY, + YandexMetricaAddonMessageProcessor +} from '../../src/addons/yandex-metrica-addon-message-processor'; +import { makePayload } from './message-processor.helpers'; + +type YandexMetricaMock = ReturnType & { + a?: ArrayLike>; +}; + +function setYandexMetrica(ym?: YandexMetricaMock): void { + Object.defineProperty(window, 'ym', { + configurable: true, + value: ym, + }); +} + +describe('YandexMetricaAddonMessageProcessor', () => { + afterEach(() => { + setYandexMetrica(); + vi.restoreAllMocks(); + }); + + it('should attach counterId and ClientID from Yandex Metrica', () => { + const ym = vi.fn((_counterId, _method, callback) => callback('client-id')) as YandexMetricaMock; + + ym.a = [[456, 'init', {}]]; + setYandexMetrica(ym); + + const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); + + expect(ym).toHaveBeenCalledWith(456, 'getClientID', expect.any(Function)); + expect(result.addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { + counterId: 456, + clientId: 'client-id', + }); + }); + + it('should leave payload unchanged when Yandex Metrica is not installed', () => { + const payload = makePayload(); + const result = new YandexMetricaAddonMessageProcessor().apply(payload); + + expect(result).toBe(payload); + expect(result.addons).toEqual({}); + }); + + it('should leave payload unchanged when counter ID is unavailable', () => { + const ym = vi.fn() as YandexMetricaMock; + + setYandexMetrica(ym); + + const payload = makePayload(); + const result = new YandexMetricaAddonMessageProcessor().apply(payload); + + expect(ym).not.toHaveBeenCalled(); + expect(result.addons).toEqual({}); + }); + + it('should attach identifiers only after getClientID resolves', () => { + let resolveClientId: ((clientId: unknown) => void) | undefined; + const ym = vi.fn((_counterId, _method, callback) => { + resolveClientId = callback; + }) as YandexMetricaMock; + + ym.a = [[456, 'init', {}]]; + setYandexMetrica(ym); + + const processor = new YandexMetricaAddonMessageProcessor(); + + expect(processor.apply(makePayload()).addons).toEqual({}); + + resolveClientId?.('client-id'); + + expect(processor.apply(makePayload()).addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { + counterId: 456, + clientId: 'client-id', + }); + }); +}); diff --git a/packages/browser/tests/catcher.addons.test.ts b/packages/browser/tests/catcher.addons.test.ts index 42c23591..908ec0e1 100644 --- a/packages/browser/tests/catcher.addons.test.ts +++ b/packages/browser/tests/catcher.addons.test.ts @@ -58,6 +58,26 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined(); }); + + it('should include Yandex Metrica counterId and ClientID', async () => { + const ym = vi.fn((_counterId, _method, callback) => callback('client-id')); + + Object.assign(ym, { a: [[123, 'init', {}]] }); + vi.stubGlobal('ym', ym); + + const { sendSpy, transport } = createTransport(); + + createCatcher(transport).send(new Error('e')); + await wait(); + + expect(getLastPayload(sendSpy).addons.yandexMetrica).toEqual({ + counterId: 123, + clientId: 'client-id', + }); + expect(ym).toHaveBeenCalledWith(123, 'getClientID', expect.any(Function)); + + vi.unstubAllGlobals(); + }); }); // ── Integration addons ──────────────────────────────────────────────────── From 856a29f3c872a92602d52b961e9d01eb10624925 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:36:30 +0300 Subject: [PATCH 2/6] webvisor --- .../yandex-metrica-addon-message-processor.ts | 7 +++-- .../yandex-metrica-message-processor.test.ts | 30 +++++++++++++++++-- packages/browser/tests/catcher.addons.test.ts | 2 +- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts b/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts index 15ab8ea5..be41626d 100644 --- a/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts +++ b/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts @@ -11,7 +11,9 @@ interface YandexMetricaFunction { method: 'getClientID', callback: (clientId: unknown) => void ): void; - a?: ArrayLike>; + a?: ArrayLike>; } type WindowWithYandexMetrica = Window & { @@ -44,8 +46,9 @@ export class YandexMetricaAddonMessageProcessor implements MessageProcessor<'err constructor() { const ym = (window as WindowWithYandexMetrica).ym; const counterId = Number(ym?.a?.[0]?.[0]); + const isWebvisorEnabled = ym?.a?.[0]?.[2]?.webvisor; - if (typeof ym !== 'function' || !Number.isSafeInteger(counterId) || counterId <= 0) { + if (typeof ym !== 'function' || !Number.isSafeInteger(counterId) || counterId <= 0 || !isWebvisorEnabled) { return; } diff --git a/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts b/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts index 4b144633..3f9ee401 100644 --- a/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts +++ b/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts @@ -25,7 +25,7 @@ describe('YandexMetricaAddonMessageProcessor', () => { it('should attach counterId and ClientID from Yandex Metrica', () => { const ym = vi.fn((_counterId, _method, callback) => callback('client-id')) as YandexMetricaMock; - ym.a = [[456, 'init', {}]]; + ym.a = [[456, 'init', { webvisor: true }]]; setYandexMetrica(ym); const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); @@ -57,13 +57,39 @@ describe('YandexMetricaAddonMessageProcessor', () => { expect(result.addons).toEqual({}); }); + it('should leave payload unchanged when webvisor is disabled', () => { + const ym = vi.fn() as YandexMetricaMock; + + ym.a = [[456, 'init', { webvisor: false }]]; + setYandexMetrica(ym); + + const payload = makePayload(); + const result = new YandexMetricaAddonMessageProcessor().apply(payload); + + expect(ym).not.toHaveBeenCalled(); + expect(result.addons).toEqual({}); + }); + + it('should leave payload unchanged when webvisor option is missing', () => { + const ym = vi.fn() as YandexMetricaMock; + + ym.a = [[456, 'init', {}]]; + setYandexMetrica(ym); + + const payload = makePayload(); + const result = new YandexMetricaAddonMessageProcessor().apply(payload); + + expect(ym).not.toHaveBeenCalled(); + expect(result.addons).toEqual({}); + }); + it('should attach identifiers only after getClientID resolves', () => { let resolveClientId: ((clientId: unknown) => void) | undefined; const ym = vi.fn((_counterId, _method, callback) => { resolveClientId = callback; }) as YandexMetricaMock; - ym.a = [[456, 'init', {}]]; + ym.a = [[456, 'init', { webvisor: true }]]; setYandexMetrica(ym); const processor = new YandexMetricaAddonMessageProcessor(); diff --git a/packages/browser/tests/catcher.addons.test.ts b/packages/browser/tests/catcher.addons.test.ts index 908ec0e1..0222f24d 100644 --- a/packages/browser/tests/catcher.addons.test.ts +++ b/packages/browser/tests/catcher.addons.test.ts @@ -62,7 +62,7 @@ describe('Catcher', () => { it('should include Yandex Metrica counterId and ClientID', async () => { const ym = vi.fn((_counterId, _method, callback) => callback('client-id')); - Object.assign(ym, { a: [[123, 'init', {}]] }); + Object.assign(ym, { a: [[123, 'init', { webvisor: true }]] }); vi.stubGlobal('ym', ym); const { sendSpy, transport } = createTransport(); From 69061d251de0197113ce00da13e482779dd66ef7 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:12:02 +0300 Subject: [PATCH 3/6] feat: enhance Yandex Metrica message processor to support multiple counters and preserve queue positions --- .../yandex-metrica-addon-message-processor.ts | 79 +++++++++++-------- .../yandex-metrica-message-processor.test.ts | 66 ++++++++++++++-- packages/browser/tests/catcher.addons.test.ts | 24 ++++-- 3 files changed, 124 insertions(+), 45 deletions(-) diff --git a/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts b/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts index be41626d..0c4e8d3a 100644 --- a/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts +++ b/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts @@ -5,66 +5,79 @@ import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; */ export const YANDEX_METRICA_ADDON_KEY = 'yandexMetrica'; +const MAX_YANDEX_METRICA_COUNTERS = 10; + interface YandexMetricaFunction { ( counterId: number, method: 'getClientID', callback: (clientId: unknown) => void ): void; - a?: ArrayLike>; + a?: ArrayLike>; } type WindowWithYandexMetrica = Window & { ym?: YandexMetricaFunction; }; +interface YandexMetricaIdentifiers { + counterId: number; + clientId: string; +} + /** - * Reads Yandex Metrica counter ID, requests ClientID once during initialization, - * and attaches both identifiers to subsequent events. + * Reads up to ten Yandex Metrica counter IDs, requests their ClientIDs once + * during initialization, and attaches available identifiers to subsequent events. * - * Important: `window.ym.a[0][0]` relies on the Metrica initialization queue + * Important: `window.ym.a[index][0]` relies on the Metrica initialization queue * and is not a public API contract. This is acceptable for the MVP, but the SDK - * should accept `counterId` explicitly in the future because a page may have - * multiple Yandex Metrica counters. + * should accept counter IDs explicitly in the future. * * @see https://yandex.ru/support/metrica/ru/objects/get-client-id */ export class YandexMetricaAddonMessageProcessor implements MessageProcessor<'errors/javascript'> { /** - * Cached Yandex Metrica identifiers. + * Cached Yandex Metrica identifiers keyed by their one-based queue position. */ - private identifiers: { - counterId: number; - clientId: string; - } | null = null; + private identifiers: Record = {}; /** - * Reads the first initialized counter and requests its ClientID. + * Reads up to ten initialized counters and requests their ClientIDs. */ constructor() { const ym = (window as WindowWithYandexMetrica).ym; - const counterId = Number(ym?.a?.[0]?.[0]); - const isWebvisorEnabled = ym?.a?.[0]?.[2]?.webvisor; - if (typeof ym !== 'function' || !Number.isSafeInteger(counterId) || counterId <= 0 || !isWebvisorEnabled) { + if (typeof ym !== 'function') { return; } - try { - ym(counterId, 'getClientID', (clientId) => { - if (typeof clientId === 'string' && clientId.length > 0) { - this.identifiers = { - counterId, - clientId, - }; - } - }); - } catch { - /** - * Yandex Metrica integration must not affect error reporting. - */ + for (let queueIndex = 0; queueIndex < MAX_YANDEX_METRICA_COUNTERS; queueIndex++) { + const queueEntry = ym.a?.[queueIndex]; + const rawCounterId = queueEntry?.[0]; + const counterId = typeof rawCounterId === 'number' || typeof rawCounterId === 'string' + ? Number(rawCounterId) + : NaN; + const options = queueEntry?.[2] as { webvisor?: unknown } | undefined; + const isWebvisorEnabled = options?.webvisor === true; + + if (!Number.isSafeInteger(counterId) || counterId <= 0 || !isWebvisorEnabled) { + continue; + } + + try { + ym(counterId, 'getClientID', (clientId) => { + if (typeof clientId === 'string' && clientId.length > 0) { + this.identifiers[queueIndex + 1] = { + counterId, + clientId, + }; + } + }); + } catch { + /** + * Yandex Metrica integration must not affect error reporting. + */ + } } } @@ -77,8 +90,10 @@ export class YandexMetricaAddonMessageProcessor implements MessageProcessor<'err public apply( payload: ProcessingPayload<'errors/javascript'> ): ProcessingPayload<'errors/javascript'> { - if (this.identifiers) { - (payload.addons as Record)[YANDEX_METRICA_ADDON_KEY] = this.identifiers; + if (Object.keys(this.identifiers).length > 0) { + (payload.addons as Record)[YANDEX_METRICA_ADDON_KEY] = { + ...this.identifiers, + }; } return payload; diff --git a/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts b/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts index 3f9ee401..7d379b77 100644 --- a/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts +++ b/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts @@ -22,18 +22,28 @@ describe('YandexMetricaAddonMessageProcessor', () => { vi.restoreAllMocks(); }); - it('should attach counterId and ClientID from Yandex Metrica', () => { - const ym = vi.fn((_counterId, _method, callback) => callback('client-id')) as YandexMetricaMock; + it('should attach counterId and ClientID for multiple Yandex Metrica counters', () => { + const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)) as YandexMetricaMock; - ym.a = [[456, 'init', { webvisor: true }]]; + ym.a = [ + [456, 'init', { webvisor: true }], + [789, 'init', { webvisor: true }], + ]; setYandexMetrica(ym); const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); expect(ym).toHaveBeenCalledWith(456, 'getClientID', expect.any(Function)); + expect(ym).toHaveBeenCalledWith(789, 'getClientID', expect.any(Function)); expect(result.addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { - counterId: 456, - clientId: 'client-id', + ['1']: { + counterId: 456, + clientId: 'client-456', + }, + ['2']: { + counterId: 789, + clientId: 'client-789', + }, }); }); @@ -83,6 +93,46 @@ describe('YandexMetricaAddonMessageProcessor', () => { expect(result.addons).toEqual({}); }); + it('should preserve the queue position when an earlier counter is invalid', () => { + const ym = vi.fn((_counterId, _method, callback) => callback('client-id')) as YandexMetricaMock; + + ym.a = [ + [456, 'init', { webvisor: false }], + [789, 'init', { webvisor: true }], + ]; + setYandexMetrica(ym); + + const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); + + expect(result.addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { + ['2']: { + counterId: 789, + clientId: 'client-id', + }, + }); + }); + + it('should process no more than ten Yandex Metrica counters', () => { + const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)) as YandexMetricaMock; + + ym.a = Array.from({ length: 11 }, (_, index) => [ + 100 + index, + 'init', + { webvisor: true }, + ]); + setYandexMetrica(ym); + + const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); + const identifiers = result.addons[YANDEX_METRICA_ADDON_KEY] as Record; + + expect(ym).toHaveBeenCalledTimes(10); + expect(identifiers).toHaveProperty('10', { + counterId: 109, + clientId: 'client-109', + }); + expect(identifiers).not.toHaveProperty('11'); + }); + it('should attach identifiers only after getClientID resolves', () => { let resolveClientId: ((clientId: unknown) => void) | undefined; const ym = vi.fn((_counterId, _method, callback) => { @@ -99,8 +149,10 @@ describe('YandexMetricaAddonMessageProcessor', () => { resolveClientId?.('client-id'); expect(processor.apply(makePayload()).addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { - counterId: 456, - clientId: 'client-id', + ['1']: { + counterId: 456, + clientId: 'client-id', + }, }); }); }); diff --git a/packages/browser/tests/catcher.addons.test.ts b/packages/browser/tests/catcher.addons.test.ts index 0222f24d..d6a4cfd3 100644 --- a/packages/browser/tests/catcher.addons.test.ts +++ b/packages/browser/tests/catcher.addons.test.ts @@ -59,10 +59,15 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined(); }); - it('should include Yandex Metrica counterId and ClientID', async () => { - const ym = vi.fn((_counterId, _method, callback) => callback('client-id')); - - Object.assign(ym, { a: [[123, 'init', { webvisor: true }]] }); + it('should include Yandex Metrica counterIds and ClientIDs', async () => { + const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)); + + Object.assign(ym, { + a: [ + [123, 'init', { webvisor: true }], + [456, 'init', { webvisor: true }], + ], + }); vi.stubGlobal('ym', ym); const { sendSpy, transport } = createTransport(); @@ -71,10 +76,17 @@ describe('Catcher', () => { await wait(); expect(getLastPayload(sendSpy).addons.yandexMetrica).toEqual({ - counterId: 123, - clientId: 'client-id', + ['1']: { + counterId: 123, + clientId: 'client-123', + }, + ['2']: { + counterId: 456, + clientId: 'client-456', + }, }); expect(ym).toHaveBeenCalledWith(123, 'getClientID', expect.any(Function)); + expect(ym).toHaveBeenCalledWith(456, 'getClientID', expect.any(Function)); vi.unstubAllGlobals(); }); From 2484bef1c9e1c01a023ae60b69b72a9006a0e4cb Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:22:23 +0300 Subject: [PATCH 4/6] refactor: rename Yandex Metrica addon and update references in Catcher and tests --- .../yandex-metrica-addon-message-processor.ts | 101 --------- .../yandex-metrika-addon-message-processor.ts | 208 ++++++++++++++++++ packages/browser/src/catcher.ts | 4 +- ... yandex-metrika-message-processor.test.ts} | 78 +++---- packages/browser/tests/catcher.addons.test.ts | 18 +- 5 files changed, 262 insertions(+), 147 deletions(-) delete mode 100644 packages/browser/src/addons/yandex-metrica-addon-message-processor.ts create mode 100644 packages/browser/src/addons/yandex-metrika-addon-message-processor.ts rename packages/browser/tests/addons/{yandex-metrica-message-processor.test.ts => yandex-metrika-message-processor.test.ts} (60%) diff --git a/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts b/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts deleted file mode 100644 index 0c4e8d3a..00000000 --- a/packages/browser/src/addons/yandex-metrica-addon-message-processor.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; - -/** - * Addon key used to attach Yandex Metrica identifiers. - */ -export const YANDEX_METRICA_ADDON_KEY = 'yandexMetrica'; - -const MAX_YANDEX_METRICA_COUNTERS = 10; - -interface YandexMetricaFunction { - ( - counterId: number, - method: 'getClientID', - callback: (clientId: unknown) => void - ): void; - a?: ArrayLike>; -} - -type WindowWithYandexMetrica = Window & { - ym?: YandexMetricaFunction; -}; - -interface YandexMetricaIdentifiers { - counterId: number; - clientId: string; -} - -/** - * Reads up to ten Yandex Metrica counter IDs, requests their ClientIDs once - * during initialization, and attaches available identifiers to subsequent events. - * - * Important: `window.ym.a[index][0]` relies on the Metrica initialization queue - * and is not a public API contract. This is acceptable for the MVP, but the SDK - * should accept counter IDs explicitly in the future. - * - * @see https://yandex.ru/support/metrica/ru/objects/get-client-id - */ -export class YandexMetricaAddonMessageProcessor implements MessageProcessor<'errors/javascript'> { - /** - * Cached Yandex Metrica identifiers keyed by their one-based queue position. - */ - private identifiers: Record = {}; - - /** - * Reads up to ten initialized counters and requests their ClientIDs. - */ - constructor() { - const ym = (window as WindowWithYandexMetrica).ym; - - if (typeof ym !== 'function') { - return; - } - - for (let queueIndex = 0; queueIndex < MAX_YANDEX_METRICA_COUNTERS; queueIndex++) { - const queueEntry = ym.a?.[queueIndex]; - const rawCounterId = queueEntry?.[0]; - const counterId = typeof rawCounterId === 'number' || typeof rawCounterId === 'string' - ? Number(rawCounterId) - : NaN; - const options = queueEntry?.[2] as { webvisor?: unknown } | undefined; - const isWebvisorEnabled = options?.webvisor === true; - - if (!Number.isSafeInteger(counterId) || counterId <= 0 || !isWebvisorEnabled) { - continue; - } - - try { - ym(counterId, 'getClientID', (clientId) => { - if (typeof clientId === 'string' && clientId.length > 0) { - this.identifiers[queueIndex + 1] = { - counterId, - clientId, - }; - } - }); - } catch { - /** - * Yandex Metrica integration must not affect error reporting. - */ - } - } - } - - /** - * Attaches cached Yandex Metrica identifiers when they are available. - * - * @param payload - event message payload to enrich - * @returns {ProcessingPayload<'errors/javascript'>} enriched or original payload - */ - public apply( - payload: ProcessingPayload<'errors/javascript'> - ): ProcessingPayload<'errors/javascript'> { - if (Object.keys(this.identifiers).length > 0) { - (payload.addons as Record)[YANDEX_METRICA_ADDON_KEY] = { - ...this.identifiers, - }; - } - - return payload; - } -} diff --git a/packages/browser/src/addons/yandex-metrika-addon-message-processor.ts b/packages/browser/src/addons/yandex-metrika-addon-message-processor.ts new file mode 100644 index 00000000..eef205ff --- /dev/null +++ b/packages/browser/src/addons/yandex-metrika-addon-message-processor.ts @@ -0,0 +1,208 @@ +import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; + +/** + * Addon key used to attach Yandex Metrika counters ClientIDs. + */ +export const YANDEX_METRIKA_ADDON_KEY = 'yandexMetrika'; + +/** + * Maximum number of Yandex Metrika initialization queue entries to inspect. + */ +const MAX_YANDEX_METRIKA_COUNTERS = 10; + +/** + * Yandex Metrika global function used to call public Metrika methods. + */ +interface YandexMetrikaFunction { + ( + counterId: number, + method: 'getClientID', + callback: (clientId: unknown) => void + ): void; + a?: ArrayLike>; +} + +/** + * Browser window with optional Yandex Metrika global function. + */ +type WindowWithYandexMetrika = Window & { + ym?: YandexMetrikaFunction; +}; + +/** + * Yandex Metrika counter ID paired with its visitor ClientID. + */ +interface YandexMetrikaCounterClientId { + /** + * Yandex Metrika counter ID. + */ + counterId: number; + + /** + * Yandex Metrika visitor ClientID. + */ + clientId: string; +} + +/** + * Reads up to ten Yandex Metrika counter IDs, requests their ClientIDs once + * during initialization, and attaches available counters ClientIDs to subsequent events. + * + * Important: `window.ym.a[index][0]` relies on the Metrika initialization queue + * and is not a public API contract. This is acceptable for the MVP, but the SDK + * should accept counter IDs explicitly in the future. + * + * @see https://yandex.ru/support/metrica/ru/objects/get-client-id + */ +export class YandexMetrikaAddonMessageProcessor implements MessageProcessor<'errors/javascript'> { + /** + * Cached Yandex Metrika counters ClientIDs keyed by their one-based queue position. + */ + private countersClientIds: Record = {}; + + /** + * Reads up to ten initialized counters and requests their ClientIDs. + */ + constructor() { + const ym = this.getYandexMetrika(); + + if (!ym) { + return; + } + + this.collectCountersClientIds(ym); + } + + /** + * Attaches cached Yandex Metrika counters ClientIDs when they are available. + * + * @param payload - event message payload to enrich + * @returns {ProcessingPayload<'errors/javascript'>} enriched or original payload + */ + public apply( + payload: ProcessingPayload<'errors/javascript'> + ): ProcessingPayload<'errors/javascript'> { + if (Object.keys(this.countersClientIds).length > 0) { + (payload.addons as Record)[YANDEX_METRIKA_ADDON_KEY] = { + ...this.countersClientIds, + }; + } + + return payload; + } + + /** + * Returns Yandex Metrika global function when it is installed on the page. + */ + private getYandexMetrika(): YandexMetrikaFunction | undefined { + const ym = (window as WindowWithYandexMetrika).ym; + + return typeof ym === 'function' ? ym : undefined; + } + + /** + * Reads initialized Yandex Metrika counters from the queue and requests ClientIDs. + * + * @param ym - Yandex Metrika global function. + */ + private collectCountersClientIds(ym: YandexMetrikaFunction): void { + for (let queueIndex = 0; queueIndex < MAX_YANDEX_METRIKA_COUNTERS; queueIndex++) { + this.collectCounterClientId(ym, queueIndex); + } + } + + /** + * Requests ClientID for one valid Yandex Metrika counter queue entry. + * + * @param ym - Yandex Metrika global function. + * @param queueIndex - Zero-based Metrika initialization queue entry index. + */ + private collectCounterClientId(ym: YandexMetrikaFunction, queueIndex: number): void { + const counterId = this.getCounterIdFromQueue(ym, queueIndex); + + if (counterId === undefined) { + return; + } + + this.requestClientId(ym, queueIndex, counterId); + } + + /** + * Returns valid counter ID from the Yandex Metrika initialization queue. + * + * @param ym - Yandex Metrika global function. + * @param queueIndex - Zero-based Metrika initialization queue entry index. + */ + private getCounterIdFromQueue( + ym: YandexMetrikaFunction, + queueIndex: number + ): number | undefined { + const queueEntry = ym.a?.[queueIndex]; + const counterId = this.parseCounterId(queueEntry?.[0]); + const options = queueEntry?.[2] as { webvisor?: unknown } | undefined; + + if (counterId === undefined || options?.webvisor !== true) { + return undefined; + } + + return counterId; + } + + /** + * Converts raw counter ID from the Metrika queue to a positive integer. + * + * @param rawCounterId - Counter ID read from the Metrika queue. + */ + private parseCounterId(rawCounterId: unknown): number | undefined { + const counterId = typeof rawCounterId === 'number' || typeof rawCounterId === 'string' + ? Number(rawCounterId) + : NaN; + + if (!Number.isSafeInteger(counterId) || counterId <= 0) { + return undefined; + } + + return counterId; + } + + /** + * Requests ClientID from Yandex Metrika and caches it when it is available. + * + * @param ym - Yandex Metrika global function. + * @param queueIndex - Zero-based Metrika initialization queue entry index. + * @param counterId - Yandex Metrika counter ID. + */ + private requestClientId( + ym: YandexMetrikaFunction, + queueIndex: number, + counterId: number + ): void { + try { + ym(counterId, 'getClientID', (clientId) => { + this.saveCounterClientId(queueIndex, counterId, clientId); + }); + } catch { + /** + * Yandex Metrika integration must not affect error reporting. + */ + } + } + + /** + * Saves Yandex Metrika counter ID and ClientID under one-based queue position. + * + * @param queueIndex - Zero-based Metrika initialization queue entry index. + * @param counterId - Yandex Metrika counter ID. + * @param clientId - ClientID returned by Yandex Metrika. + */ + private saveCounterClientId(queueIndex: number, counterId: number, clientId: unknown): void { + if (typeof clientId !== 'string' || clientId.length === 0) { + return; + } + + this.countersClientIds[queueIndex + 1] = { + counterId, + clientId, + }; + } +} diff --git a/packages/browser/src/catcher.ts b/packages/browser/src/catcher.ts index fcf6a3ed..4c09af9d 100644 --- a/packages/browser/src/catcher.ts +++ b/packages/browser/src/catcher.ts @@ -16,7 +16,7 @@ import { ConsoleOutputAddonMessageProcessor } from './addons/console-output-addo import { DebugAddonMessageProcessor } from './addons/debug-addon-message-processor'; import { BrowserBreadcrumbsMessageProcessor } from './addons/browser-breadcrumbs-message-processor'; import { PerformanceIssuesMonitor } from './addons/performance-issues'; -import { YandexMetricaAddonMessageProcessor } from './addons/yandex-metrica-addon-message-processor'; +import { YandexMetrikaAddonMessageProcessor } from './addons/yandex-metrika-addon-message-processor'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -152,7 +152,7 @@ export default class Catcher extends BaseCatcher { } this.addMessageProcessor(new BrowserAddonMessageProcessor()); - this.addMessageProcessor(new YandexMetricaAddonMessageProcessor()); + this.addMessageProcessor(new YandexMetrikaAddonMessageProcessor()); if (this.consoleTracking) { this.consoleCatcher = ConsoleCatcher.getInstance(); diff --git a/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts b/packages/browser/tests/addons/yandex-metrika-message-processor.test.ts similarity index 60% rename from packages/browser/tests/addons/yandex-metrica-message-processor.test.ts rename to packages/browser/tests/addons/yandex-metrika-message-processor.test.ts index 7d379b77..9095a081 100644 --- a/packages/browser/tests/addons/yandex-metrica-message-processor.test.ts +++ b/packages/browser/tests/addons/yandex-metrika-message-processor.test.ts @@ -1,41 +1,41 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { - YANDEX_METRICA_ADDON_KEY, - YandexMetricaAddonMessageProcessor -} from '../../src/addons/yandex-metrica-addon-message-processor'; + YANDEX_METRIKA_ADDON_KEY, + YandexMetrikaAddonMessageProcessor +} from '../../src/addons/yandex-metrika-addon-message-processor'; import { makePayload } from './message-processor.helpers'; -type YandexMetricaMock = ReturnType & { +type YandexMetrikaMock = ReturnType & { a?: ArrayLike>; }; -function setYandexMetrica(ym?: YandexMetricaMock): void { +function setYandexMetrika(ym?: YandexMetrikaMock): void { Object.defineProperty(window, 'ym', { configurable: true, value: ym, }); } -describe('YandexMetricaAddonMessageProcessor', () => { +describe('YandexMetrikaAddonMessageProcessor', () => { afterEach(() => { - setYandexMetrica(); + setYandexMetrika(); vi.restoreAllMocks(); }); - it('should attach counterId and ClientID for multiple Yandex Metrica counters', () => { - const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)) as YandexMetricaMock; + it('should attach counterId and ClientID for multiple Yandex Metrika counters', () => { + const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)) as YandexMetrikaMock; ym.a = [ [456, 'init', { webvisor: true }], [789, 'init', { webvisor: true }], ]; - setYandexMetrica(ym); + setYandexMetrika(ym); - const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); + const result = new YandexMetrikaAddonMessageProcessor().apply(makePayload()); expect(ym).toHaveBeenCalledWith(456, 'getClientID', expect.any(Function)); expect(ym).toHaveBeenCalledWith(789, 'getClientID', expect.any(Function)); - expect(result.addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { + expect(result.addons).toHaveProperty(YANDEX_METRIKA_ADDON_KEY, { ['1']: { counterId: 456, clientId: 'client-456', @@ -47,64 +47,64 @@ describe('YandexMetricaAddonMessageProcessor', () => { }); }); - it('should leave payload unchanged when Yandex Metrica is not installed', () => { + it('should leave payload unchanged when Yandex Metrika is not installed', () => { const payload = makePayload(); - const result = new YandexMetricaAddonMessageProcessor().apply(payload); + const result = new YandexMetrikaAddonMessageProcessor().apply(payload); expect(result).toBe(payload); expect(result.addons).toEqual({}); }); it('should leave payload unchanged when counter ID is unavailable', () => { - const ym = vi.fn() as YandexMetricaMock; + const ym = vi.fn() as YandexMetrikaMock; - setYandexMetrica(ym); + setYandexMetrika(ym); const payload = makePayload(); - const result = new YandexMetricaAddonMessageProcessor().apply(payload); + const result = new YandexMetrikaAddonMessageProcessor().apply(payload); expect(ym).not.toHaveBeenCalled(); expect(result.addons).toEqual({}); }); it('should leave payload unchanged when webvisor is disabled', () => { - const ym = vi.fn() as YandexMetricaMock; + const ym = vi.fn() as YandexMetrikaMock; ym.a = [[456, 'init', { webvisor: false }]]; - setYandexMetrica(ym); + setYandexMetrika(ym); const payload = makePayload(); - const result = new YandexMetricaAddonMessageProcessor().apply(payload); + const result = new YandexMetrikaAddonMessageProcessor().apply(payload); expect(ym).not.toHaveBeenCalled(); expect(result.addons).toEqual({}); }); it('should leave payload unchanged when webvisor option is missing', () => { - const ym = vi.fn() as YandexMetricaMock; + const ym = vi.fn() as YandexMetrikaMock; ym.a = [[456, 'init', {}]]; - setYandexMetrica(ym); + setYandexMetrika(ym); const payload = makePayload(); - const result = new YandexMetricaAddonMessageProcessor().apply(payload); + const result = new YandexMetrikaAddonMessageProcessor().apply(payload); expect(ym).not.toHaveBeenCalled(); expect(result.addons).toEqual({}); }); it('should preserve the queue position when an earlier counter is invalid', () => { - const ym = vi.fn((_counterId, _method, callback) => callback('client-id')) as YandexMetricaMock; + const ym = vi.fn((_counterId, _method, callback) => callback('client-id')) as YandexMetrikaMock; ym.a = [ [456, 'init', { webvisor: false }], [789, 'init', { webvisor: true }], ]; - setYandexMetrica(ym); + setYandexMetrika(ym); - const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); + const result = new YandexMetrikaAddonMessageProcessor().apply(makePayload()); - expect(result.addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { + expect(result.addons).toHaveProperty(YANDEX_METRIKA_ADDON_KEY, { ['2']: { counterId: 789, clientId: 'client-id', @@ -112,43 +112,43 @@ describe('YandexMetricaAddonMessageProcessor', () => { }); }); - it('should process no more than ten Yandex Metrica counters', () => { - const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)) as YandexMetricaMock; + it('should process no more than ten Yandex Metrika counters', () => { + const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)) as YandexMetrikaMock; ym.a = Array.from({ length: 11 }, (_, index) => [ 100 + index, 'init', { webvisor: true }, ]); - setYandexMetrica(ym); + setYandexMetrika(ym); - const result = new YandexMetricaAddonMessageProcessor().apply(makePayload()); - const identifiers = result.addons[YANDEX_METRICA_ADDON_KEY] as Record; + const result = new YandexMetrikaAddonMessageProcessor().apply(makePayload()); + const countersClientIds = result.addons[YANDEX_METRIKA_ADDON_KEY] as Record; expect(ym).toHaveBeenCalledTimes(10); - expect(identifiers).toHaveProperty('10', { + expect(countersClientIds).toHaveProperty('10', { counterId: 109, clientId: 'client-109', }); - expect(identifiers).not.toHaveProperty('11'); + expect(countersClientIds).not.toHaveProperty('11'); }); - it('should attach identifiers only after getClientID resolves', () => { + it('should attach counters ClientIDs only after getClientID resolves', () => { let resolveClientId: ((clientId: unknown) => void) | undefined; const ym = vi.fn((_counterId, _method, callback) => { resolveClientId = callback; - }) as YandexMetricaMock; + }) as YandexMetrikaMock; ym.a = [[456, 'init', { webvisor: true }]]; - setYandexMetrica(ym); + setYandexMetrika(ym); - const processor = new YandexMetricaAddonMessageProcessor(); + const processor = new YandexMetrikaAddonMessageProcessor(); expect(processor.apply(makePayload()).addons).toEqual({}); resolveClientId?.('client-id'); - expect(processor.apply(makePayload()).addons).toHaveProperty(YANDEX_METRICA_ADDON_KEY, { + expect(processor.apply(makePayload()).addons).toHaveProperty(YANDEX_METRIKA_ADDON_KEY, { ['1']: { counterId: 456, clientId: 'client-id', diff --git a/packages/browser/tests/catcher.addons.test.ts b/packages/browser/tests/catcher.addons.test.ts index d6a4cfd3..b0b42f9f 100644 --- a/packages/browser/tests/catcher.addons.test.ts +++ b/packages/browser/tests/catcher.addons.test.ts @@ -1,18 +1,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type * as HawkCore from '@hawk.so/core'; import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); vi.mock('@hawk.so/core', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, StackParser: class { parse = mockParse; } }; + const actual = await importOriginal(); + + return { ...actual, StackParser: class { public parse = mockParse; } }; }); describe('Catcher', () => { beforeEach(() => { + const breadcrumbStore = BrowserBreadcrumbStore as typeof BrowserBreadcrumbStore & { + instance?: { + destroy(): void; + }; + }; + localStorage.clear(); mockParse.mockResolvedValue([]); - (BrowserBreadcrumbStore as any).instance?.destroy(); + breadcrumbStore.instance?.destroy(); }); // ── Environment addons ──────────────────────────────────────────────────── @@ -59,7 +67,7 @@ describe('Catcher', () => { expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined(); }); - it('should include Yandex Metrica counterIds and ClientIDs', async () => { + it('should include Yandex Metrika counterIds and ClientIDs', async () => { const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)); Object.assign(ym, { @@ -75,7 +83,7 @@ describe('Catcher', () => { createCatcher(transport).send(new Error('e')); await wait(); - expect(getLastPayload(sendSpy).addons.yandexMetrica).toEqual({ + expect(getLastPayload(sendSpy).addons.yandexMetrika).toEqual({ ['1']: { counterId: 123, clientId: 'client-123', From a208e137da8ecd6769d29bf9ce09d623eec0c8f6 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:29:17 +0300 Subject: [PATCH 5/6] update readme --- packages/browser/README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/browser/README.md b/packages/browser/README.md index 39a80652..fe3ca26d 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -95,7 +95,7 @@ Initialization settings: | `beforeSend` | function(event) => event \| false \| void | optional | Filter data before sending. Return modified event, `false` to drop the event. | | `issues` | IssuesOptions object | optional | Issues config. See [Issues configuration](#issues-configuration). | -Other available [initial settings](types/hawk-initial-settings.d.ts) are described at the type definition. +Other available [initial settings](src/types/hawk-initial-settings.ts) are described at the type definition. ## Manual sending @@ -353,6 +353,22 @@ const hawk = new HawkCatcher({ }); ``` +## Yandex Metrika + +If [Yandex Metrika](https://yandex.ru/support/metrica/) with Webvisor is installed on your site, Hawk automatically attaches visitor ClientIDs to every event. In Hawk Garage you can open the user's Webvisor session from the event details. + +No additional Hawk configuration is required — initialize Hawk as usual and ensure Metrika counters are loaded on the page. + +Hawk inspects up to 10 Metrika counters from the initialization queue and includes only those initialized with `webvisor: true`. For each matched counter, Hawk requests the visitor ClientID via the public [`getClientID`](https://yandex.ru/support/metrica/ru/objects/get-client-id) API and sends it in the event addon: + +Requirements: + +- Yandex Metrika script must be present on the page (`window.ym`) +- Counter must be initialized with Webvisor enabled (`webvisor: true` in init options) + +> [!NOTE] +> Hawk reads counter IDs from Metrika's internal initialization queue, which is not part of the public API. A future SDK version may accept counter IDs explicitly. + ## Source maps consuming If your bundle is minified, it is useful to pass source-map files to the Hawk. After that you will see beautiful From 3ca01d7b99ae899cae29bb0003a80de6c54c3939 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:20:33 +0300 Subject: [PATCH 6/6] chore --- packages/browser/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/browser/README.md b/packages/browser/README.md index fe3ca26d..f608dc4d 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -366,9 +366,6 @@ Requirements: - Yandex Metrika script must be present on the page (`window.ym`) - Counter must be initialized with Webvisor enabled (`webvisor: true` in init options) -> [!NOTE] -> Hawk reads counter IDs from Metrika's internal initialization queue, which is not part of the public API. A future SDK version may accept counter IDs explicitly. - ## Source maps consuming If your bundle is minified, it is useful to pass source-map files to the Hawk. After that you will see beautiful