diff --git a/packages/browser/README.md b/packages/browser/README.md
index 39a8065..c3526e4 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -17,7 +17,7 @@ Former it was named [@hawk.so/javascript](https://www.npmjs.com/package/@hawk.so
- 📊 Web Vitals issues monitoring
-
Vue support
-
React support
-
+-
Yandex Metrika Webvisor linking (visitor ClientID attachment)
## Installation
@@ -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,12 @@ 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.
+
+No additional Hawk configuration is required — initialize Hawk as usual and ensure Metrika counters are loaded on the page.
+
## 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
diff --git a/packages/browser/package.json b/packages/browser/package.json
index ed7b754..83d1aff 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-metrika-addon-message-processor.ts b/packages/browser/src/addons/yandex-metrika-addon-message-processor.ts
new file mode 100644
index 0000000..eef205f
--- /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 5890adc..4c09af9 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 { YandexMetrikaAddonMessageProcessor } from './addons/yandex-metrika-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 YandexMetrikaAddonMessageProcessor());
if (this.consoleTracking) {
this.consoleCatcher = ConsoleCatcher.getInstance();
diff --git a/packages/browser/tests/addons/yandex-metrika-message-processor.test.ts b/packages/browser/tests/addons/yandex-metrika-message-processor.test.ts
new file mode 100644
index 0000000..9095a08
--- /dev/null
+++ b/packages/browser/tests/addons/yandex-metrika-message-processor.test.ts
@@ -0,0 +1,158 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ YANDEX_METRIKA_ADDON_KEY,
+ YandexMetrikaAddonMessageProcessor
+} from '../../src/addons/yandex-metrika-addon-message-processor';
+import { makePayload } from './message-processor.helpers';
+
+type YandexMetrikaMock = ReturnType & {
+ a?: ArrayLike>;
+};
+
+function setYandexMetrika(ym?: YandexMetrikaMock): void {
+ Object.defineProperty(window, 'ym', {
+ configurable: true,
+ value: ym,
+ });
+}
+
+describe('YandexMetrikaAddonMessageProcessor', () => {
+ afterEach(() => {
+ setYandexMetrika();
+ vi.restoreAllMocks();
+ });
+
+ 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 }],
+ ];
+ setYandexMetrika(ym);
+
+ 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_METRIKA_ADDON_KEY, {
+ ['1']: {
+ counterId: 456,
+ clientId: 'client-456',
+ },
+ ['2']: {
+ counterId: 789,
+ clientId: 'client-789',
+ },
+ });
+ });
+
+ it('should leave payload unchanged when Yandex Metrika is not installed', () => {
+ const payload = makePayload();
+ 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 YandexMetrikaMock;
+
+ setYandexMetrika(ym);
+
+ const payload = makePayload();
+ 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 YandexMetrikaMock;
+
+ ym.a = [[456, 'init', { webvisor: false }]];
+ setYandexMetrika(ym);
+
+ const payload = makePayload();
+ 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 YandexMetrikaMock;
+
+ ym.a = [[456, 'init', {}]];
+ setYandexMetrika(ym);
+
+ const payload = makePayload();
+ 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 YandexMetrikaMock;
+
+ ym.a = [
+ [456, 'init', { webvisor: false }],
+ [789, 'init', { webvisor: true }],
+ ];
+ setYandexMetrika(ym);
+
+ const result = new YandexMetrikaAddonMessageProcessor().apply(makePayload());
+
+ expect(result.addons).toHaveProperty(YANDEX_METRIKA_ADDON_KEY, {
+ ['2']: {
+ counterId: 789,
+ clientId: 'client-id',
+ },
+ });
+ });
+
+ 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 },
+ ]);
+ setYandexMetrika(ym);
+
+ const result = new YandexMetrikaAddonMessageProcessor().apply(makePayload());
+ const countersClientIds = result.addons[YANDEX_METRIKA_ADDON_KEY] as Record;
+
+ expect(ym).toHaveBeenCalledTimes(10);
+ expect(countersClientIds).toHaveProperty('10', {
+ counterId: 109,
+ clientId: 'client-109',
+ });
+ expect(countersClientIds).not.toHaveProperty('11');
+ });
+
+ 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 YandexMetrikaMock;
+
+ ym.a = [[456, 'init', { webvisor: true }]];
+ setYandexMetrika(ym);
+
+ const processor = new YandexMetrikaAddonMessageProcessor();
+
+ expect(processor.apply(makePayload()).addons).toEqual({});
+
+ resolveClientId?.('client-id');
+
+ 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 42c2359..b0b42f9 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 ────────────────────────────────────────────────────
@@ -58,6 +66,38 @@ describe('Catcher', () => {
expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined();
});
+
+ it('should include Yandex Metrika 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();
+
+ createCatcher(transport).send(new Error('e'));
+ await wait();
+
+ expect(getLastPayload(sendSpy).addons.yandexMetrika).toEqual({
+ ['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();
+ });
});
// ── Integration addons ────────────────────────────────────────────────────