From e79aed1bb3f58a781f3f7393ecd16a9bee44fb4e Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Tue, 15 Sep 2026 20:11:12 +0300 Subject: [PATCH 1/4] fix(daily-events): temporarly convert invalid timesamps --- src/resolvers/project.js | 250 +++++++++++++++--- src/utils/graphqlIntSafe.js | 204 ++++++++++++++ .../project-daily-events-portion.test.ts | 66 +++++ test/utils/graphqlIntSafe.test.ts | 60 +++++ 4 files changed, 545 insertions(+), 35 deletions(-) create mode 100644 src/utils/graphqlIntSafe.js create mode 100644 test/utils/graphqlIntSafe.test.ts diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 029d30f4..e3e8324d 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -24,56 +24,236 @@ const DAILY_EVENTS_GROUP_HASH_INDEX_NAME = 'groupHash'; const MAX_SEARCH_QUERY_LENGTH = 50; const FALLBACK_EVENT_TITLE = 'Unknown'; const { limitBacktraceForDailyEventsList } = require('../utils/eventPayloadLimits'); +const { + isUnsafeUnixTimestamp, + toSafeGraphQLInt, + toSafeUnixTimestampForGraphQLInt, + toSafeSortValueBoundary, + utcMidnightUnix, +} = require('../utils/graphqlIntSafe'); /** - * Temporary list-response sanitizer: - * - fallback for empty payload.title - * - cap backtrace frames/sourceCode size (heavy Rails stacks) + * TEMPORARY (remove after ~2026-11-15): clamps nextCursor Int fields that + * exceed GraphQL Int / sane unix range. Needed while legacy Sentry events with + * year-2056 timestamps may still exist in Mongo. * - * @param {object} dailyEventsPortion - portion returned by events factory - * @param {string|ObjectId} projectId - project id for logs - * @returns {object} + * @param {object} cursor - DailyEventsCursor from factory + * @param {string|null} projectIdStr - project id for logs + * @param {number} nowSec - current unix seconds + * @returns {object} original or converted cursor */ -function sanitizeDailyEventsPortion(dailyEventsPortion, projectId) { - if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) { - return dailyEventsPortion; +function sanitizeDailyEventsCursor(cursor, projectIdStr, nowSec) { + if (!cursor) { + return cursor; } - dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => { - const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null; - const payload = event && event.payload ? event.payload : null; - 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, - }); - } + const safeGrouping = toSafeUnixTimestampForGraphQLInt( + cursor.groupingTimestampBoundary, + cursor.idBoundary, + nowSec + ); + const safeSort = toSafeSortValueBoundary( + cursor.sortValueBoundary, + cursor.idBoundary, + nowSec + ); + + if ( + safeGrouping === cursor.groupingTimestampBoundary && + safeSort === cursor.sortValueBoundary + ) { + return cursor; + } - if (!titleChanged && !backtraceChanged) { - return dailyEvent; - } + console.warn('🟡 [ProjectResolver.dailyEventsPortion] Converted nextCursor Int-unsafe values', { + projectId: projectIdStr, + before: { + groupingTimestampBoundary: cursor.groupingTimestampBoundary, + sortValueBoundary: cursor.sortValueBoundary, + }, + after: { + groupingTimestampBoundary: safeGrouping, + sortValueBoundary: safeSort, + }, + }); + + return { + ...cursor, + groupingTimestampBoundary: safeGrouping, + sortValueBoundary: safeSort, + }; +} - return { - ...dailyEvent, - event: { - ...(event || {}), +/** + * TEMPORARY (remove after ~2026-11-15): sanitizes one DailyEvent row for GraphQL + * list response — title fallback, backtrace limits, Int-safe timestamps/counts. + * Drop once collector clamp has aged out bad dailyEvents / repetitions. + * + * @param {object} dailyEvent - DailyEvent from factory + * @param {string|null} projectIdStr - project id for logs + * @param {number} nowSec - current unix seconds + * @returns {object} + */ +function sanitizeDailyEvent(dailyEvent, projectIdStr, nowSec) { + const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null; + const payload = event && event.payload ? event.payload : null; + 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; + + const fallbackId = (event && (event._id || event.id)) || + (dailyEvent && dailyEvent.id) || + null; + + const safeLastRepetitionTime = toSafeUnixTimestampForGraphQLInt( + dailyEvent && dailyEvent.lastRepetitionTime, + fallbackId, + nowSec + ); + const safeGroupingTimestamp = toSafeUnixTimestampForGraphQLInt( + dailyEvent && dailyEvent.groupingTimestamp, + fallbackId, + nowSec + ); + /** + * Prefer midnight of corrected lastRepetitionTime when grouping was also bad, + * so day buckets stay consistent with the event time we expose. + */ + const groupingNeedsFix = isUnsafeUnixTimestamp(dailyEvent && dailyEvent.groupingTimestamp, nowSec); + const lastRepetitionNeedsFix = isUnsafeUnixTimestamp(dailyEvent && dailyEvent.lastRepetitionTime, nowSec); + const correctedGroupingTimestamp = groupingNeedsFix + ? utcMidnightUnix( + lastRepetitionNeedsFix + ? safeLastRepetitionTime + : (typeof dailyEvent.lastRepetitionTime === 'number' + ? dailyEvent.lastRepetitionTime + : safeGroupingTimestamp) + ) + : safeGroupingTimestamp; + + const safeCount = typeof (dailyEvent && dailyEvent.count) === 'number' + ? toSafeGraphQLInt(dailyEvent.count, 0) + : dailyEvent.count; + const safeAffectedUsers = typeof (dailyEvent && dailyEvent.affectedUsers) === 'number' + ? toSafeGraphQLInt(dailyEvent.affectedUsers, 0) + : dailyEvent.affectedUsers; + + let nextEvent = event; + + if (event) { + const safeTotalCount = typeof event.totalCount === 'number' + ? toSafeGraphQLInt(event.totalCount, 0) + : event.totalCount; + const safeUsersAffected = typeof event.usersAffected === 'number' + ? toSafeGraphQLInt(event.usersAffected, 0) + : event.usersAffected; + const safeEventTimestamp = toSafeUnixTimestampForGraphQLInt( + event.timestamp, + fallbackId, + nowSec + ); + + const eventIntsChanged = safeTotalCount !== event.totalCount || + safeUsersAffected !== event.usersAffected || + safeEventTimestamp !== event.timestamp; + + if (eventIntsChanged || titleChanged || backtraceChanged) { + nextEvent = { + ...event, + totalCount: safeTotalCount, + usersAffected: safeUsersAffected, + timestamp: safeEventTimestamp, payload: { ...(payload || {}), title, backtrace, }, + }; + } + } else if (titleChanged || backtraceChanged) { + nextEvent = { + ...(event || {}), + payload: { + ...(payload || {}), + title, + backtrace, }, }; + } + + const dailyChanged = correctedGroupingTimestamp !== dailyEvent.groupingTimestamp || + safeLastRepetitionTime !== dailyEvent.lastRepetitionTime || + safeCount !== dailyEvent.count || + safeAffectedUsers !== dailyEvent.affectedUsers || + nextEvent !== event; + + if (dailyChanged && (groupingNeedsFix || lastRepetitionNeedsFix)) { + console.warn('🟡 [ProjectResolver.dailyEventsPortion] Converted Int-unsafe daily event timestamps', { + projectId: projectIdStr, + dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null, + before: { + groupingTimestamp: dailyEvent.groupingTimestamp, + lastRepetitionTime: dailyEvent.lastRepetitionTime, + }, + after: { + groupingTimestamp: correctedGroupingTimestamp, + lastRepetitionTime: safeLastRepetitionTime, + }, + }); + } + + if (!hasValidTitle) { + console.warn('🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', { + projectId: projectIdStr, + 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 (!dailyChanged) { + return dailyEvent; + } + + return { + ...dailyEvent, + count: safeCount, + affectedUsers: safeAffectedUsers, + groupingTimestamp: correctedGroupingTimestamp, + lastRepetitionTime: safeLastRepetitionTime, + event: nextEvent, + }; +} + +/** + * TEMPORARY (remove after ~2026-11-15): list-response sanitizer for + * dailyEventsPortion — title/backtrace hygiene plus Int overflow conversion + * for legacy far-future Sentry timestamps. Safe to delete once those docs age out. + * + * @param {object} dailyEventsPortion - portion returned by events factory + * @param {string|ObjectId} projectId - project id for logs + * @returns {object} + */ +function sanitizeDailyEventsPortion(dailyEventsPortion, projectId) { + if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) { + return dailyEventsPortion; + } + + const projectIdStr = projectId ? projectId.toString() : null; + const nowSec = Math.floor(Date.now() / 1000); + + dailyEventsPortion.nextCursor = sanitizeDailyEventsCursor( + dailyEventsPortion.nextCursor, + projectIdStr, + nowSec + ); + + dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => { + return sanitizeDailyEvent(dailyEvent, projectIdStr, nowSec); }); return dailyEventsPortion; diff --git a/src/utils/graphqlIntSafe.js b/src/utils/graphqlIntSafe.js new file mode 100644 index 00000000..ee68e0ab --- /dev/null +++ b/src/utils/graphqlIntSafe.js @@ -0,0 +1,204 @@ +/** + * TEMPORARY (remove after ~2026-11-15 together with sanitizeDailyEvent* in + * project.js): helpers to keep GraphQL Int fields within the signed 32-bit + * range while legacy/bad Sentry timestamps (e.g. year 2056) may still exist. + */ + +const GRAPHQL_INT_MIN = -2147483648; +const GRAPHQL_INT_MAX = 2147483647; + +/** + * Allow a small clock skew ahead of server time. + */ +const FUTURE_SLACK_SEC = 24 * 60 * 60; + +/** + * Reject timestamps older than this relative to now. + */ +const MAX_PAST_SEC = 10 * 365.25 * 24 * 60 * 60; + +/** + * Values above this are almost certainly unix milliseconds, not seconds. + */ +const UNIX_MS_THRESHOLD = 1e12; + +/** + * Counts rarely reach 1e9; unix seconds for modern dates do. + */ +const TIMESTAMP_SORT_VALUE_THRESHOLD = 1e9; + +/** + * @param {*} value + * @returns {boolean} + */ +function isOutOfGraphQLIntRange(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return false; + } + + if (!Number.isInteger(value)) { + return true; + } + + return value < GRAPHQL_INT_MIN || value > GRAPHQL_INT_MAX; +} + +/** + * @param {string|object|null|undefined} id - Mongo ObjectId or hex string + * @returns {number|null} unix seconds from ObjectId, or null + */ +function unixSecondsFromObjectId(id) { + if (!id) { + return null; + } + + const hex = id.toString().slice(0, 8); + + if (!/^[a-fA-F0-9]{8}$/.test(hex)) { + return null; + } + + const ts = parseInt(hex, 16); + + if (!Number.isFinite(ts)) { + return null; + } + + return ts; +} + +/** + * @param {number} unixSeconds + * @returns {number} UTC midnight unix seconds + */ +function utcMidnightUnix(unixSeconds) { + const date = new Date(unixSeconds * 1000); + + date.setUTCHours(0, 0, 0, 0); + + return Math.floor(date.getTime() / 1000); +} + +/** + * Clamp any number into GraphQL Int range. + * + * @param {*} value + * @param {number} [fallback=0] + * @returns {number} + */ +function toSafeGraphQLInt(value, fallback = 0) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return toSafeGraphQLInt(fallback, 0); + } + + const intValue = Math.trunc(value); + + if (intValue > GRAPHQL_INT_MAX) { + return GRAPHQL_INT_MAX; + } + + if (intValue < GRAPHQL_INT_MIN) { + return GRAPHQL_INT_MIN; + } + + return intValue; +} + +/** + * Convert a stored unix timestamp into a GraphQL-Int-safe value. + * Prefers ObjectId receive-time when the stored value is absurd / out of Int32. + * Non-numbers are returned unchanged. + * + * @param {*} value - stored timestamp (seconds or ms) + * @param {string|object|null|undefined} fallbackId - ObjectId for fallback seconds + * @param {number} [nowSec=Math.floor(Date.now()/1000)] + * @returns {*} + */ +function toSafeUnixTimestampForGraphQLInt(value, fallbackId, nowSec = Math.floor(Date.now() / 1000)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return value; + } + + const fromOid = unixSecondsFromObjectId(fallbackId); + const fallback = fromOid != null ? fromOid : Math.min(nowSec, GRAPHQL_INT_MAX); + + let ts = Math.trunc(value); + + if (ts > UNIX_MS_THRESHOLD) { + ts = Math.floor(ts / 1000); + } + + const maxFuture = nowSec + FUTURE_SLACK_SEC; + const minPast = nowSec - MAX_PAST_SEC; + + if ( + ts > GRAPHQL_INT_MAX || + ts < GRAPHQL_INT_MIN || + ts > maxFuture || + ts < minPast + ) { + return toSafeGraphQLInt(fallback, nowSec); + } + + return ts; +} + +/** + * @param {*} value + * @param {number} [nowSec=Math.floor(Date.now()/1000)] + * @returns {boolean} + */ +function isUnsafeUnixTimestamp(value, nowSec = Math.floor(Date.now() / 1000)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return false; + } + + let ts = Math.trunc(value); + + if (ts > UNIX_MS_THRESHOLD) { + ts = Math.floor(ts / 1000); + } + + const maxFuture = nowSec + FUTURE_SLACK_SEC; + const minPast = nowSec - MAX_PAST_SEC; + + return ( + ts > GRAPHQL_INT_MAX || + ts < GRAPHQL_INT_MIN || + ts > maxFuture || + ts < minPast || + !Number.isInteger(value) + ); +} + +/** + * sortValueBoundary may be lastRepetitionTime, count, or affectedUsers. + * + * @param {*} value + * @param {string|object|null|undefined} idBoundary + * @param {number} [nowSec] + * @returns {number} + */ +function toSafeSortValueBoundary(value, idBoundary, nowSec = Math.floor(Date.now() / 1000)) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return value; + } + + if (Math.abs(value) >= TIMESTAMP_SORT_VALUE_THRESHOLD || value > GRAPHQL_INT_MAX || value < GRAPHQL_INT_MIN) { + return toSafeUnixTimestampForGraphQLInt(value, idBoundary, nowSec); + } + + return toSafeGraphQLInt(value, 0); +} + +module.exports = { + GRAPHQL_INT_MIN, + GRAPHQL_INT_MAX, + isOutOfGraphQLIntRange, + isUnsafeUnixTimestamp, + unixSecondsFromObjectId, + utcMidnightUnix, + toSafeGraphQLInt, + toSafeUnixTimestampForGraphQLInt, + toSafeSortValueBoundary, +}; diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index e399f001..7b16df3a 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -221,6 +221,72 @@ describe('Project resolver dailyEventsPortion', () => { warnSpy.mockRestore(); }); + it('should convert far-future timestamps to ObjectId-based Int-safe values', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const eventObjectId = '6aa93c9b3a3878cb15936a41'; + const expectedTs = parseInt(eventObjectId.slice(0, 8), 16); + const expectedMidnight = Math.floor(new Date(expectedTs * 1000).setUTCHours(0, 0, 0, 0) / 1000); + + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: { + groupingTimestampBoundary: 2736115200, + sortValueBoundary: 2736187957, + idBoundary: '6aa82a4f9f06968718806c76', + }, + dailyEvents: [ + { + id: '6aa93c9b9eb65b518e9f8cf0', + count: 1, + affectedUsers: 0, + groupingTimestamp: 2736201600, + lastRepetitionTime: 2736250836, + event: { + _id: eventObjectId, + originalEventId: '6a217a79db8fff3481881dd4', + totalCount: 13692, + usersAffected: 0, + timestamp: 2736250836, + payload: { + title: 'Future clock event', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const project = { _id: 'project-1' }; + const result = await projectResolver.Project.dailyEventsPortion(project, { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }, {}) as { + nextCursor: { + groupingTimestampBoundary: number; + sortValueBoundary: number; + }; + dailyEvents: Array<{ + groupingTimestamp: number; + lastRepetitionTime: number; + event: { timestamp: number; totalCount: number }; + }>; + }; + + expect(result.dailyEvents[0].groupingTimestamp).toBe(expectedMidnight); + expect(result.dailyEvents[0].lastRepetitionTime).toBe(expectedTs); + expect(result.dailyEvents[0].event.timestamp).toBe(expectedTs); + expect(result.dailyEvents[0].event.totalCount).toBe(13692); + expect(result.nextCursor.groupingTimestampBoundary).toBe(parseInt('6aa82a4f', 16)); + expect(result.nextCursor.sortValueBoundary).toBe(parseInt('6aa82a4f', 16)); + expect(warnSpy).toHaveBeenCalled(); + + 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) => { diff --git a/test/utils/graphqlIntSafe.test.ts b/test/utils/graphqlIntSafe.test.ts new file mode 100644 index 00000000..8ba323ab --- /dev/null +++ b/test/utils/graphqlIntSafe.test.ts @@ -0,0 +1,60 @@ +import '../../src/env-test'; + +const { + GRAPHQL_INT_MAX, + isOutOfGraphQLIntRange, + unixSecondsFromObjectId, + utcMidnightUnix, + toSafeGraphQLInt, + toSafeUnixTimestampForGraphQLInt, + toSafeSortValueBoundary, +} = require('../../src/utils/graphqlIntSafe'); + +describe('graphqlIntSafe', () => { + const nowSec = Math.floor(new Date('2026-09-15T12:00:00Z').getTime() / 1000); + const objectId = '6aa93c9b3a3878cb15936a41'; // ~2026-09-15T12:39:55Z + const objectIdSec = unixSecondsFromObjectId(objectId); + + it('detects values outside GraphQL Int range', () => { + expect(isOutOfGraphQLIntRange(2736201600)).toBe(true); + expect(isOutOfGraphQLIntRange(GRAPHQL_INT_MAX)).toBe(false); + expect(isOutOfGraphQLIntRange(1.5)).toBe(true); + }); + + it('parses unix seconds from ObjectId', () => { + expect(objectIdSec).toBe(parseInt('6aa93c9b', 16)); + }); + + it('clamps oversized counts to Int max', () => { + expect(toSafeGraphQLInt(3000000000)).toBe(GRAPHQL_INT_MAX); + }); + + it('replaces far-future timestamps with ObjectId time', () => { + const farFuture = 2736250836; + const safe = toSafeUnixTimestampForGraphQLInt(farFuture, objectId, nowSec); + + expect(safe).toBe(objectIdSec); + expect(isOutOfGraphQLIntRange(safe)).toBe(false); + }); + + it('converts millisecond timestamps', () => { + const ms = (nowSec - 120) * 1000; + expect(toSafeUnixTimestampForGraphQLInt(ms, objectId, nowSec)).toBe(nowSec - 120); + }); + + it('keeps reasonable timestamps', () => { + expect(toSafeUnixTimestampForGraphQLInt(nowSec - 3600, objectId, nowSec)).toBe(nowSec - 3600); + }); + + it('builds utc midnight from corrected time', () => { + const midnight = utcMidnightUnix(objectIdSec); + + expect(midnight).toBeLessThanOrEqual(objectIdSec); + expect(midnight % 86400).toBe(0); + }); + + it('treats large sort boundaries as timestamps', () => { + expect(toSafeSortValueBoundary(2736187957, objectId, nowSec)).toBe(objectIdSec); + expect(toSafeSortValueBoundary(42, objectId, nowSec)).toBe(42); + }); +}); From 41210fa5d7fa5564cf1db3ae8b558092249eea95 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:13:31 +0000 Subject: [PATCH 2/4] Bump version up to 1.5.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 25b4791b..3800d089 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.14", + "version": "1.5.15", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From c5d6433ae726f4852409eede13e84fcbb984b778 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Tue, 15 Sep 2026 20:16:19 +0300 Subject: [PATCH 3/4] Update project.js --- src/resolvers/project.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolvers/project.js b/src/resolvers/project.js index e3e8324d..4bc16b71 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -33,7 +33,7 @@ const { } = require('../utils/graphqlIntSafe'); /** - * TEMPORARY (remove after ~2026-11-15): clamps nextCursor Int fields that + * @todo TEMPORARY (remove after ~2026-11-15): clamps nextCursor Int fields that * exceed GraphQL Int / sane unix range. Needed while legacy Sentry events with * year-2056 timestamps may still exist in Mongo. * From 5ec0e25c80531e6b8029c61517473cdd32007822 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Tue, 15 Sep 2026 22:52:12 +0300 Subject: [PATCH 4/4] update --- src/resolvers/project.js | 27 +++++--- src/utils/graphqlIntSafe.js | 64 +++++++++++++------ .../project-daily-events-portion.test.ts | 60 +++++++++++++++++ test/utils/graphqlIntSafe.test.ts | 15 ++++- 4 files changed, 133 insertions(+), 33 deletions(-) diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 4bc16b71..40bfb51e 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -33,16 +33,17 @@ const { } = require('../utils/graphqlIntSafe'); /** - * @todo TEMPORARY (remove after ~2026-11-15): clamps nextCursor Int fields that - * exceed GraphQL Int / sane unix range. Needed while legacy Sentry events with - * year-2056 timestamps may still exist in Mongo. + * TEMPORARY (remove after ~2026-11-15): clamps nextCursor Int fields. + * Factory still matches raw Mongo boundaries — converted cursors may skip + * leftover legacy rows on later pages (see sanitizeDailyEventsPortion note). * * @param {object} cursor - DailyEventsCursor from factory * @param {string|null} projectIdStr - project id for logs + * @param {string|undefined} sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS * @param {number} nowSec - current unix seconds * @returns {object} original or converted cursor */ -function sanitizeDailyEventsCursor(cursor, projectIdStr, nowSec) { +function sanitizeDailyEventsCursor(cursor, projectIdStr, sort, nowSec) { if (!cursor) { return cursor; } @@ -55,6 +56,7 @@ function sanitizeDailyEventsCursor(cursor, projectIdStr, nowSec) { const safeSort = toSafeSortValueBoundary( cursor.sortValueBoundary, cursor.idBoundary, + sort, nowSec ); @@ -67,6 +69,7 @@ function sanitizeDailyEventsCursor(cursor, projectIdStr, nowSec) { console.warn('🟡 [ProjectResolver.dailyEventsPortion] Converted nextCursor Int-unsafe values', { projectId: projectIdStr, + sort, before: { groupingTimestampBoundary: cursor.groupingTimestampBoundary, sortValueBoundary: cursor.sortValueBoundary, @@ -121,16 +124,15 @@ function sanitizeDailyEvent(dailyEvent, projectIdStr, nowSec) { /** * Prefer midnight of corrected lastRepetitionTime when grouping was also bad, * so day buckets stay consistent with the event time we expose. + * Always use already-normalized safe* values — never raw ms. */ const groupingNeedsFix = isUnsafeUnixTimestamp(dailyEvent && dailyEvent.groupingTimestamp, nowSec); const lastRepetitionNeedsFix = isUnsafeUnixTimestamp(dailyEvent && dailyEvent.lastRepetitionTime, nowSec); const correctedGroupingTimestamp = groupingNeedsFix ? utcMidnightUnix( - lastRepetitionNeedsFix + typeof safeLastRepetitionTime === 'number' ? safeLastRepetitionTime - : (typeof dailyEvent.lastRepetitionTime === 'number' - ? dailyEvent.lastRepetitionTime - : safeGroupingTimestamp) + : safeGroupingTimestamp ) : safeGroupingTimestamp; @@ -234,11 +236,15 @@ function sanitizeDailyEvent(dailyEvent, projectIdStr, nowSec) { * dailyEventsPortion — title/backtrace hygiene plus Int overflow conversion * for legacy far-future Sentry timestamps. Safe to delete once those docs age out. * + * Note: converting nextCursor can skip remaining legacy rows on later pages + * (factory matches raw Mongo fields). Acceptable trade-off vs aggregation cost. + * * @param {object} dailyEventsPortion - portion returned by events factory * @param {string|ObjectId} projectId - project id for logs + * @param {string|undefined} sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS * @returns {object} */ -function sanitizeDailyEventsPortion(dailyEventsPortion, projectId) { +function sanitizeDailyEventsPortion(dailyEventsPortion, projectId, sort) { if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) { return dailyEventsPortion; } @@ -249,6 +255,7 @@ function sanitizeDailyEventsPortion(dailyEventsPortion, projectId) { dailyEventsPortion.nextCursor = sanitizeDailyEventsCursor( dailyEventsPortion.nextCursor, projectIdStr, + sort, nowSec ); @@ -855,7 +862,7 @@ module.exports = { assignee ); - return sanitizeDailyEventsPortion(dailyEventsPortion, project._id); + return sanitizeDailyEventsPortion(dailyEventsPortion, project._id, sort); }, /** diff --git a/src/utils/graphqlIntSafe.js b/src/utils/graphqlIntSafe.js index ee68e0ab..b1c4b5ca 100644 --- a/src/utils/graphqlIntSafe.js +++ b/src/utils/graphqlIntSafe.js @@ -23,9 +23,15 @@ const MAX_PAST_SEC = 10 * 365.25 * 24 * 60 * 60; const UNIX_MS_THRESHOLD = 1e12; /** - * Counts rarely reach 1e9; unix seconds for modern dates do. + * GraphQL / factory sort modes that use a unix timestamp as sortValueBoundary. */ -const TIMESTAMP_SORT_VALUE_THRESHOLD = 1e9; +const TIMESTAMP_SORT_MODES = new Set([ + 'BY_DATE', + 'lastRepetitionTime', + undefined, + null, + '', +]); /** * @param {*} value @@ -79,6 +85,22 @@ function utcMidnightUnix(unixSeconds) { return Math.floor(date.getTime() / 1000); } +/** + * Normalize a stored timestamp to unix seconds (ms → sec). Does not range-check. + * + * @param {number} value + * @returns {number} + */ +function normalizeUnixSeconds(value) { + let ts = Math.trunc(value); + + if (ts > UNIX_MS_THRESHOLD) { + ts = Math.floor(ts / 1000); + } + + return ts; +} + /** * Clamp any number into GraphQL Int range. * @@ -121,12 +143,7 @@ function toSafeUnixTimestampForGraphQLInt(value, fallbackId, nowSec = Math.floor const fromOid = unixSecondsFromObjectId(fallbackId); const fallback = fromOid != null ? fromOid : Math.min(nowSec, GRAPHQL_INT_MAX); - - let ts = Math.trunc(value); - - if (ts > UNIX_MS_THRESHOLD) { - ts = Math.floor(ts / 1000); - } + const ts = normalizeUnixSeconds(value); const maxFuture = nowSec + FUTURE_SLACK_SEC; const minPast = nowSec - MAX_PAST_SEC; @@ -153,38 +170,41 @@ function isUnsafeUnixTimestamp(value, nowSec = Math.floor(Date.now() / 1000)) { return false; } - let ts = Math.trunc(value); - - if (ts > UNIX_MS_THRESHOLD) { - ts = Math.floor(ts / 1000); - } - + const ts = normalizeUnixSeconds(value); const maxFuture = nowSec + FUTURE_SLACK_SEC; const minPast = nowSec - MAX_PAST_SEC; + /** + * Normalized seconds differ from the original → ms or non-integer input; + * must not be passed through as a GraphQL Int / into utcMidnightUnix raw. + */ + if (ts !== value) { + return true; + } + return ( ts > GRAPHQL_INT_MAX || ts < GRAPHQL_INT_MIN || ts > maxFuture || - ts < minPast || - !Number.isInteger(value) + ts < minPast ); } /** - * sortValueBoundary may be lastRepetitionTime, count, or affectedUsers. + * sortValueBoundary may be lastRepetitionTime (BY_DATE), count, or affectedUsers. * * @param {*} value * @param {string|object|null|undefined} idBoundary + * @param {string|null|undefined} sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS (or factory field name) * @param {number} [nowSec] - * @returns {number} + * @returns {*} */ -function toSafeSortValueBoundary(value, idBoundary, nowSec = Math.floor(Date.now() / 1000)) { +function toSafeSortValueBoundary(value, idBoundary, sort, nowSec = Math.floor(Date.now() / 1000)) { if (typeof value !== 'number' || !Number.isFinite(value)) { return value; } - if (Math.abs(value) >= TIMESTAMP_SORT_VALUE_THRESHOLD || value > GRAPHQL_INT_MAX || value < GRAPHQL_INT_MIN) { + if (TIMESTAMP_SORT_MODES.has(sort)) { return toSafeUnixTimestampForGraphQLInt(value, idBoundary, nowSec); } @@ -194,10 +214,14 @@ function toSafeSortValueBoundary(value, idBoundary, nowSec = Math.floor(Date.now module.exports = { GRAPHQL_INT_MIN, GRAPHQL_INT_MAX, + FUTURE_SLACK_SEC, + MAX_PAST_SEC, + UNIX_MS_THRESHOLD, isOutOfGraphQLIntRange, isUnsafeUnixTimestamp, unixSecondsFromObjectId, utcMidnightUnix, + normalizeUnixSeconds, toSafeGraphQLInt, toSafeUnixTimestampForGraphQLInt, toSafeSortValueBoundary, diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index 7b16df3a..b4fd39c9 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -11,6 +11,8 @@ jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ import projectResolverModule from '../../src/resolvers/project'; import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +const { GRAPHQL_INT_MAX } = require('../../src/utils/graphqlIntSafe'); + const projectResolver = projectResolverModule as { Project: { dailyEventsPortion: (...args: unknown[]) => Promise; @@ -280,6 +282,9 @@ describe('Project resolver dailyEventsPortion', () => { expect(result.dailyEvents[0].lastRepetitionTime).toBe(expectedTs); expect(result.dailyEvents[0].event.timestamp).toBe(expectedTs); expect(result.dailyEvents[0].event.totalCount).toBe(13692); + /** + * Cursor is converted with the same helpers the factory uses for match/sort. + */ expect(result.nextCursor.groupingTimestampBoundary).toBe(parseInt('6aa82a4f', 16)); expect(result.nextCursor.sortValueBoundary).toBe(parseInt('6aa82a4f', 16)); expect(warnSpy).toHaveBeenCalled(); @@ -287,6 +292,61 @@ describe('Project resolver dailyEventsPortion', () => { warnSpy.mockRestore(); }); + it('should normalize millisecond lastRepetitionTime before utc midnight', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const eventObjectId = '6aa93c9b3a3878cb15936a41'; + const nowSec = Math.floor(Date.now() / 1000); + const lastRepetitionMs = (nowSec - 60) * 1000; + const expectedSeconds = nowSec - 60; + const expectedMidnight = Math.floor(new Date(expectedSeconds * 1000).setUTCHours(0, 0, 0, 0) / 1000); + + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: null, + dailyEvents: [ + { + id: '6aa93c9b9eb65b518e9f8cf0', + count: 1, + affectedUsers: 0, + groupingTimestamp: 2736201600, + lastRepetitionTime: lastRepetitionMs, + event: { + _id: eventObjectId, + originalEventId: '6a217a79db8fff3481881dd4', + totalCount: 1, + timestamp: lastRepetitionMs, + payload: { + title: 'ms timestamp', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const result = await projectResolver.Project.dailyEventsPortion({ _id: 'project-1' }, { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }, {}) as { + dailyEvents: Array<{ + groupingTimestamp: number; + lastRepetitionTime: number; + event: { timestamp: number }; + }>; + }; + + expect(result.dailyEvents[0].lastRepetitionTime).toBe(expectedSeconds); + expect(result.dailyEvents[0].event.timestamp).toBe(expectedSeconds); + expect(result.dailyEvents[0].groupingTimestamp).toBe(expectedMidnight); + expect(result.dailyEvents[0].groupingTimestamp).toBeLessThanOrEqual(GRAPHQL_INT_MAX); + + 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) => { diff --git a/test/utils/graphqlIntSafe.test.ts b/test/utils/graphqlIntSafe.test.ts index 8ba323ab..9e60668f 100644 --- a/test/utils/graphqlIntSafe.test.ts +++ b/test/utils/graphqlIntSafe.test.ts @@ -3,6 +3,7 @@ import '../../src/env-test'; const { GRAPHQL_INT_MAX, isOutOfGraphQLIntRange, + isUnsafeUnixTimestamp, unixSecondsFromObjectId, utcMidnightUnix, toSafeGraphQLInt, @@ -42,8 +43,14 @@ describe('graphqlIntSafe', () => { expect(toSafeUnixTimestampForGraphQLInt(ms, objectId, nowSec)).toBe(nowSec - 120); }); + it('treats integer millisecond timestamps as unsafe', () => { + const ms = (nowSec - 120) * 1000; + expect(isUnsafeUnixTimestamp(ms, nowSec)).toBe(true); + }); + it('keeps reasonable timestamps', () => { expect(toSafeUnixTimestampForGraphQLInt(nowSec - 3600, objectId, nowSec)).toBe(nowSec - 3600); + expect(isUnsafeUnixTimestamp(nowSec - 3600, nowSec)).toBe(false); }); it('builds utc midnight from corrected time', () => { @@ -53,8 +60,10 @@ describe('graphqlIntSafe', () => { expect(midnight % 86400).toBe(0); }); - it('treats large sort boundaries as timestamps', () => { - expect(toSafeSortValueBoundary(2736187957, objectId, nowSec)).toBe(objectIdSec); - expect(toSafeSortValueBoundary(42, objectId, nowSec)).toBe(42); + it('uses timestamp conversion only for BY_DATE sort boundaries', () => { + expect(toSafeSortValueBoundary(2736187957, objectId, 'BY_DATE', nowSec)).toBe(objectIdSec); + expect(toSafeSortValueBoundary(3000000000, objectId, 'BY_COUNT', nowSec)).toBe(GRAPHQL_INT_MAX); + expect(toSafeSortValueBoundary(3000000000, objectId, 'BY_AFFECTED_USERS', nowSec)).toBe(GRAPHQL_INT_MAX); + expect(toSafeSortValueBoundary(42, objectId, 'BY_COUNT', nowSec)).toBe(42); }); });