Skip to content

Commit 541251f

Browse files
authored
Trim event title to a maximum length in grouper (#574)
* Trim event title to a maximum length in grouper * refactor(grouper): move title trimming into DataFilter * Trim grouper event titles * Add Sanitizer for event payload fields and drop per-event handle log * Keep sanitizer placeholder for context/addons wrapped in object * Update string context test to expect placeholder object * Document string-context sanitize handling * Cut off extra object keys instead of replacing object with placeholder
1 parent 50fc907 commit 541251f

5 files changed

Lines changed: 382 additions & 31 deletions

File tree

lib/utils/sanitizer.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { rightTrim } from './string';
2+
3+
/**
4+
* Maximum string length before appending ellipsis
5+
*/
6+
const MAX_STRING_LENGTH = 200;
7+
8+
/**
9+
* Maximum number of object keys to keep, the rest are reported via META_FIELD
10+
*/
11+
const MAX_OBJECT_KEYS_COUNT = 20;
12+
13+
/**
14+
* Key added to an object to report how many of its keys were skipped
15+
*/
16+
const META_FIELD = '__meta';
17+
18+
/**
19+
* Maximum depth of sanitized objects
20+
*/
21+
const MAX_DEPTH = 5;
22+
23+
/**
24+
* Maximum length of sanitized arrays
25+
*/
26+
const MAX_ARRAY_LENGTH = 10;
27+
28+
/**
29+
* Checks that the value is a plain object
30+
*
31+
* @param target - value to check
32+
*/
33+
function isPlainObject(target: unknown): target is Record<string, unknown> {
34+
return Object.prototype.toString.call(target) === '[object Object]';
35+
}
36+
37+
/**
38+
* Values that can be tracked by WeakSet to detect circular references
39+
*/
40+
type ObjectLike = Record<string, unknown> | unknown[];
41+
42+
/**
43+
* Prepares event data for storing: trims long strings, slices long arrays,
44+
* cuts off extra object keys and replaces too deep objects and circular
45+
* references with placeholders.
46+
*/
47+
export class Sanitizer {
48+
/**
49+
* Apply sanitizing for array/object/primitives
50+
*
51+
* @param data - any value to sanitize
52+
* @param depth - current depth of recursion
53+
* @param seen - already visited objects
54+
*/
55+
public static sanitize(data: unknown, depth = 0, seen = new WeakSet<ObjectLike>()): unknown {
56+
if (data !== null && typeof data === 'object') {
57+
if (seen.has(data as ObjectLike)) {
58+
return '<circular>';
59+
}
60+
seen.add(data as ObjectLike);
61+
}
62+
63+
if (Array.isArray(data)) {
64+
return Sanitizer.sanitizeArray(data, depth + 1, seen);
65+
}
66+
67+
if (isPlainObject(data)) {
68+
return Sanitizer.sanitizeObject(data, depth + 1, seen);
69+
}
70+
71+
if (typeof data === 'string') {
72+
return rightTrim(data, MAX_STRING_LENGTH);
73+
}
74+
75+
return data;
76+
}
77+
78+
/**
79+
* Slices array to the maximum length and sanitizes each element
80+
*
81+
* @param arr - array to sanitize
82+
* @param depth - current depth of recursion
83+
* @param seen - already visited objects
84+
*/
85+
private static sanitizeArray(arr: unknown[], depth: number, seen: WeakSet<ObjectLike>): unknown[] {
86+
const length = arr.length;
87+
88+
if (length > MAX_ARRAY_LENGTH) {
89+
arr = arr.slice(0, MAX_ARRAY_LENGTH);
90+
arr.push(`<${length - MAX_ARRAY_LENGTH} more items...>`);
91+
}
92+
93+
return arr.map((item) => {
94+
return Sanitizer.sanitize(item, depth, seen);
95+
});
96+
}
97+
98+
/**
99+
* Sanitizes object values recursively
100+
*
101+
* @param data - object to sanitize
102+
* @param depth - current depth of recursion
103+
* @param seen - already visited objects
104+
*/
105+
private static sanitizeObject(
106+
data: Record<string, unknown>,
107+
depth: number,
108+
seen: WeakSet<ObjectLike>
109+
): Record<string, unknown> | '<deep object>' {
110+
if (depth > MAX_DEPTH) {
111+
return '<deep object>';
112+
}
113+
114+
const keys = Object.keys(data);
115+
const result: Record<string, unknown> = {};
116+
117+
for (const key of keys.slice(0, MAX_OBJECT_KEYS_COUNT)) {
118+
result[key] = Sanitizer.sanitize(data[key], depth, seen);
119+
}
120+
121+
const skippedKeysCount = keys.length - MAX_OBJECT_KEYS_COUNT;
122+
123+
if (skippedKeysCount > 0) {
124+
result[META_FIELD] = `${skippedKeysCount} more key(s) skipped`;
125+
}
126+
127+
return result;
128+
}
129+
}

workers/grouper/src/data-filter.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import type { EventAddons, EventData } from '@hawk.so/types';
22
import { unsafeFields } from '../../../lib/utils/unsafeFields';
3+
import { rightTrim } from '../../../lib/utils/string';
4+
import { Sanitizer } from '../../../lib/utils/sanitizer';
35

46
/**
57
* Maximum depth for object traversal to prevent excessive memory allocations
68
*/
79
const MAX_TRAVERSAL_DEPTH = 20;
810

11+
/**
12+
* Maximum length for event title before appending ellipsis
13+
*/
14+
const MAX_TITLE_LENGTH = 400;
15+
916
/**
1017
* Recursively iterate through object and call function on each key
1118
*
@@ -135,13 +142,58 @@ export default class DataFilter {
135142
* @param event - event to process
136143
*/
137144
public processEvent(event: EventData<EventAddons>): void {
145+
this.trimEventTitle(event);
146+
this.sanitizeEvent(event);
147+
138148
unsafeFields.forEach(field => {
139149
if (event[field]) {
140150
this.processField(event[field]);
141151
}
142152
});
143153
}
144154

155+
/**
156+
* Trim event title to the maximum allowed length.
157+
* It mutates the original object.
158+
*
159+
* @param event - event to process
160+
*/
161+
public trimEventTitle(event: EventData<EventAddons>): void {
162+
if (typeof event.title === 'string') {
163+
event.title = rightTrim(event.title, MAX_TITLE_LENGTH);
164+
}
165+
}
166+
167+
/**
168+
* Sanitize event fields that can contain long strings, deep objects or long arrays.
169+
* It mutates the original object.
170+
*
171+
* @param event - event to process
172+
*/
173+
public sanitizeEvent(event: EventData<EventAddons>): void {
174+
unsafeFields.forEach(field => {
175+
if (event[field] !== undefined) {
176+
(event as unknown as Record<string, unknown>)[field] = Sanitizer.sanitize(event[field]);
177+
}
178+
});
179+
180+
event.backtrace?.forEach(frame => {
181+
if (frame.arguments !== undefined) {
182+
frame.arguments = Sanitizer.sanitize(frame.arguments) as string[];
183+
}
184+
});
185+
186+
event.breadcrumbs?.forEach(breadcrumb => {
187+
if (typeof breadcrumb.message === 'string') {
188+
breadcrumb.message = Sanitizer.sanitize(breadcrumb.message) as string;
189+
}
190+
191+
if (breadcrumb.data !== undefined) {
192+
breadcrumb.data = Sanitizer.sanitize(breadcrumb.data) as typeof breadcrumb.data;
193+
}
194+
});
195+
}
196+
145197
/**
146198
* Recursively iterates object and applies filtering to its entries
147199
*

workers/grouper/src/index.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const DB_DUPLICATE_KEY_ERROR = '11000';
5959
const DAILY_METRICS_RETENTION_DAYS = 90;
6060

6161
/**
62-
* Maximum length for backtrace code line or title
62+
* Maximum length for backtrace code line
6363
*/
6464
const MAX_CODE_LINE_LENGTH = 140;
6565

@@ -198,8 +198,6 @@ export default class GrouperWorker extends Worker {
198198
this.grouperMetrics.observePayloadSize(taskPayloadSize);
199199
this.memoryMonitor.logBeforeHandle(memoryBeforeHandle, handledTasksCount, taskPayloadSize, task.projectId);
200200

201-
this.logger.info(`[handle] project=${task.projectId} catcher=${task.catcherType} title="${task.payload.title}" payloadSize=${taskPayloadSize}b backtraceFrames=${task.payload.backtrace?.length ?? 0}`);
202-
203201
// FIX RELEASE TYPE
204202
// TODO: REMOVE AFTER 01.01.2026, after the most of the users update to new js catcher
205203
if (task.payload && task.payload.release !== undefined) {
@@ -209,6 +207,11 @@ export default class GrouperWorker extends Worker {
209207
};
210208
}
211209

210+
/**
211+
* Filter event data before hashing so hash and stored event stay consistent.
212+
*/
213+
this.dataFilter.processEvent(task.payload);
214+
212215
let uniqueEventHash = await session.measureStep('hash', () => this.getUniqueEventHash(task));
213216
let existedEvent: GroupedEventDBScheme;
214217
let repetitionId = null;
@@ -219,11 +222,6 @@ export default class GrouperWorker extends Worker {
219222
* Trim source code lines to prevent memory leaks
220223
*/
221224
this.trimSourceCodeLines(task.payload);
222-
223-
/**
224-
* Filter sensitive information
225-
*/
226-
this.dataFilter.processEvent(task.payload);
227225
});
228226

229227
/**

0 commit comments

Comments
 (0)