Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
101 changes: 101 additions & 0 deletions packages/browser/src/addons/yandex-metrica-addon-message-processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core';

/**
* Addon key used to attach Yandex Metrica identifiers.
*/
export const YANDEX_METRICA_ADDON_KEY = 'yandexMetrica';
Comment thread
Dobrunia marked this conversation as resolved.
Outdated

const MAX_YANDEX_METRICA_COUNTERS = 10;

interface YandexMetricaFunction {
Comment thread
Dobrunia marked this conversation as resolved.
Outdated
(
counterId: number,
method: 'getClientID',
callback: (clientId: unknown) => void
): void;
a?: ArrayLike<ArrayLike<unknown>>;
}

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.
Comment thread
Dobrunia marked this conversation as resolved.
Outdated
*/
private identifiers: Record<number, YandexMetricaIdentifiers> = {};

/**
* Reads up to ten initialized counters and requests their ClientIDs.
*/
constructor() {
Comment thread
Dobrunia marked this conversation as resolved.
Outdated
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<string, unknown>)[YANDEX_METRICA_ADDON_KEY] = {
...this.identifiers,
};
}

return payload;
}
}
2 changes: 2 additions & 0 deletions packages/browser/src/catcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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
Expand Down Expand Up @@ -151,6 +152,7 @@
}

this.addMessageProcessor(new BrowserAddonMessageProcessor());
this.addMessageProcessor(new YandexMetricaAddonMessageProcessor());

if (this.consoleTracking) {
this.consoleCatcher = ConsoleCatcher.getInstance();
Expand Down Expand Up @@ -224,7 +226,7 @@
* - global errors handling
* - performance issue detectors (Long Tasks / LoAF)
*
* @param settings

Check warning on line 229 in packages/browser/src/catcher.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "settings" description
*/
private configureIssues(settings: HawkInitialSettings): void {
if (settings.issues === false) {
Expand Down
158 changes: 158 additions & 0 deletions packages/browser/tests/addons/yandex-metrica-message-processor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
YANDEX_METRICA_ADDON_KEY,
YandexMetricaAddonMessageProcessor
} from '../../src/addons/yandex-metrica-addon-message-processor';
Comment thread
Dobrunia marked this conversation as resolved.
Outdated
import { makePayload } from './message-processor.helpers';

type YandexMetricaMock = ReturnType<typeof vi.fn> & {
a?: ArrayLike<ArrayLike<unknown>>;
};

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 for multiple Yandex Metrica counters', () => {
const ym = vi.fn((counterId, _method, callback) => callback(`client-${counterId}`)) as YandexMetricaMock;

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, {
['1']: {
counterId: 456,
clientId: 'client-456',
},
['2']: {
counterId: 789,
clientId: 'client-789',
},
});
});

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 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 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<string, unknown>;

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) => {
resolveClientId = callback;
}) as YandexMetricaMock;

ym.a = [[456, 'init', { webvisor: true }]];
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, {
['1']: {
counterId: 456,
clientId: 'client-id',
},
});
});
});
32 changes: 32 additions & 0 deletions packages/browser/tests/catcher.addons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,38 @@ describe('Catcher', () => {

expect(getLastPayload(sendSpy).addons.RAW_EVENT_DATA).toBeUndefined();
});

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();

createCatcher(transport).send(new Error('e'));
await wait();

expect(getLastPayload(sendSpy).addons.yandexMetrica).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();
});
Comment thread
Dobrunia marked this conversation as resolved.
});

// ── Integration addons ────────────────────────────────────────────────────
Expand Down
Loading