From 8844840c0ac48a2f14c5de648fd41aa62ead90b0 Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 24 Dec 2025 15:55:17 +0300 Subject: [PATCH 1/3] chore(event-email): event email notification now contains link to a particular repetition (#499) * Add repetitionId to event notification flow Introduces the repetitionId field to event notification data structures and templates, allowing emails and notifications to reference specific event repetitions. Updates TypeScript interfaces, worker logic, and email templates to support and display repetitionId where applicable. * fix grouper test --- workers/email/src/templates/emails/event/html.twig | 7 ++++++- workers/email/src/templates/emails/event/text.twig | 7 ++++++- workers/grouper/src/index.ts | 1 + workers/grouper/tests/index.test.ts | 1 + workers/notifier/src/index.ts | 1 + workers/notifier/types/channel.ts | 6 ++++++ workers/notifier/types/notifier-task.ts | 6 ++++++ workers/sender/src/index.ts | 3 ++- workers/sender/types/template-variables/event.ts | 5 +++++ 9 files changed, 34 insertions(+), 3 deletions(-) diff --git a/workers/email/src/templates/emails/event/html.twig b/workers/email/src/templates/emails/event/html.twig index 35be79c6c..535afe3a7 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 d12c7f00d..f078d7d10 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 8eef04fce..8d20daf56 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 153a7952b..ee781e98a 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/notifier/src/index.ts b/workers/notifier/src/index.ts index a05101c32..d2d17966e 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 3a195c3d7..001a1f273 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 f773660c9..04bf3abb6 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/sender/src/index.ts b/workers/sender/src/index.ts index 7f24c1dc3..23fdeb63c 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 701444ece..69e7fe3d3 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; } /** From 21cb047c61f4790175daf44599d6925779e1d94d Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 24 Dec 2025 18:07:25 +0300 Subject: [PATCH 2/3] fix(sentry): replay skipping improved (#503) * fix sentry replay skipping * lint code * Update index.test.ts --- lib/memoize/index.ts | 2 +- workers/archiver/src/index.ts | 14 ++-- workers/email/scripts/emailOverview.ts | 2 +- workers/javascript/tests/index.test.ts | 1 - workers/limiter/tests/dbHelper.test.ts | 2 +- workers/paymaster/src/index.ts | 5 +- workers/paymaster/tests/index.test.ts | 1 + workers/release/src/index.ts | 1 + workers/sentry/src/index.ts | 39 ++++++++-- workers/sentry/tests/index.test.ts | 100 ++++++++++++++++++++++++- 10 files changed, 146 insertions(+), 21 deletions(-) diff --git a/lib/memoize/index.ts b/lib/memoize/index.ts index a363360cf..c4c62f729 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 18d39de4d..457f36c55 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 ff36ed82a..a108b86b8 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/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 6b57d1e4a..531826e89 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 9b705878f..cded599b5 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/paymaster/src/index.ts b/workers/paymaster/src/index.ts index 333a39118..94be50d96 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 8ad43b4de..51ff31fdd 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 93c2618f6..ea9282dca 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/sentry/src/index.ts b/workers/sentry/src/index.ts index b05940944..ff71217b9 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; } } diff --git a/workers/sentry/tests/index.test.ts b/workers/sentry/tests/index.test.ts index e41c9fc58..acf44e965 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 { From 2cb3e5b14d0df10613482b57f08aa3e5a8acf5b9 Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 26 Dec 2025 15:14:18 +0300 Subject: [PATCH 3/3] Log Sentry client_report items for debugging (#505) Added handling for 'client_report' items in Sentry envelopes to log their internals for easier debugging of dropped events and SDK/reporting issues. Decodes payloads as needed and logs errors if decoding fails. --- workers/sentry/src/index.ts | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/workers/sentry/src/index.ts b/workers/sentry/src/index.ts index ff71217b9..9ed56c4e5 100644 --- a/workers/sentry/src/index.ts +++ b/workers/sentry/src/index.ts @@ -155,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'; }