Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
name: Run integration tests on push

on:
- push
push:
branches-ignore:
- main
- master
- prod

jobs:
tests:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
name: Run Jest tests on push

on:
- push
push:
branches-ignore:
- main
- master
- prod

jobs:
build:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.5.6",
"version": "1.5.8",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
44 changes: 25 additions & 19 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,46 +23,54 @@ const GROUPING_TIMESTAMP_AND_GROUP_HASH_INDEX_NAME = 'groupingTimestampAndGroupH
const DAILY_EVENTS_GROUP_HASH_INDEX_NAME = 'groupHash';
const MAX_SEARCH_QUERY_LENGTH = 50;
const FALLBACK_EVENT_TITLE = 'Unknown';
const { limitBacktraceForDailyEventsList } = require('../utils/eventPayloadLimits');

/**
* Ensures each daily event has non-empty payload title
* and writes warning log with identifiers when fallback is used.
* Temporary list-response sanitizer:
* - fallback for empty payload.title
* - cap backtrace frames/sourceCode size (heavy Rails stacks)
*
* @param {object} dailyEventsPortion - portion returned by events factory
* @param {string|ObjectId} projectId - project id for logs
* @returns {object}
*/
function normalizeDailyEventsPayloadTitle(dailyEventsPortion, projectId) {
function sanitizeDailyEventsPortion(dailyEventsPortion, projectId) {
if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) {
return dailyEventsPortion;
}

dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => {
const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null;
const payload = event && event.payload ? event.payload : null;
const hasValidTitle = payload &&
typeof payload.title === 'string' &&
payload.title.trim().length > 0;
const rawTitle = payload && typeof payload.title === 'string' ? payload.title : '';
const hasValidTitle = rawTitle.trim().length > 0;
const title = hasValidTitle ? rawTitle : FALLBACK_EVENT_TITLE;
const backtrace = limitBacktraceForDailyEventsList(payload && payload.backtrace);
const titleChanged = !payload || payload.title !== title;
const backtraceChanged = !payload || payload.backtrace !== backtrace;

if (!hasValidTitle) {
console.warn('🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', {
projectId: projectId ? projectId.toString() : null,
dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null,
dailyEventGroupHash: dailyEvent && dailyEvent.groupHash ? dailyEvent.groupHash.toString() : null,
eventOriginalId: event && event.originalEventId ? event.originalEventId.toString() : null,
eventId: event && event._id ? event._id.toString() : null,
});
}

if (hasValidTitle) {
if (!titleChanged && !backtraceChanged) {
return dailyEvent;
}

console.warn('🔴🔴🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', {
projectId: projectId ? projectId.toString() : null,
dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null,
dailyEventGroupHash: dailyEvent && dailyEvent.groupHash ? dailyEvent.groupHash.toString() : null,
eventOriginalId: event && event.originalEventId ? event.originalEventId.toString() : null,
eventId: event && event._id ? event._id.toString() : null,
});

return {
...dailyEvent,
event: {
...(event || {}),
payload: {
...(payload || {}),
title: FALLBACK_EVENT_TITLE,
title,
backtrace,
},
},
};
Expand Down Expand Up @@ -667,9 +675,7 @@ module.exports = {
assignee
);

normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id);

return dailyEventsPortion;
return sanitizeDailyEventsPortion(dailyEventsPortion, project._id);
},

/**
Expand Down
79 changes: 79 additions & 0 deletions src/utils/eventPayloadLimits.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Temporary limits for heavy event payloads in list responses.
* Deep Rails backtraces with sourceCode were producing multi‑MB ProjectDailyEvents
* responses and 502s behind the API gateway.
*/

const MAX_DAILY_EVENTS_BACKTRACE_FRAMES =
Number(process.env.MAX_DAILY_EVENTS_BACKTRACE_FRAMES) || 20;

const MAX_DAILY_EVENTS_SOURCE_CODE_LINES =
Number(process.env.MAX_DAILY_EVENTS_SOURCE_CODE_LINES) || 21;

const MAX_DAILY_EVENTS_CODE_LINE_LENGTH =
Number(process.env.MAX_DAILY_EVENTS_CODE_LINE_LENGTH) || 140;

/**
* Trim a string to max length and append ellipsis when truncated.
*
* @param {unknown} content - source line content
* @param {number} maxLength - max characters to keep
* @returns {unknown}
*/
function trimCodeLine(content, maxLength = MAX_DAILY_EVENTS_CODE_LINE_LENGTH) {
if (typeof content !== 'string') {
return content;
}

if (content.length <= maxLength) {
return content;
}

return `${content.slice(0, maxLength)}…`;
}

/**
* Cap backtrace frames and sourceCode size for list payloads.
* Keeps sourceCode for UI, but limits frames/lines so list responses stay small.
*
* @param {unknown} backtrace - event payload backtrace
* @returns {unknown}
*/
function limitBacktraceForDailyEventsList(backtrace) {
if (!Array.isArray(backtrace)) {
return backtrace;
}

return backtrace.slice(0, MAX_DAILY_EVENTS_BACKTRACE_FRAMES).map((frame) => {
if (!frame || typeof frame !== 'object') {
return frame;
}

if (!Array.isArray(frame.sourceCode)) {
return frame;
}

return {
...frame,
sourceCode: frame.sourceCode
.slice(0, MAX_DAILY_EVENTS_SOURCE_CODE_LINES)
.map((line) => {
if (!line || typeof line !== 'object') {
return line;
}

return {
...line,
content: trimCodeLine(line.content),
};
}),
};
});
}

module.exports = {
MAX_DAILY_EVENTS_BACKTRACE_FRAMES,
MAX_DAILY_EVENTS_SOURCE_CODE_LINES,
MAX_DAILY_EVENTS_CODE_LINE_LENGTH,
limitBacktraceForDailyEventsList,
};
67 changes: 67 additions & 0 deletions test/resolvers/project-daily-events-portion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,71 @@ describe('Project resolver dailyEventsPortion', () => {

warnSpy.mockRestore();
});

it('should cap backtrace frames and sourceCode size in list response', async () => {
const longLine = 'x'.repeat(200);
const frames = Array.from({ length: 80 }, (_, index) => {
return {
file: `frame-${index}.rb`,
line: index + 1,
sourceCode: Array.from({ length: 30 }, (__, lineIndex) => {
return {
line: lineIndex + 1,
content: longLine,
};
}),
};
});
const findDailyEventsPortion = jest.fn().mockResolvedValue({
nextCursor: null,
dailyEvents: [
{
id: 'daily-1',
groupHash: 'group-1',
event: {
_id: 'repetition-1',
originalEventId: 'event-1',
payload: {
title: 'PG::UniqueViolation',
backtrace: frames,
},
},
},
],
});
(getEventsFactory as unknown as jest.Mock).mockReturnValue({
findDailyEventsPortion,
});

const project = { _id: 'project-1' };
const args = {
limit: 10,
nextCursor: null,
sort: 'BY_DATE',
filters: {},
search: '',
};

const result = await projectResolver.Project.dailyEventsPortion(project, args, {}) as {
dailyEvents: Array<{
event: {
payload: {
title: string;
backtrace: Array<{
file: string;
sourceCode: Array<{ content: string }>;
}>;
};
};
}>;
};

const backtrace = result.dailyEvents[0].event.payload.backtrace;

expect(backtrace).toHaveLength(20);
expect(backtrace[0].file).toBe('frame-0.rb');
expect(backtrace[0].sourceCode).toHaveLength(21);
expect(backtrace[0].sourceCode[0].content.endsWith('…')).toBe(true);
expect(backtrace[19].file).toBe('frame-19.rb');
});
});
40 changes: 40 additions & 0 deletions test/utils/eventPayloadLimits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
MAX_DAILY_EVENTS_BACKTRACE_FRAMES,
MAX_DAILY_EVENTS_CODE_LINE_LENGTH,
MAX_DAILY_EVENTS_SOURCE_CODE_LINES,
limitBacktraceForDailyEventsList,
} from '../../src/utils/eventPayloadLimits';

describe('eventPayloadLimits', () => {
it('should return non-array backtrace as is', () => {
expect(limitBacktraceForDailyEventsList(null)).toBeNull();
expect(limitBacktraceForDailyEventsList(undefined)).toBeUndefined();
});

it('should cap frames and sourceCode size while keeping sourceCode', () => {
const longLine = 'x'.repeat(MAX_DAILY_EVENTS_CODE_LINE_LENGTH + 40);
const backtrace = Array.from({ length: MAX_DAILY_EVENTS_BACKTRACE_FRAMES + 10 }, (_, index) => {
return {
file: `file-${index}.rb`,
line: index,
sourceCode: Array.from({ length: MAX_DAILY_EVENTS_SOURCE_CODE_LINES + 5 }, (__, lineIndex) => {
return {
line: lineIndex,
content: longLine,
};
}),
};
});

const limited = limitBacktraceForDailyEventsList(backtrace) as Array<{
file: string;
sourceCode: Array<{ content: string }>;
}>;

expect(limited).toHaveLength(MAX_DAILY_EVENTS_BACKTRACE_FRAMES);
expect(limited[0].file).toBe('file-0.rb');
expect(limited[0].sourceCode).toHaveLength(MAX_DAILY_EVENTS_SOURCE_CODE_LINES);
expect(limited[0].sourceCode[0].content.endsWith('…')).toBe(true);
expect(limited[0].sourceCode[0].content.length).toBe(MAX_DAILY_EVENTS_CODE_LINE_LENGTH + 1);
});
});
Loading