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.2.26",
"version": "1.2.27",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
91 changes: 74 additions & 17 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ const { composeEventPayloadByRepetition } = require('../utils/merge');

const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);

/**
* Chart series labels
*/
const ChartType = {
Accepted: 'accepted',
RateLimited: 'rate-limited',
};

/**
* @typedef {import('mongodb').UpdateWriteOpResult} UpdateWriteOpResult
*/
Expand Down Expand Up @@ -449,25 +457,50 @@ class EventsFactory extends Factory {
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));

try {
const redisData = await this.chartDataService.getProjectChartData(
projectId,
startDate,
endDate,
groupBy,
timezoneOffset
);

if (redisData && redisData.length > 0) {
return redisData;
}

// Fallback to Mongo (empty groupHash for project-level data)
return this.findChartData(days, timezoneOffset, '');
const [acceptedSeries, rateLimitedSeries] = await Promise.all([
this.chartDataService.getProjectChartData(
projectId,
startDate,
endDate,
groupBy,
timezoneOffset,
'events-accepted'
),
this.chartDataService.getProjectChartData(
projectId,
startDate,
endDate,
groupBy,
timezoneOffset,
'events-rate-limited'
),
]);

return [
{
label: ChartType.Accepted,
data: acceptedSeries,
},
{
label: ChartType.RateLimited,
data: rateLimitedSeries,
},
];
} catch (err) {
console.error('[EventsFactory] getProjectChartData error:', err);

// Fallback to Mongo on error (empty groupHash for project-level data)
return this.findChartData(days, timezoneOffset, '');
const fallbackAccepted = await this.findChartData(days, timezoneOffset, '');

return [
{
label: ChartType.Accepted,
data: fallbackAccepted,
},
{
label: ChartType.RateLimited,
data: this._composeZeroSeries(fallbackAccepted),
},
];
}
}

Expand All @@ -480,7 +513,14 @@ class EventsFactory extends Factory {
* @returns {Promise<Array>}
*/
async getEventDailyChart(groupHash, days, timezoneOffset = 0) {
return this.findChartData(days, timezoneOffset, groupHash);
const data = await this.findChartData(days, timezoneOffset, groupHash);

return [
{
label: ChartType.Accepted,
data,
},
];
}

/**
Expand Down Expand Up @@ -574,6 +614,23 @@ class EventsFactory extends Factory {
return result;
}

/**
* Compose zero-filled chart series using timestamps from the provided template
*
* @param {Array<{timestamp: number, count: number}>} template - reference series for timestamps
* @returns {Array<{timestamp: number, count: number}>}
*/
_composeZeroSeries(template = []) {
if (!Array.isArray(template) || template.length === 0) {
return [];
}

return template.map((point) => ({
timestamp: point.timestamp,
count: 0,
}));
}

/**
* Returns number of documents that occurred after the last visit time
*
Expand Down
5 changes: 5 additions & 0 deletions src/redisHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default class RedisHelper {
constructor() {
if (!process.env.REDIS_URL) {
console.warn('[Redis] REDIS_URL not set, Redis features will be disabled');

return;
}

Expand All @@ -49,7 +50,9 @@ export default class RedisHelper {
* Max wait time: 30 seconds
*/
const delay = Math.min(retries * 1000, 30000);

console.log(`[Redis] Reconnecting... attempt ${retries}, waiting ${delay}ms`);

return delay;
},
},
Expand Down Expand Up @@ -93,6 +96,7 @@ export default class RedisHelper {
if (!RedisHelper.instance) {
RedisHelper.instance = new RedisHelper();
}

return RedisHelper.instance;
}

Expand All @@ -102,6 +106,7 @@ export default class RedisHelper {
public async initialize(): Promise<void> {
if (!this.redisClient) {
console.warn('[Redis] Client not initialized, skipping connection');

return;
}

Expand Down
10 changes: 8 additions & 2 deletions src/services/chartDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default class ChartDataService {
* @param endDate - end date as ISO string (e.g., '2025-01-31T23:59:59Z')
* @param groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day)
* @param timezoneOffset - user's local timezone offset in minutes (default: 0)
* @param metricType - Redis metric type suffix (e.g., 'events-accepted', 'events-rate-limited')
* @returns Array of data points with timestamp and count
* @throws Error if Redis is not connected (caller should fallback to MongoDB)
*/
Expand All @@ -27,7 +28,8 @@ export default class ChartDataService {
startDate: string,
endDate: string,
groupBy: number,
timezoneOffset = 0
timezoneOffset = 0,
metricType = 'events-accepted'
): Promise<{ timestamp: number; count: number }[]> {
// Check if Redis is connected
if (!this.redisHelper.isConnected()) {
Expand All @@ -37,7 +39,7 @@ export default class ChartDataService {

// Determine granularity and compose key
const granularity = getTimeSeriesSuffix(groupBy);
const key = composeProjectMetricsKey(granularity, projectId);
const key = composeProjectMetricsKey(granularity, projectId, metricType);

// Parse ISO date strings to milliseconds
const start = new Date(startDate).getTime();
Expand All @@ -46,6 +48,7 @@ export default class ChartDataService {

// Fetch data from Redis
let result: TsRangeResult[] = [];

try {
result = await this.redisHelper.tsRange(
key,
Expand All @@ -65,8 +68,10 @@ export default class ChartDataService {

// Transform data from Redis
const dataPoints: { [ts: number]: number } = {};

for (const [tsStr, valStr] of result) {
const tsMs = Number(tsStr);

dataPoints[tsMs] = Number(valStr) || 0;
}

Expand All @@ -79,6 +84,7 @@ export default class ChartDataService {

while (current <= end) {
const count = dataPoints[current] || 0;

filled.push({
timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000),
count,
Expand Down
15 changes: 15 additions & 0 deletions src/typeDefs/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,19 @@ export default gql`
"""
count: Int
}

"""
Chart line definition
"""
type ChartLine {
"""
Series label (e.g., events-accepted)
"""
label: String!

"""
Data points for the series
"""
data: [ChartDataItem!]!
}
`;
2 changes: 1 addition & 1 deletion src/typeDefs/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ type Event {
User's local timezone offset in minutes
"""
timezoneOffset: Int! = 0
): [ChartDataItem!]!
): [ChartLine!]!
}

"""
Expand Down
2 changes: 1 addition & 1 deletion src/typeDefs/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ type Project {
User's local timezone offset in minutes
"""
timezoneOffset: Int! = 0
): [ChartDataItem]
): [ChartLine!]!
"""
Returns number of unread events
"""
Expand Down
Loading