Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dafb4cb
feat: add Redis TimeSeries helper with safe increment and auto-creation
pavelzotikov Nov 5, 2025
dbf6845
add grouping mode: 'hours' or 'days'
pavelzotikov Nov 5, 2025
04278d2
feat(api): implement flexible chart data API with Redis TimeSeries
pavelzotikov Nov 5, 2025
2a88509
change expires time for jwt access secret
pavelzotikov Nov 5, 2025
6147a7a
Bump version up to 1.2.14
github-actions[bot] Nov 6, 2025
0599da4
refactor: separate Event and Project chart data APIs
pavelzotikov Nov 7, 2025
a40d522
merge master
pavelzotikov Nov 7, 2025
3af0959
Bump version up to 1.2.20
github-actions[bot] Nov 7, 2025
56281b8
fix: Add Redis auto-reconnect mechanism for Kubernetes pod restarts
pavelzotikov Nov 7, 2025
db9d94c
refactor: Apply PR #576 review comments and improve architecture
pavelzotikov Nov 7, 2025
067bd6a
refactor: Apply PR #576 review comments and improve architecture
pavelzotikov Nov 7, 2025
14134f9
Merge branch 'feature/redis-timeseries-helper' of https://github.com/…
pavelzotikov Nov 7, 2025
1fe3d01
Update redisKeys.ts
pavelzotikov Nov 7, 2025
476378d
refactor: Rename redisKeys.ts to chartStorageKeys.ts
pavelzotikov Nov 7, 2025
e0bf8be
refactor: Rename composeTimeSeriesKey to composeProjectMetricsKey
pavelzotikov Nov 7, 2025
2115a1d
Update chartStorageKeys.ts
pavelzotikov Nov 8, 2025
e8bed5c
Update eventsFactory.js
pavelzotikov Nov 8, 2025
ba0b275
linter
pavelzotikov Nov 8, 2025
5abce3c
Update index.ts
pavelzotikov Nov 8, 2025
9276156
Update eventsFactory.js
pavelzotikov Nov 8, 2025
dec8f36
Merge branch 'master' into feature/redis-timeseries-helper
pavelzotikov Nov 8, 2025
ac754bf
Bump version up to 1.2.21
github-actions[bot] Nov 8, 2025
9a57f76
Update eventsFactory.js
pavelzotikov Nov 8, 2025
db93f62
Update api.env
pavelzotikov Nov 8, 2025
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.2.13",
"version": "1.2.14",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -81,6 +81,7 @@
"mongodb": "^3.7.3",
"morgan": "^1.10.1",
"prom-client": "^15.1.3",
"redis": "^4.7.0",
"safe-regex": "^2.1.0",
"ts-node-dev": "^2.0.0",
"uuid": "^8.3.2"
Expand Down
39 changes: 39 additions & 0 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates';
import safe from 'safe-regex';
import { createProjectEventsByIdLoader } from '../dataLoaders';
import { Effect, sgr } from '../utils/ansi';

const Factory = require('./modelFactory');
const mongo = require('../mongo');
const Event = require('../models/event');
const { ObjectID } = require('mongodb');
import RedisHelper from '../redisHelper';
const { composeEventPayloadByRepetition } = require('../utils/merge');

const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
Expand Down Expand Up @@ -69,6 +71,12 @@ const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
* Factory Class for Event's Model
*/
class EventsFactory extends Factory {
/**
/**
* Redis helper instance for modifying data through redis
*/
redis = new RedisHelper();

/**
* Event types with collections where they stored
* @return {{EVENTS: string, DAILY_EVENTS: string, REPETITIONS: string, RELEASES: string}}
Expand All @@ -94,6 +102,8 @@ class EventsFactory extends Factory {
throw new Error('Can not construct Event model, because projectId is not provided');
}

this.redis.initialize();

this.projectId = projectId;
this.eventsDataLoader = createProjectEventsByIdLoader(mongo.databases.events, this.projectId);
}
Expand Down Expand Up @@ -392,6 +402,35 @@ class EventsFactory extends Factory {
};
}

async getChartData(startDate, endDate, groupBy = 60, timezoneOffset = 0, projectId = '', groupHash = '') {
try {
const redisData = await this.redis.getChartDataFromRedis(
startDate,
endDate,
groupBy,
timezoneOffset,
projectId,
groupHash
);

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

// Fallback to Mongo
const start = new Date(startDate).getTime();
const end = new Date(endDate).getTime();
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
return this.findChartData(days, timezoneOffset, groupHash);
} catch (err) {
console.error('[EventsFactory] getChartData error:', err);
const start = new Date(startDate).getTime();
const end = new Date(endDate).getTime();
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
return this.findChartData(days, timezoneOffset, groupHash);
}
}

/**
* Fetch timestamps and total count of errors (or target error) for each day since
*
Expand Down
149 changes: 149 additions & 0 deletions src/redisHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import HawkCatcher from '@hawk.so/nodejs';
import { createClient, RedisClientType } from 'redis';
import { Effect, sgr } from './utils/ansi';

/**
* Helper class for working with Redis
*/
export default class RedisHelper {
/**
* TTL for lock records in Redis (in seconds)
*/
private static readonly LOCK_TTL = 10;

/**
* Redis client instance
*/
private readonly redisClient!: RedisClientType;

/**
* Constructor
* Initializes the Redis client and sets up error handling
*/
constructor() {
try {
this.redisClient = createClient({ url: process.env.REDIS_URL });

this.redisClient.on('error', (error) => {
console.error('[Redis] Client error:', error);
if (error) {
HawkCatcher.send(error);
}
});
} catch (error) {
console.error('[Redis] Error creating client:', error);
}
}

/**
* Connect to Redis
*/
public async initialize(): Promise<void> {
try {
await this.redisClient.connect();
console.log('[Redis] Connected successfully');
} catch (error) {
console.error('[Redis] Connection failed:', error);
HawkCatcher.send(error as Error);
}
}

/**
* Close Redis client
*/
public async close(): Promise<void> {
if (this.redisClient.isOpen) {
await this.redisClient.quit();
console.log('[Redis] Connection closed');
}
}

public async getChartDataFromRedis(
startDate: string,
endDate: string,
groupBy: number, // minutes: 1=minute, 60=hour, 1440=day
timezoneOffset = 0,
projectId = '',
groupHash = ''
): Promise<{ timestamp: number; count: number }[]> {
if (!this.redisClient.isOpen) {
throw new Error('Redis client not connected');
}

// Determine suffix based on groupBy
let suffix: string;
if (groupBy === 1) {
suffix = 'minutely';
} else if (groupBy === 60) {
suffix = 'hourly';
} else if (groupBy === 1440) {
suffix = 'daily';
} else {
// For custom intervals, fallback to minutely with aggregation
suffix = 'minutely';
}

const key = groupHash
? `ts:events:${groupHash}:${suffix}`
: projectId
? `ts:events:${projectId}:${suffix}`
: `ts:events:${suffix}`;

// Parse dates (support ISO string or Unix timestamp in seconds)
const start = typeof startDate === 'string' && startDate.includes('-')
? new Date(startDate).getTime()
: Number(startDate) * 1000;
const end = typeof endDate === 'string' && endDate.includes('-')
? new Date(endDate).getTime()
: Number(endDate) * 1000;

const bucketMs = groupBy * 60 * 1000;

let result: [string, string][] = [];
try {
// Use aggregation to sum events within each bucket
// Since we now use TS.ADD (not TS.INCRBY), each sample is 1, so SUM gives us count
result = (await this.redisClient.sendCommand([
'TS.RANGE',
key,
start.toString(),
end.toString(),
'AGGREGATION',
'sum',
bucketMs.toString(),
])) as [string, string][] | [];
} catch (err: any) {
if (err.message.includes('TSDB: the key does not exist')) {
console.warn(`[Redis] Key ${key} does not exist, returning zeroed data`);
result = [];
} else {
throw err;
}
}

// Transform data from Redis
const dataPoints: { [ts: number]: number } = {};
for (const [tsStr, valStr] of result) {
const tsMs = Number(tsStr);
dataPoints[tsMs] = Number(valStr) || 0;
}

// Fill missing intervals with zeros
const filled: { timestamp: number; count: number }[] = [];
let current = start;

// Round current to the nearest bucket boundary
current = Math.floor(current / bucketMs) * bucketMs;

while (current <= end) {
const count = dataPoints[current] || 0;
filled.push({
timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000),
count,
});
current += bucketMs;
}

return filled.sort((a, b) => a.timestamp - b.timestamp);
}
}
4 changes: 2 additions & 2 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ module.exports = {
* @param {number} timezoneOffset - user's local timezone offset in minutes
* @returns {Promise<ProjectChartItem[]>}
*/
async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) {
async chartData({ projectId, groupHash }, { startDate, endDate, groupBy, timezoneOffset }, context) {
const factory = getEventsFactory(context, projectId);

return factory.findChartData(days, timezoneOffset, groupHash);
return factory.getChartData(startDate, endDate, groupBy, timezoneOffset, projectId, groupHash);
},

/**
Expand Down
4 changes: 2 additions & 2 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -465,10 +465,10 @@ module.exports = {
*
* @return {Promise<ProjectChartItem[]>}
*/
async chartData(project, { days, timezoneOffset }, context) {
async chartData(project, { startDate, endDate, groupBy, timezoneOffset }, context) {
const factory = getEventsFactory(context, project._id);

return factory.findChartData(days, timezoneOffset);
return factory.getChartData(startDate, endDate, groupBy, timezoneOffset, project._id);
},

/**
Expand Down
16 changes: 13 additions & 3 deletions src/typeDefs/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,23 @@ type Event {
usersAffected: Int

"""
Return graph of the error rate for the last few days
Return graph of the error rate for the specified period
"""
chartData(
"""
How many days we need to fetch for displaying in a chart
Start date (ISO string or Unix timestamp in seconds)
"""
days: Int! = 0
startDate: String!

"""
End date (ISO string or Unix timestamp in seconds)
"""
endDate: String!

"""
Grouping interval in minutes (1=minute, 60=hour, 1440=day)
"""
groupBy: Int! = 60

"""
User's local timezone offset in minutes
Expand Down
14 changes: 12 additions & 2 deletions src/typeDefs/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,19 @@ type Project {
"""
chartData(
"""
How many days we need to fetch for displaying in a chart
Start date (ISO string or Unix timestamp in seconds)
"""
days: Int! = 0
startDate: String!

"""
End date (ISO string or Unix timestamp in seconds)
"""
endDate: String!

"""
Grouping interval in minutes (1=minute, 60=hour, 1440=day)
"""
groupBy: Int! = 60

"""
User's local timezone offset in minutes
Expand Down
Loading