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 {