Skip to content

Commit 5907e7a

Browse files
authored
Merge pull request #576 from codex-team/feature/redis-timeseries-helper
Add Redis TS helper and integrate chart data retrieval
2 parents 487a5a3 + 029fb48 commit 5907e7a

File tree

14 files changed

+546
-24
lines changed

14 files changed

+546
-24
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.2.22",
3+
"version": "1.2.23",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {
@@ -28,6 +28,7 @@
2828
"jest": "^26.2.2",
2929
"mongodb-memory-server": "^6.6.1",
3030
"nodemon": "^2.0.2",
31+
"redis-mock": "^0.56.3",
3132
"ts-jest": "^26.1.4",
3233
"ts-node": "^10.9.1",
3334
"typescript": "^4.7.4"
@@ -82,6 +83,7 @@
8283
"mongodb": "^3.7.3",
8384
"morgan": "^1.10.1",
8485
"prom-client": "^15.1.3",
86+
"redis": "^4.7.0",
8587
"safe-regex": "^2.1.0",
8688
"ts-node-dev": "^2.0.0",
8789
"uuid": "^8.3.2"

src/index.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { graphqlUploadExpress } from 'graphql-upload';
2929
import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics';
3030
import { requestLogger } from './utils/logger';
3131
import ReleasesFactory from './models/releasesFactory';
32+
import RedisHelper from './redisHelper';
3233

3334
/**
3435
* Option to enable playground
@@ -148,6 +149,7 @@ class HawkAPI {
148149
/**
149150
* Creates factories to work with models
150151
* @param dataLoaders - dataLoaders for fetching data form database
152+
* @returns factories object
151153
*/
152154
private static setupFactories(dataLoaders: DataLoaders): ContextFactories {
153155
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -212,23 +214,9 @@ class HawkAPI {
212214

213215
/**
214216
* Initializing accounting SDK
215-
*/
216-
let tlsVerify;
217-
218-
/**
219217
* Checking env variables
220218
* If at least one path is not transmitted, the variable tlsVerify is undefined
221219
*/
222-
if (
223-
![process.env.TLS_CA_CERT, process.env.TLS_CERT, process.env.TLS_KEY].some(value => value === undefined || value.length === 0)
224-
) {
225-
tlsVerify = {
226-
tlsCaCertPath: `${process.env.TLS_CA_CERT}`,
227-
tlsCertPath: `${process.env.TLS_CERT}`,
228-
tlsKeyPath: `${process.env.TLS_KEY}`,
229-
};
230-
}
231-
232220
/*
233221
* const accounting = new Accounting({
234222
* baseURL: `${process.env.CODEX_ACCOUNTING_URL}`,
@@ -252,6 +240,12 @@ class HawkAPI {
252240
public async start(): Promise<void> {
253241
await mongo.setupConnections();
254242
await rabbitmq.setupConnections();
243+
244+
// Initialize Redis singleton with auto-reconnect
245+
const redis = RedisHelper.getInstance();
246+
247+
await redis.initialize();
248+
255249
await this.server.start();
256250
this.app.use(graphqlUploadExpress());
257251
this.server.applyMiddleware({ app: this.app });

src/models/eventsFactory.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates';
22
import safe from 'safe-regex';
33
import { createProjectEventsByIdLoader } from '../dataLoaders';
4+
import RedisHelper from '../redisHelper';
5+
import ChartDataService from '../services/chartDataService';
46

57
const Factory = require('./modelFactory');
68
const mongo = require('../mongo');
@@ -85,11 +87,21 @@ class EventsFactory extends Factory {
8587

8688
/**
8789
* Creates Event instance
88-
* @param {ObjectId} projectId - project ID
90+
* @param {ObjectId} projectId
8991
*/
9092
constructor(projectId) {
9193
super();
9294

95+
/**
96+
* Redis helper instance (singleton)
97+
*/
98+
this.redis = RedisHelper.getInstance();
99+
100+
/**
101+
* Chart data service for fetching data from Redis TimeSeries
102+
*/
103+
this.chartDataService = new ChartDataService(this.redis);
104+
93105
if (!projectId) {
94106
throw new Error('Can not construct Event model, because projectId is not provided');
95107
}
@@ -414,6 +426,57 @@ class EventsFactory extends Factory {
414426
};
415427
}
416428

429+
/**
430+
* Get project chart data from Redis or fallback to MongoDB
431+
*
432+
* @param {string} projectId - project ID
433+
* @param {string} startDate - start date (ISO string)
434+
* @param {string} endDate - end date (ISO string)
435+
* @param {number} groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day)
436+
* @param {number} timezoneOffset - user's local timezone offset in minutes
437+
* @returns {Promise<Array>}
438+
*/
439+
async getProjectChartData(projectId, startDate, endDate, groupBy = 60, timezoneOffset = 0) {
440+
// Calculate days for MongoDB fallback
441+
const start = new Date(startDate).getTime();
442+
const end = new Date(endDate).getTime();
443+
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
444+
445+
try {
446+
const redisData = await this.chartDataService.getProjectChartData(
447+
projectId,
448+
startDate,
449+
endDate,
450+
groupBy,
451+
timezoneOffset
452+
);
453+
454+
if (redisData && redisData.length > 0) {
455+
return redisData;
456+
}
457+
458+
// Fallback to Mongo (empty groupHash for project-level data)
459+
return this.findChartData(days, timezoneOffset, '');
460+
} catch (err) {
461+
console.error('[EventsFactory] getProjectChartData error:', err);
462+
463+
// Fallback to Mongo on error (empty groupHash for project-level data)
464+
return this.findChartData(days, timezoneOffset, '');
465+
}
466+
}
467+
468+
/**
469+
* Get event daily chart data from MongoDB only
470+
*
471+
* @param {string} groupHash - event's group hash
472+
* @param {number} days - how many days to fetch
473+
* @param {number} timezoneOffset - user's local timezone offset in minutes
474+
* @returns {Promise<Array>}
475+
*/
476+
async getEventDailyChart(groupHash, days, timezoneOffset = 0) {
477+
return this.findChartData(days, timezoneOffset, groupHash);
478+
}
479+
417480
/**
418481
* Fetch timestamps and total count of errors (or target error) for each day since
419482
*

src/redisHelper.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import HawkCatcher from '@hawk.so/nodejs';
2+
import { createClient, RedisClientType } from 'redis';
3+
4+
// eslint call error: 0:0 error Parsing error: Cannot read properties of undefined (reading 'map')
5+
// export type TsRangeResult = [timestamp: string, value: string];
6+
export type TsRangeResult = any;
7+
8+
/**
9+
* Helper class for working with Redis
10+
*/
11+
export default class RedisHelper {
12+
/**
13+
* TTL for lock records in Redis (in seconds)
14+
*/
15+
private static readonly LOCK_TTL = 10;
16+
17+
/**
18+
* Singleton instance
19+
*/
20+
private static instance: RedisHelper | null = null;
21+
22+
/**
23+
* Redis client instance
24+
*/
25+
private redisClient: RedisClientType | null = null;
26+
27+
/**
28+
* Flag to track if we're currently reconnecting
29+
*/
30+
private isReconnecting = false;
31+
32+
/**
33+
* Constructor
34+
* Initializes the Redis client and sets up error handling with auto-reconnect
35+
*/
36+
constructor() {
37+
if (!process.env.REDIS_URL) {
38+
console.warn('[Redis] REDIS_URL not set, Redis features will be disabled');
39+
return;
40+
}
41+
42+
try {
43+
this.redisClient = createClient({
44+
url: process.env.REDIS_URL,
45+
socket: {
46+
reconnectStrategy: (retries) => {
47+
/*
48+
* Exponential backoff: wait longer between each retry
49+
* Max wait time: 30 seconds
50+
*/
51+
const delay = Math.min(retries * 1000, 30000);
52+
console.log(`[Redis] Reconnecting... attempt ${retries}, waiting ${delay}ms`);
53+
return delay;
54+
},
55+
},
56+
});
57+
58+
// Handle connection errors
59+
this.redisClient.on('error', (error) => {
60+
console.error('[Redis] Client error:', error);
61+
if (error) {
62+
HawkCatcher.send(error);
63+
}
64+
});
65+
66+
// Handle successful reconnection
67+
this.redisClient.on('ready', () => {
68+
console.log('[Redis] Client ready');
69+
this.isReconnecting = false;
70+
});
71+
72+
// Handle reconnecting event
73+
this.redisClient.on('reconnecting', () => {
74+
console.log('[Redis] Client reconnecting...');
75+
this.isReconnecting = true;
76+
});
77+
78+
// Handle connection end
79+
this.redisClient.on('end', () => {
80+
console.log('[Redis] Connection ended');
81+
});
82+
} catch (error) {
83+
console.error('[Redis] Error creating client:', error);
84+
HawkCatcher.send(error as Error);
85+
this.redisClient = null;
86+
}
87+
}
88+
89+
/**
90+
* Get singleton instance
91+
*/
92+
public static getInstance(): RedisHelper {
93+
if (!RedisHelper.instance) {
94+
RedisHelper.instance = new RedisHelper();
95+
}
96+
return RedisHelper.instance;
97+
}
98+
99+
/**
100+
* Connect to Redis
101+
*/
102+
public async initialize(): Promise<void> {
103+
if (!this.redisClient) {
104+
console.warn('[Redis] Client not initialized, skipping connection');
105+
return;
106+
}
107+
108+
try {
109+
if (!this.redisClient.isOpen && !this.isReconnecting) {
110+
await this.redisClient.connect();
111+
console.log('[Redis] Connected successfully');
112+
}
113+
} catch (error) {
114+
console.error('[Redis] Connection failed:', error);
115+
HawkCatcher.send(error as Error);
116+
// Don't throw - let reconnectStrategy handle it
117+
}
118+
}
119+
120+
/**
121+
* Close Redis client
122+
*/
123+
public async close(): Promise<void> {
124+
if (this.redisClient?.isOpen) {
125+
await this.redisClient.quit();
126+
console.log('[Redis] Connection closed');
127+
}
128+
}
129+
130+
/**
131+
* Check if Redis is connected
132+
*/
133+
public isConnected(): boolean {
134+
return Boolean(this.redisClient?.isOpen);
135+
}
136+
137+
/**
138+
* Execute TS.RANGE command with aggregation
139+
*
140+
* @param key - Redis TimeSeries key
141+
* @param start - start timestamp in milliseconds
142+
* @param end - end timestamp in milliseconds
143+
* @param aggregationType - aggregation type (sum, avg, min, max, etc.)
144+
* @param bucketMs - bucket size in milliseconds
145+
* @returns Array of [timestamp, value] tuples
146+
*/
147+
public async tsRange(
148+
key: string,
149+
start: string,
150+
end: string,
151+
aggregationType: string,
152+
bucketMs: string
153+
): Promise<TsRangeResult[]> {
154+
if (!this.redisClient) {
155+
throw new Error('Redis client not initialized');
156+
}
157+
158+
return (await this.redisClient.sendCommand([
159+
'TS.RANGE',
160+
key,
161+
start,
162+
end,
163+
'AGGREGATION',
164+
aggregationType,
165+
bucketMs,
166+
])) as TsRangeResult[];
167+
}
168+
}

src/resolvers/event.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ module.exports = {
8686
async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) {
8787
const factory = getEventsFactory(context, projectId);
8888

89-
return factory.findChartData(days, timezoneOffset, groupHash);
89+
return factory.getEventDailyChart(groupHash, days, timezoneOffset);
9090
},
9191

9292
/**

src/resolvers/project.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,10 +493,10 @@ module.exports = {
493493
*
494494
* @return {Promise<ProjectChartItem[]>}
495495
*/
496-
async chartData(project, { days, timezoneOffset }, context) {
496+
async chartData(project, { startDate, endDate, groupBy, timezoneOffset }, context) {
497497
const factory = getEventsFactory(context, project._id);
498498

499-
return factory.findChartData(days, timezoneOffset);
499+
return factory.getProjectChartData(project._id, startDate, endDate, groupBy, timezoneOffset);
500500
},
501501

502502
/**

0 commit comments

Comments
 (0)