From d9e176b3a0ade4d10c0d70c1df068c4d9eb2a64b Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 29 Jul 2026 18:14:10 +0300 Subject: [PATCH] chore(data-filter): cap backtrace frames --- workers/grouper/src/data-filter.ts | 72 +++++++++++++++++++++++ workers/grouper/src/index.ts | 49 --------------- workers/grouper/tests/data-filter.test.ts | 34 +++++++++++ 3 files changed, 106 insertions(+), 49 deletions(-) diff --git a/workers/grouper/src/data-filter.ts b/workers/grouper/src/data-filter.ts index bd89d175..a7c58c04 100644 --- a/workers/grouper/src/data-filter.ts +++ b/workers/grouper/src/data-filter.ts @@ -13,6 +13,22 @@ const MAX_TRAVERSAL_DEPTH = 20; */ const MAX_TITLE_LENGTH = 400; +/** + * Maximum number of backtrace frames to keep. + * Deep Rails stacks (200+ frames with sourceCode) inflate stored events and list API responses. + */ +const MAX_BACKTRACE_FRAMES = 20; + +/** + * Maximum sourceCode lines kept per backtrace frame + */ +const MAX_SOURCE_CODE_LINES = 21; + +/** + * Maximum length of a single source code line + */ +const MAX_CODE_LINE_LENGTH = 140; + /** * Recursively iterate through object and call function on each key * @@ -143,6 +159,7 @@ export default class DataFilter { */ public processEvent(event: EventData): void { this.trimEventTitle(event); + this.trimBacktrace(event); this.sanitizeEvent(event); unsafeFields.forEach(field => { @@ -164,6 +181,54 @@ export default class DataFilter { } } + /** + * Cap backtrace frames and sourceCode size. + * It mutates the original object. + * + * @param event - event to process + */ + public trimBacktrace(event: EventData): void { + if (!Array.isArray(event.backtrace)) { + return; + } + + if (event.backtrace.length === 0) { + /** + * Normalize empty backtrace — empty arrays lead to visual bugs + */ + delete event.backtrace; + + return; + } + + if (event.backtrace.length > MAX_BACKTRACE_FRAMES) { + event.backtrace = event.backtrace.slice(0, MAX_BACKTRACE_FRAMES); + } + + event.backtrace.forEach((frame) => { + if (!frame || !Array.isArray(frame.sourceCode)) { + return; + } + + const sourceCode = frame.sourceCode.length > MAX_SOURCE_CODE_LINES + ? frame.sourceCode.slice(0, MAX_SOURCE_CODE_LINES) + : frame.sourceCode; + + frame.sourceCode = sourceCode.map((line) => { + if (!line || typeof line !== 'object') { + return line; + } + + return { + ...line, + content: typeof line.content === 'string' + ? rightTrim(line.content, MAX_CODE_LINE_LENGTH) + : line.content, + }; + }); + }); + } + /** * Sanitize event fields that can contain long strings, deep objects or long arrays. * It mutates the original object. @@ -275,3 +340,10 @@ export default class DataFilter { return value; } } + +export { + MAX_BACKTRACE_FRAMES, + MAX_SOURCE_CODE_LINES, + MAX_CODE_LINE_LENGTH, + MAX_TITLE_LENGTH, +}; diff --git a/workers/grouper/src/index.ts b/workers/grouper/src/index.ts index 46f77c98..6750d768 100644 --- a/workers/grouper/src/index.ts +++ b/workers/grouper/src/index.ts @@ -7,11 +7,7 @@ import * as WorkerNames from '../../../lib/workerNames'; import * as pkg from '../package.json'; import type { GroupWorkerTask, RepetitionDelta } from '../types/group-worker-task'; import type { - EventAddons, - EventData, GroupedEventDBScheme, - BacktraceFrame, - SourceCodeLine, ProjectEventGroupingPatternsDBScheme, ErrorsCatcherType } from '@hawk.so/types'; @@ -24,8 +20,6 @@ import DataFilter from './data-filter'; import RedisHelper from './redisHelper'; import { computeDelta } from './utils/repetitionDiff'; import { bucketTimestampMs } from './utils/bucketTimestamp'; -import { rightTrim } from '../../../lib/utils/string'; -import { hasValue } from '../../../lib/utils/hasValue'; import GrouperMetrics from './metrics/grouperMetrics'; import GrouperMemoryMonitor from './metrics/memoryMonitor'; import SlowHandleDiagnostics, { SlowHandleSession } from './metrics/slowHandleDiagnostics'; @@ -58,11 +52,6 @@ const DB_DUPLICATE_KEY_ERROR = '11000'; */ const DAILY_METRICS_RETENTION_DAYS = 90; -/** - * Maximum length for backtrace code line - */ -const MAX_CODE_LINE_LENGTH = 140; - /** * Worker for handling Javascript events */ @@ -217,13 +206,6 @@ export default class GrouperWorker extends Worker { let repetitionId = null; let incrementDailyAffectedUsers = false; - await session.measureStep('preprocess', () => { - /** - * Trim source code lines to prevent memory leaks - */ - this.trimSourceCodeLines(task.payload); - }); - /** * Find similar events by grouping pattern */ @@ -494,37 +476,6 @@ export default class GrouperWorker extends Worker { } } - /** - * Trims source code lines in event's backtrace to prevent memory leaks - * - * @param event - event to process - */ - private trimSourceCodeLines(event: EventData): void { - if (!event.backtrace) { - return; - } - - event.backtrace.forEach((frame: BacktraceFrame) => { - if (!frame.sourceCode) { - return; - } - - frame.sourceCode = frame.sourceCode.map((line: SourceCodeLine) => { - return { - line: line.line, - content: hasValue(line.content) ? rightTrim(line.content, MAX_CODE_LINE_LENGTH) : line.content, - }; - }); - }); - - /** - * Normalize backtrace, if backtrace equals to [] it leads to visual bugs - */ - if (event.backtrace.length === 0) { - event.backtrace = null; - } - } - /** * Get unique hash based on event type and title * diff --git a/workers/grouper/tests/data-filter.test.ts b/workers/grouper/tests/data-filter.test.ts index e4ec8164..1b1f1d22 100644 --- a/workers/grouper/tests/data-filter.test.ts +++ b/workers/grouper/tests/data-filter.test.ts @@ -517,4 +517,38 @@ describe('GrouperWorker', () => { expect(event.context['self']).toBe(''); }); }); + + describe('Backtrace trimming', () => { + test('should cap backtrace frames and sourceCode lines', () => { + const longLine = 'x'.repeat(300); + const backtrace = Array.from({ length: 80 }, (_, index) => { + return { + file: `frame-${index}.rb`, + line: index + 1, + sourceCode: Array.from({ length: 25 }, (__, lineIndex) => { + return { + line: lineIndex + 1, + content: longLine, + }; + }), + }; + }); + const event = generateEvent({ backtrace }); + + dataFilter.processEvent(event); + + expect(event.backtrace).toHaveLength(20); + expect(event.backtrace?.[0].sourceCode).toHaveLength(21); + expect(event.backtrace?.[0].sourceCode?.[0].content?.endsWith('…')).toBe(true); + expect(event.backtrace?.[0].sourceCode?.[0].content?.length).toBeLessThanOrEqual(141); + }); + + test('should normalize empty backtrace to undefined', () => { + const event = generateEvent({ backtrace: [] }); + + dataFilter.processEvent(event); + + expect(event.backtrace).toBeUndefined(); + }); + }); });