diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e58e0441..1896cc0e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,7 +1,11 @@ name: Run integration tests on push on: - - push + push: + branches-ignore: + - main + - master + - prod jobs: tests: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 211fe006..e4bbb5b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,7 +1,11 @@ name: Run Jest tests on push on: - - push + push: + branches-ignore: + - main + - master + - prod jobs: build: diff --git a/package.json b/package.json index 1e9ebca4..9b7c317a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.6", + "version": "1.5.8", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 7e4bf7fe..029d30f4 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -23,16 +23,18 @@ 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; } @@ -40,29 +42,35 @@ function normalizeDailyEventsPayloadTitle(dailyEventsPortion, projectId) { 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, }, }, }; @@ -667,9 +675,7 @@ module.exports = { assignee ); - normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id); - - return dailyEventsPortion; + return sanitizeDailyEventsPortion(dailyEventsPortion, project._id); }, /** diff --git a/src/utils/eventPayloadLimits.js b/src/utils/eventPayloadLimits.js new file mode 100644 index 00000000..817f075b --- /dev/null +++ b/src/utils/eventPayloadLimits.js @@ -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, +}; diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index ba9d61c9..e399f001 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -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'); + }); }); diff --git a/test/utils/eventPayloadLimits.test.ts b/test/utils/eventPayloadLimits.test.ts new file mode 100644 index 00000000..95aa8b7a --- /dev/null +++ b/test/utils/eventPayloadLimits.test.ts @@ -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); + }); +});