Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
34 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
ad01c8f
fix eslint in files
pavelzotikov Nov 12, 2025
a2c6efe
merge master
pavelzotikov Nov 12, 2025
a4edc89
update package.json: new version
pavelzotikov Nov 12, 2025
37cab1b
add redis-mock library and fix tests
pavelzotikov Nov 12, 2025
82e0dc0
change version for redis-mock
pavelzotikov Nov 12, 2025
ea05c7d
add redis in integration.test
pavelzotikov Nov 12, 2025
2bfe2b8
fix intergration.test
pavelzotikov Nov 12, 2025
cde06d2
Update src/redisHelper.ts
pavelzotikov Nov 12, 2025
57b8de7
fix for pr comments
pavelzotikov Nov 12, 2025
029fb48
Merge branch 'feature/redis-timeseries-helper' of https://github.com/…
pavelzotikov Nov 12, 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.19",
"version": "1.2.20",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -82,6 +82,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
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics';
import { requestLogger } from './utils/logger';
import ReleasesFactory from './models/releasesFactory';
import RedisHelper from './redisHelper';

/**
* Option to enable playground
Expand Down Expand Up @@ -252,6 +253,11 @@
public async start(): Promise<void> {
await mongo.setupConnections();
await rabbitmq.setupConnections();

Check failure on line 256 in src/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
// Initialize Redis singleton with auto-reconnect
const redis = RedisHelper.getInstance();
await redis.initialize();

Check failure on line 260 in src/index.ts

View workflow job for this annotation

GitHub Actions / ESlint

Trailing spaces not allowed
await this.server.start();
this.app.use(graphqlUploadExpress());
this.server.applyMiddleware({ app: this.app });
Expand Down
65 changes: 65 additions & 0 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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';
import ChartDataService from '../services/chartDataService';
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 +72,16 @@ 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 (singleton)
*/
redis = RedisHelper.getInstance();

/**
* Chart data service for fetching data from Redis TimeSeries
*/
chartDataService = new ChartDataService(this.redis);

/**
* Event types with collections where they stored
* @return {{EVENTS: string, DAILY_EVENTS: string, REPETITIONS: string, RELEASES: string}}
Expand All @@ -94,6 +107,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 @@ -414,6 +429,56 @@ class EventsFactory extends Factory {
};
}

/**
* Get project chart data from Redis or fallback to MongoDB
*
* @param {string} projectId - project ID
* @param {string} startDate - start date (ISO string)
* @param {string} endDate - end date (ISO string)
* @param {number} groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day)
* @param {number} timezoneOffset - user's local timezone offset in minutes
* @returns {Promise<Array>}
*/
async getProjectChartData(projectId, startDate, endDate, groupBy = 60, timezoneOffset = 0) {
// Calculate days for MongoDB fallback
const start = new Date(startDate).getTime();
const end = new Date(endDate).getTime();
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, '');
} catch (err) {
console.error('[EventsFactory] getProjectChartData error:', err);
// Fallback to Mongo on error (empty groupHash for project-level data)
return this.findChartData(days, timezoneOffset, '');
}
}

/**
* Get event daily chart data from MongoDB only
*
* @param {string} groupHash - event's group hash
* @param {number} days - how many days to fetch
* @param {number} timezoneOffset - user's local timezone offset in minutes
* @returns {Promise<Array>}
*/
async getEventDailyChart(groupHash, days, timezoneOffset = 0) {
return this.findChartData(days, timezoneOffset, groupHash);
}

/**
* Fetch timestamps and total count of errors (or target error) for each day since
*
Expand Down
147 changes: 147 additions & 0 deletions src/redisHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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;

/**
* Singleton instance
*/
private static instance: RedisHelper | null = null;

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

/**
* Flag to track if we're currently reconnecting
*/
private isReconnecting = false;

/**
* Constructor
* Initializes the Redis client and sets up error handling with auto-reconnect
*/
constructor() {
try {
this.redisClient = createClient({
url: process.env.REDIS_URL,
socket: {
reconnectStrategy: (retries) => {
// Exponential backoff: wait longer between each retry
// Max wait time: 30 seconds
const delay = Math.min(retries * 1000, 30000);
console.log(`[Redis] Reconnecting... attempt ${retries}, waiting ${delay}ms`);
return delay;
},
},
});

// Handle connection errors
this.redisClient.on('error', (error) => {
console.error('[Redis] Client error:', error);
if (error) {
HawkCatcher.send(error);
}
});

// Handle successful reconnection
this.redisClient.on('ready', () => {
console.log('[Redis] Client ready');
this.isReconnecting = false;
});

// Handle reconnecting event
this.redisClient.on('reconnecting', () => {
console.log('[Redis] Client reconnecting...');
this.isReconnecting = true;
});

// Handle connection end
this.redisClient.on('end', () => {
console.log('[Redis] Connection ended');
});
} catch (error) {
console.error('[Redis] Error creating client:', error);
}
}

/**
* Get singleton instance
*/
public static getInstance(): RedisHelper {
if (!RedisHelper.instance) {
RedisHelper.instance = new RedisHelper();
}
return RedisHelper.instance;
}

/**
* Connect to Redis
*/
public async initialize(): Promise<void> {
try {
if (!this.redisClient.isOpen && !this.isReconnecting) {
await this.redisClient.connect();
console.log('[Redis] Connected successfully');
}
} catch (error) {
console.error('[Redis] Connection failed:', error);
HawkCatcher.send(error as Error);
// Don't throw - let reconnectStrategy handle it
}
}

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

/**
* Check if Redis is connected
*/
public isConnected(): boolean {
return this.redisClient.isOpen;
}

/**
* Execute TS.RANGE command with aggregation
*
* @param key - Redis TimeSeries key
* @param start - start timestamp in milliseconds
* @param end - end timestamp in milliseconds
* @param aggregationType - aggregation type (sum, avg, min, max, etc.)
* @param bucketMs - bucket size in milliseconds
* @returns Array of [timestamp, value] tuples
*/
public async tsRange(
key: string,
start: string,
end: string,
aggregationType: string,
bucketMs: string
): Promise<[string, string][]> {
return (await this.redisClient.sendCommand([
'TS.RANGE',
key,
start,
end,
'AGGREGATION',
aggregationType,
bucketMs,
])) as [string, string][];
}
}
2 changes: 1 addition & 1 deletion src/resolvers/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ module.exports = {
async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) {
const factory = getEventsFactory(context, projectId);

return factory.findChartData(days, timezoneOffset, groupHash);
return factory.getEventDailyChart(groupHash, days, timezoneOffset);
},

/**
Expand Down
4 changes: 2 additions & 2 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,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.getProjectChartData(project._id, startDate, endDate, groupBy, timezoneOffset);
},

/**
Expand Down
92 changes: 92 additions & 0 deletions src/services/chartDataService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import RedisHelper from '../redisHelper';
import { composeTimeSeriesKey, getTimeSeriesSuffix } from '../utils/redisKeys';

/**
* Service for fetching chart data from Redis TimeSeries
*/
export default class ChartDataService {
private redisHelper: RedisHelper;

constructor(redisHelper: RedisHelper) {
this.redisHelper = redisHelper;
}

/**
* Get project chart data from Redis TimeSeries
*
* @param projectId - project ID
* @param startDate - start date as ISO string (e.g., '2025-01-01T00:00:00Z')
* @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)
* @returns Array of data points with timestamp and count
* @throws Error if Redis is not connected (caller should fallback to MongoDB)
*/
public async getProjectChartData(
projectId: string,
startDate: string,
endDate: string,
groupBy: number,
timezoneOffset = 0
): Promise<{ timestamp: number; count: number }[]> {
// Check if Redis is connected
if (!this.redisHelper.isConnected()) {
console.warn('[ChartDataService] Redis not connected, will fallback to MongoDB');
throw new Error('Redis client not connected');
}

// Determine suffix and compose key
const suffix = getTimeSeriesSuffix(groupBy);
const key = composeTimeSeriesKey(suffix, projectId);

// Parse ISO date strings to milliseconds
const start = new Date(startDate).getTime();
const end = new Date(endDate).getTime();
const bucketMs = groupBy * 60 * 1000;

// Fetch data from Redis
let result: [string, string][] = [];
try {
result = await this.redisHelper.tsRange(
key,
start.toString(),
end.toString(),
'sum',
bucketMs.toString()
);
} catch (err: any) {
if (err.message.includes('TSDB: the key does not exist')) {
console.warn(`[ChartDataService] 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);
}
}

Loading
Loading