diff --git a/lib/memoize/index.ts b/lib/memoize/index.ts
index a363360c..c4c62f72 100644
--- a/lib/memoize/index.ts
+++ b/lib/memoize/index.ts
@@ -45,7 +45,7 @@ export function memoize(options: MemoizeOptions = {}): MethodDecorator {
max = 50,
ttl = 1000 * 60 * 30,
strategy = 'concat',
- skipCache = []
+ skipCache = [],
} = options;
/* eslint-enable */
diff --git a/workers/archiver/src/index.ts b/workers/archiver/src/index.ts
index 18d39de4..457f36c5 100644
--- a/workers/archiver/src/index.ts
+++ b/workers/archiver/src/index.ts
@@ -86,7 +86,7 @@ export default class ArchiverWorker extends Worker {
const projects = await this.projectCollection.find({}).project({
_id: 1,
- name: 1
+ name: 1,
});
const projectsData: ReportDataByProject[] = [];
@@ -155,11 +155,11 @@ export default class ArchiverWorker extends Worker {
await this.projectCollection.updateOne({
_id: project._id,
},
- {
- $inc: {
- archivedEventsCount: deletedCount,
- },
- });
+ {
+ $inc: {
+ archivedEventsCount: deletedCount,
+ },
+ });
}
/**
@@ -351,7 +351,7 @@ export default class ArchiverWorker extends Worker {
this.logger.info('Report notification response:', {
status: response?.status,
statusText: response?.statusText,
- data: response?.data
+ data: response?.data,
});
}
diff --git a/workers/email/scripts/emailOverview.ts b/workers/email/scripts/emailOverview.ts
index ff36ed82..a108b86b 100644
--- a/workers/email/scripts/emailOverview.ts
+++ b/workers/email/scripts/emailOverview.ts
@@ -156,7 +156,7 @@ class EmailTestServer {
tariffPlanId: '5f47f031ff71510040f433c1',
password: '1as2eadd321a3cDf',
plan: {
- name: 'Корпоративный'
+ name: 'Корпоративный',
},
workspaceName: workspace.name,
};
diff --git a/workers/email/src/templates/emails/event/html.twig b/workers/email/src/templates/emails/event/html.twig
index 35be79c6..535afe3a 100644
--- a/workers/email/src/templates/emails/event/html.twig
+++ b/workers/email/src/templates/emails/event/html.twig
@@ -8,6 +8,7 @@
{% set utmParams = 'utm_source=email&utm_medium=transactional&utm_campaign=event' %}
{% set event = events[0].event %}
+ {% set repetitionId = events[0].repetitionId %}
{% set daysRepeated = events[0].daysRepeated %}
{% set newCount = events[0].newCount %}
{% set usersAffected = events[0].usersAffected %}
@@ -56,7 +57,11 @@
|
- {% set eventURL = host ~ '/project/' ~ project._id ~ '/event/' ~ event._id ~ '?' ~ utmParams %}
+ {% if repetitionId %}
+ {% set eventURL = host ~ '/project/' ~ project._id ~ '/event/' ~ event._id ~ '/' ~ repetitionId ~ '/overview?' ~ utmParams %}
+ {% else %}
+ {% set eventURL = host ~ '/project/' ~ project._id ~ '/event/' ~ event._id ~ '?' ~ utmParams %}
+ {% endif %}
{% include '../../components/button.twig' with {href: eventURL, label: 'Смотреть детали'} %}
|
diff --git a/workers/email/src/templates/emails/event/text.twig b/workers/email/src/templates/emails/event/text.twig
index d12c7f00..f078d7d1 100644
--- a/workers/email/src/templates/emails/event/text.twig
+++ b/workers/email/src/templates/emails/event/text.twig
@@ -1,4 +1,5 @@
{% set event = events[0].event %}
+{% set repetitionId = events[0].repetitionId %}
{% set daysRepeated = events[0].daysRepeated %}
{% set newCount = events[0].newCount %}
{% set usersAffected = events[0].usersAffected %}
@@ -25,7 +26,11 @@
Это событие произошло {{ event.totalCount }} {{ pluralize_ru(event.totalCount, ['раз', 'раза', 'раз']) }} за {{ daysRepeated }} {{ pluralize_ru(daysRepeated, ['день', 'дня', 'дней']) }}.
-Смотреть детали: {{ host }}/project/{{ project._id }}/event/{{ event._id }}?{{ utmParams }}
+{% if repetitionId %}
+Смотреть детали: {{ host }}/project/{{ project._id }}/event/{{ event._id }}/{{ repetitionId }}/overview?{{ utmParams }}
+{% else %}
+Смотреть детали: {{ host }}/project/{{ project._id }}/event/{{ event._id }}/overview?{{ utmParams }}
+{% endif %}
***
diff --git a/workers/grouper/src/index.ts b/workers/grouper/src/index.ts
index 8eef04fc..8d20daf5 100644
--- a/workers/grouper/src/index.ts
+++ b/workers/grouper/src/index.ts
@@ -265,6 +265,7 @@ export default class GrouperWorker extends Worker {
title: task.payload.title,
groupHash: uniqueEventHash,
isNew: isFirstOccurrence,
+ repetitionId: repetitionId ? repetitionId.toString() : null,
},
});
}
diff --git a/workers/grouper/tests/index.test.ts b/workers/grouper/tests/index.test.ts
index 153a7952..ee781e98 100644
--- a/workers/grouper/tests/index.test.ts
+++ b/workers/grouper/tests/index.test.ts
@@ -734,6 +734,7 @@ describe('GrouperWorker', () => {
title: task.payload.title,
groupHash: expect.any(String),
isNew: true,
+ repetitionId: null,
},
});
diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts
index 6b57d1e4..531826e8 100644
--- a/workers/javascript/tests/index.test.ts
+++ b/workers/javascript/tests/index.test.ts
@@ -442,5 +442,4 @@ describe('JavaScript event worker', () => {
await worker.finish();
});
-
});
diff --git a/workers/limiter/tests/dbHelper.test.ts b/workers/limiter/tests/dbHelper.test.ts
index 9b705878..cded599b 100644
--- a/workers/limiter/tests/dbHelper.test.ts
+++ b/workers/limiter/tests/dbHelper.test.ts
@@ -304,7 +304,7 @@ describe('DbHelper', () => {
/**
* Act
*/
- await dbHelper.updateWorkspacesEventsCountAndIsBlocked([updatedWorkspace]);
+ await dbHelper.updateWorkspacesEventsCountAndIsBlocked([ updatedWorkspace ]);
/**
* Assert
diff --git a/workers/notifier/src/index.ts b/workers/notifier/src/index.ts
index a05101c3..d2d17966 100644
--- a/workers/notifier/src/index.ts
+++ b/workers/notifier/src/index.ts
@@ -160,6 +160,7 @@ export default class NotifierWorker extends Worker {
await this.sendToSenderWorker(channelKey, [ {
key: event.groupHash,
count: 1,
+ repetitionId: event.repetitionId,
} ]);
}
}
diff --git a/workers/notifier/types/channel.ts b/workers/notifier/types/channel.ts
index 3a195c3d..001a1f27 100644
--- a/workers/notifier/types/channel.ts
+++ b/workers/notifier/types/channel.ts
@@ -35,6 +35,12 @@ export interface SenderData {
* Number of events received
*/
count: number;
+
+ /**
+ * ID of the repetition that triggered this notification
+ * null for first occurrence, ObjectId string for repetitions
+ */
+ repetitionId: string | null;
}
/**
diff --git a/workers/notifier/types/notifier-task.ts b/workers/notifier/types/notifier-task.ts
index f773660c..04bf3abb 100644
--- a/workers/notifier/types/notifier-task.ts
+++ b/workers/notifier/types/notifier-task.ts
@@ -14,6 +14,12 @@ export type NotifierEvent = Pick, 'title'> & {
* Flag to show if event is received first time
*/
isNew: boolean;
+
+ /**
+ * ID of the repetition that triggered this notification
+ * null for first occurrence, string for repetitions
+ */
+ repetitionId: string | null;
};
/**
diff --git a/workers/paymaster/src/index.ts b/workers/paymaster/src/index.ts
index 333a3911..94be50d9 100644
--- a/workers/paymaster/src/index.ts
+++ b/workers/paymaster/src/index.ts
@@ -160,6 +160,8 @@ export default class PaymasterWorker extends Worker {
/**
* Finds plan by id from cached plans
+ *
+ * @param planId
*/
private findPlanById(planId: WorkspaceDBScheme['tariffPlanId']): PlanDBScheme | undefined {
return this.plans.find((plan) => plan._id.toString() === planId.toString());
@@ -167,6 +169,8 @@ export default class PaymasterWorker extends Worker {
/**
* Returns workspace plan, refreshes cache when plan is missing
+ *
+ * @param workspace
*/
private async getWorkspacePlan(workspace: WorkspaceDBScheme): Promise {
let currentPlan = this.findPlanById(workspace.tariffPlanId);
@@ -413,7 +417,6 @@ export default class PaymasterWorker extends Worker {
});
}
-
/**
* Sends reminder emails to blocked workspace admins
*
diff --git a/workers/paymaster/tests/index.test.ts b/workers/paymaster/tests/index.test.ts
index 8ad43b4d..51ff31fd 100644
--- a/workers/paymaster/tests/index.test.ts
+++ b/workers/paymaster/tests/index.test.ts
@@ -317,6 +317,7 @@ describe('PaymasterWorker', () => {
}
MockDate.reset();
+
return addTaskSpy;
};
diff --git a/workers/release/src/index.ts b/workers/release/src/index.ts
index 93c2618f..ea9282dc 100644
--- a/workers/release/src/index.ts
+++ b/workers/release/src/index.ts
@@ -281,6 +281,7 @@ export default class ReleaseWorker extends Worker {
/**
* Some bundlers could skip file in the source map content since it duplicates in map name
* Like map name bundle.js.map is a source map for a bundle.js
+ *
* @see https://sourcemaps.info/spec.html - format
*/
originFileName: mapContent.file ?? file.name.replace(/\.map$/, ''),
diff --git a/workers/sender/src/index.ts b/workers/sender/src/index.ts
index 7f24c1dc..23fdeb63 100644
--- a/workers/sender/src/index.ts
+++ b/workers/sender/src/index.ts
@@ -171,13 +171,14 @@ export default abstract class SenderWorker extends Worker {
const eventsData = await Promise.all(
events.map(
- async ({ key: groupHash, count }: { key: string; count: number }): Promise => {
+ async ({ key: groupHash, count, repetitionId }: { key: string; count: number; repetitionId?: string | null }): Promise => {
const [event, daysRepeated] = await this.getEventDataByGroupHash(projectId, groupHash);
return {
event,
newCount: count,
daysRepeated,
+ repetitionId: repetitionId ?? null,
};
}
)
diff --git a/workers/sender/types/template-variables/event.ts b/workers/sender/types/template-variables/event.ts
index 701444ec..69e7fe3d 100644
--- a/workers/sender/types/template-variables/event.ts
+++ b/workers/sender/types/template-variables/event.ts
@@ -25,6 +25,11 @@ export interface TemplateEventData {
* Number of affected users for this event
*/
usersAffected?: number;
+
+ /**
+ * ID of the particular repetition of occurred event
+ */
+ repetitionId?: string | null;
}
/**
diff --git a/workers/sentry/src/index.ts b/workers/sentry/src/index.ts
index b0594094..9ed56c4e 100644
--- a/workers/sentry/src/index.ts
+++ b/workers/sentry/src/index.ts
@@ -67,10 +67,12 @@ export default class SentryEventWorker extends Worker {
/**
* Filter out binary items that crash parseEnvelope
+ * Also filters out all Sentry Replay events (replay_event and replay_recording)
*/
private filterOutBinaryItems(rawEvent: string): string {
const lines = rawEvent.split('\n');
const filteredLines = [];
+ let isInReplayBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
@@ -90,17 +92,42 @@ export default class SentryEventWorker extends Worker {
// Try to parse as JSON to check if it's a header
const parsed = JSON.parse(line);
- // If it's a replay header, skip this line and the next one (payload)
+ // Check if this is a replay event type
if (parsed.type === 'replay_recording' || parsed.type === 'replay_event') {
- // Skip the next line too (which would be the payload)
- i++;
+ // Mark that we're in a replay block and skip this line
+ isInReplayBlock = true;
continue;
}
- // Keep valid headers and other JSON data
- filteredLines.push(line);
+ // If we're in a replay block, check if this is still part of it
+ if (isInReplayBlock) {
+ // Check if this line is part of replay data (segment_id, length, etc.)
+ if ('segment_id' in parsed || ('length' in parsed && parsed.type !== 'event') || 'replay_id' in parsed) {
+ // Still in replay block, skip this line
+ continue;
+ }
+
+ // If it's a new envelope item (like event), we've exited the replay block
+ if (parsed.type === 'event' || parsed.type === 'transaction' || parsed.type === 'session') {
+ isInReplayBlock = false;
+ } else {
+ // Unknown type, assume we're still in replay block
+ continue;
+ }
+ }
+
+ // Keep valid headers and other JSON data (not in replay block)
+ if (!isInReplayBlock) {
+ filteredLines.push(line);
+ }
} catch {
- // If line doesn't parse as JSON, it might be binary data - skip it
+ // If line doesn't parse as JSON, it might be binary data
+ // If we're in a replay block, skip it (it's part of replay recording)
+ if (isInReplayBlock) {
+ continue;
+ }
+
+ // If not in replay block and not JSON, it might be corrupted data - skip it
continue;
}
}
@@ -128,6 +155,46 @@ export default class SentryEventWorker extends Worker {
* Skip non-event items
*/
if (itemHeader.type !== 'event') {
+ if (itemHeader.type === 'client_report') {
+ /**
+ * Sentry "client_report" items are useful for debugging dropped events.
+ * We log internals here to make diagnosing SDK/reporting issues easier.
+ */
+ try {
+ let decodedPayload: unknown = itemPayload;
+
+ /**
+ * Sometimes Sentry parses the itemPayload as a Uint8Array.
+ * Decode it to JSON so it can be logged meaningfully.
+ */
+ if (decodedPayload instanceof Uint8Array) {
+ const textDecoder = new TextDecoder();
+ decodedPayload = textDecoder.decode(decodedPayload as Uint8Array);
+ }
+
+ if (typeof decodedPayload === 'string') {
+ try {
+ decodedPayload = JSON.parse(decodedPayload);
+ } catch {
+ /**
+ * Keep the raw string if it isn't valid JSON.
+ */
+ }
+ }
+
+ this.logger.info('Received client_report item; logging internals:');
+ this.logger.json({
+ envelopeHeaders,
+ itemHeader,
+ payload: decodedPayload,
+ });
+ } catch (clientReportError) {
+ this.logger.warn('Failed to decode/log client_report item:', clientReportError);
+ this.logger.info('👇 Here is the raw client_report item:');
+ this.logger.json(item);
+ }
+ }
+
this.logger.info(`Skipping non-event item of type: ${itemHeader.type}`);
return 'skipped';
}
diff --git a/workers/sentry/tests/index.test.ts b/workers/sentry/tests/index.test.ts
index e41c9fc5..acf44e96 100644
--- a/workers/sentry/tests/index.test.ts
+++ b/workers/sentry/tests/index.test.ts
@@ -806,12 +806,18 @@ describe('SentryEventWorker', () => {
event_id: '4c40fee730194a989439a86bf75634111',
sent_at: '2025-08-29T10:59:29.952Z',
/* eslint-enable @typescript-eslint/naming-convention */
- sdk: { name: 'sentry.javascript.react', version: '9.10.1' },
+ sdk: {
+ name: 'sentry.javascript.react',
+ version: '9.10.1',
+ },
}),
// Event item header
JSON.stringify({ type: 'event' }),
// Event item payload
- JSON.stringify({ message: 'Test event', level: 'error' }),
+ JSON.stringify({
+ message: 'Test event',
+ level: 'error',
+ }),
// Replay event item header - should be filtered out
JSON.stringify({ type: 'replay_event' }),
// Replay event item payload - should be filtered out
@@ -822,7 +828,10 @@ describe('SentryEventWorker', () => {
/* eslint-enable @typescript-eslint/naming-convention */
}),
// Replay recording item header - should be filtered out
- JSON.stringify({ type: 'replay_recording', length: 343 }),
+ JSON.stringify({
+ type: 'replay_recording',
+ length: 343,
+ }),
// Replay recording binary payload - should be filtered out
'binary-data-here-that-is-not-json',
];
@@ -841,6 +850,7 @@ describe('SentryEventWorker', () => {
expect(mockedAmqpChannel.sendToQueue).toHaveBeenCalledTimes(1);
const addedTaskPayload = getAddTaskPayloadFromLastCall();
+
expect(addedTaskPayload).toMatchObject({
payload: expect.objectContaining({
addons: {
@@ -852,6 +862,90 @@ describe('SentryEventWorker', () => {
}),
});
});
+
+ it('should ignore envelope with only replay_event and replay_recording items', async () => {
+ /**
+ * Test case based on real-world scenario where envelope contains only replay data
+ * This should not crash with "Unexpected end of JSON input" error
+ */
+ const envelopeLines = [
+ // Envelope header
+ JSON.stringify({
+ /* eslint-disable @typescript-eslint/naming-convention */
+ event_id: '62680958b3ab4497886375e06533d86a',
+ sent_at: '2025-12-24T13:16:34.580Z',
+ /* eslint-enable @typescript-eslint/naming-convention */
+ sdk: {
+ name: 'sentry.javascript.react',
+ version: '10.22.0',
+ },
+ }),
+ // Replay event item header - should be filtered out
+ JSON.stringify({ type: 'replay_event' }),
+ // Replay event item payload (large JSON) - should be filtered out
+ JSON.stringify({
+ /* eslint-disable @typescript-eslint/naming-convention */
+ type: 'replay_event',
+ replay_start_timestamp: 1766582182.757,
+ timestamp: 1766582194.579,
+ error_ids: [],
+ trace_ids: [],
+ urls: ['https://my.huntio.ru/applicants', 'https://my.huntio.ru/applicants/1270067'],
+ replay_id: '62680958b3ab4497886375e06533d86a',
+ segment_id: 1,
+ replay_type: 'session',
+ request: {
+ url: 'https://my.huntio.ru/applicants/1270067',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ },
+ },
+ event_id: '62680958b3ab4497886375e06533d86a',
+ environment: 'production',
+ release: '1.0.7',
+ sdk: {
+ integrations: ['InboundFilters', 'FunctionToString', 'BrowserApiErrors', 'Breadcrumbs'],
+ name: 'sentry.javascript.react',
+ version: '10.22.0',
+ settings: { infer_ip: 'auto' },
+ },
+ user: {
+ id: 487,
+ email: 'npr@unicorn-resources.pro',
+ username: 'Прохорова Наталья',
+ },
+ contexts: { react: { version: '19.1.0' } },
+ transaction: '/applicants/1270067',
+ platform: 'javascript',
+ /* eslint-enable @typescript-eslint/naming-convention */
+ }),
+ // Replay recording item header - should be filtered out
+ JSON.stringify({
+ type: 'replay_recording',
+ length: 16385,
+ }),
+ /* eslint-disable @typescript-eslint/naming-convention */
+ // Segment ID - should be filtered out
+ JSON.stringify({ segment_id: 1 }),
+ /* eslint-enable @typescript-eslint/naming-convention */
+ // Binary data (simulated) - should be filtered out
+ 'xnFWy@v$xAlJ=&fS~¾˶IJ {