Skip to content
Merged
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
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.14",
"version": "1.5.15",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
259 changes: 223 additions & 36 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,56 +24,243 @@ 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.
* Factory still matches raw Mongo boundaries — converted cursors may skip
* leftover legacy rows on later pages (see sanitizeDailyEventsPortion note).
*
* @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 {string|undefined} sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS
* @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, sort, 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,
sort,
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,
sort,
before: {
groupingTimestampBoundary: cursor.groupingTimestampBoundary,
sortValueBoundary: cursor.sortValueBoundary,
},
after: {
groupingTimestampBoundary: safeGrouping,
sortValueBoundary: safeSort,
},
});

return {
...cursor,
groupingTimestampBoundary: safeGrouping,
sortValueBoundary: safeSort,
Comment thread
neSpecc marked this conversation as resolved.
};
}

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.
* 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(
typeof safeLastRepetitionTime === 'number'
? safeLastRepetitionTime
: 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.
*
* 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, sort) {
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,
sort,
nowSec
);

dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => {
return sanitizeDailyEvent(dailyEvent, projectIdStr, nowSec);
});

return dailyEventsPortion;
Expand Down Expand Up @@ -675,7 +862,7 @@ module.exports = {
assignee
);

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

/**
Expand Down
Loading
Loading