Skip to content

Commit 04278d2

Browse files
committed
feat(api): implement flexible chart data API with Redis TimeSeries
- Replace days parameter with startDate/endDate/groupBy (minutes) - Add RedisHelper class for TimeSeries operations - Support minutely/hourly/daily aggregations based on groupBy value - Add search parameter support in events queries (title/backtrace/context/addons) - Update GraphQL schema and resolvers for new chart parameters - Fallback to MongoDB when Redis data unavailable
1 parent dbf6845 commit 04278d2

File tree

7 files changed

+113
-95
lines changed

7 files changed

+113
-95
lines changed

src/models/eventsFactory.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -402,20 +402,31 @@ class EventsFactory extends Factory {
402402
};
403403
}
404404

405-
async getChartData(groupingBy = 'hours', rangeValue = 24, timezoneOffset = 0, projectId = '', groupHash = '') {
405+
async getChartData(startDate, endDate, groupBy = 60, timezoneOffset = 0, projectId = '', groupHash = '') {
406406
try {
407-
const redisData = await this.redis.getChartDataFromRedis(groupingBy, rangeValue, timezoneOffset, projectId, groupHash);
407+
const redisData = await this.redis.getChartDataFromRedis(
408+
startDate,
409+
endDate,
410+
groupBy,
411+
timezoneOffset,
412+
projectId,
413+
groupHash
414+
);
408415

409416
if (redisData && redisData.length > 0) {
410417
return redisData;
411418
}
412419

413-
const hours = groupingBy === 'hours' ? rangeValue : Math.max(1, rangeValue) * 24;
414-
const days = Math.max(1, Math.ceil(hours / 24));
420+
// Fallback to Mongo
421+
const start = new Date(startDate).getTime();
422+
const end = new Date(endDate).getTime();
423+
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
415424
return this.findChartData(days, timezoneOffset, groupHash);
416425
} catch (err) {
417-
const hours = groupingBy === 'hours' ? rangeValue : Math.max(1, rangeValue) * 24;
418-
const days = Math.max(1, Math.ceil(hours / 24));
426+
console.error('[EventsFactory] getChartData error:', err);
427+
const start = new Date(startDate).getTime();
428+
const end = new Date(endDate).getTime();
429+
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
419430
return this.findChartData(days, timezoneOffset, groupHash);
420431
}
421432
}

src/models/user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ export default class UserModel extends AbstractModel<UserDBScheme> implements Us
268268
userId: this._id,
269269
},
270270
process.env.JWT_SECRET_ACCESS_TOKEN as Secret,
271-
{ expiresIn: '15m' }
271+
{ expiresIn: '1d' }
272272
);
273273

274274
const refreshToken = await jwt.sign(

src/redisHelper.ts

Lines changed: 72 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -59,94 +59,91 @@ export default class RedisHelper {
5959
}
6060

6161
public async getChartDataFromRedis(
62-
groupingBy: 'hours' | 'days',
63-
rangeValue: number,
62+
startDate: string,
63+
endDate: string,
64+
groupBy: number, // minutes: 1=minute, 60=hour, 1440=day
6465
timezoneOffset = 0,
6566
projectId = '',
6667
groupHash = ''
6768
): Promise<{ timestamp: number; count: number }[]> {
6869
if (!this.redisClient.isOpen) {
6970
throw new Error('Redis client not connected');
7071
}
71-
72-
const suffix = groupingBy === 'hours' ? 'hourly' : 'daily';
72+
73+
// Determine suffix based on groupBy
74+
let suffix: string;
75+
if (groupBy === 1) {
76+
suffix = 'minutely';
77+
} else if (groupBy === 60) {
78+
suffix = 'hourly';
79+
} else if (groupBy === 1440) {
80+
suffix = 'daily';
81+
} else {
82+
// For custom intervals, fallback to minutely with aggregation
83+
suffix = 'minutely';
84+
}
85+
7386
const key = groupHash
7487
? `ts:events:${groupHash}:${suffix}`
7588
: projectId
7689
? `ts:events:${projectId}:${suffix}`
7790
: `ts:events:${suffix}`;
78-
79-
const now = Date.now();
80-
81-
// определяем начало выборки
82-
const fromDate = new Date(now);
83-
if (groupingBy === 'hours') {
84-
fromDate.setMinutes(0, 0, 0);
91+
92+
// Parse dates (support ISO string or Unix timestamp in seconds)
93+
const start = typeof startDate === 'string' && startDate.includes('-')
94+
? new Date(startDate).getTime()
95+
: Number(startDate) * 1000;
96+
const end = typeof endDate === 'string' && endDate.includes('-')
97+
? new Date(endDate).getTime()
98+
: Number(endDate) * 1000;
99+
100+
const bucketMs = groupBy * 60 * 1000;
101+
102+
let result: [string, string][] = [];
103+
try {
104+
// Use aggregation to sum events within each bucket
105+
// Since we now use TS.ADD (not TS.INCRBY), each sample is 1, so SUM gives us count
106+
result = (await this.redisClient.sendCommand([
107+
'TS.RANGE',
108+
key,
109+
start.toString(),
110+
end.toString(),
111+
'AGGREGATION',
112+
'sum',
113+
bucketMs.toString(),
114+
])) as [string, string][] | [];
115+
} catch (err: any) {
116+
if (err.message.includes('TSDB: the key does not exist')) {
117+
console.warn(`[Redis] Key ${key} does not exist, returning zeroed data`);
118+
result = [];
85119
} else {
86-
fromDate.setHours(0, 0, 0, 0);
87-
}
88-
fromDate.setMilliseconds(fromDate.getMilliseconds() - (groupingBy === 'hours' ? rangeValue * 60 * 60 * 1000 : rangeValue * 24 * 60 * 60 * 1000));
89-
const from = fromDate.getTime();
90-
91-
let result: [string, string][] = [];
92-
try {
93-
result = (await this.redisClient.sendCommand([
94-
'TS.RANGE',
95-
key,
96-
from.toString(),
97-
now.toString(),
98-
])) as [string, string][] | [];
99-
} catch (err: any) {
100-
if (err.message.includes('TSDB: the key does not exist')) {
101-
console.warn(`[Redis] Key ${key} does not exist, returning zeroed data`);
102-
result = [];
103-
} else {
104-
throw err;
105-
}
106-
}
107-
108-
// агрегируем события по интервалу
109-
const dataPoints: { [ts: number]: number } = {};
110-
for (const [tsStr] of result) {
111-
const tsMs = Number(tsStr);
112-
const date = new Date(tsMs);
113-
114-
let intervalStart: number;
115-
if (groupingBy === 'hours') {
116-
date.setMinutes(0, 0, 0);
117-
intervalStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
118-
} else {
119-
date.setHours(0, 0, 0, 0);
120-
intervalStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
121-
}
122-
123-
const intervalWithOffset = intervalStart + timezoneOffset * 60 * 1000;
124-
dataPoints[intervalWithOffset] = (dataPoints[intervalWithOffset] || 0) + 1;
120+
throw err;
125121
}
126-
127-
// заполняем пропущенные интервалы нулями
128-
const filled: { timestamp: number; count: number }[] = [];
129-
const nowDate = new Date(now);
130-
131-
for (let i = 0; i < rangeValue; i++) {
132-
const date = new Date(nowDate);
133-
134-
if (groupingBy === 'hours') {
135-
date.setHours(date.getHours() - i, 0, 0, 0);
136-
var intervalStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
137-
} else {
138-
date.setDate(date.getDate() - i);
139-
date.setHours(0, 0, 0, 0);
140-
var intervalStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
141-
}
142-
143-
const intervalWithOffset = intervalStart + timezoneOffset * 60 * 1000;
144-
filled.push({
145-
timestamp: Math.floor(intervalWithOffset / 1000),
146-
count: dataPoints[intervalWithOffset] || 0,
147-
});
148-
}
149-
150-
return filled.sort((a, b) => a.timestamp - b.timestamp);
122+
}
123+
124+
// Transform data from Redis
125+
const dataPoints: { [ts: number]: number } = {};
126+
for (const [tsStr, valStr] of result) {
127+
const tsMs = Number(tsStr);
128+
dataPoints[tsMs] = Number(valStr) || 0;
129+
}
130+
131+
// Fill missing intervals with zeros
132+
const filled: { timestamp: number; count: number }[] = [];
133+
let current = start;
134+
135+
// Round current to the nearest bucket boundary
136+
current = Math.floor(current / bucketMs) * bucketMs;
137+
138+
while (current <= end) {
139+
const count = dataPoints[current] || 0;
140+
filled.push({
141+
timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000),
142+
count,
143+
});
144+
current += bucketMs;
145+
}
146+
147+
return filled.sort((a, b) => a.timestamp - b.timestamp);
151148
}
152149
}

src/resolvers/event.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ module.exports = {
8383
* @param {number} timezoneOffset - user's local timezone offset in minutes
8484
* @returns {Promise<ProjectChartItem[]>}
8585
*/
86-
async chartData({ projectId, groupHash }, { groupingBy, rangeValue, timezoneOffset }, context) {
86+
async chartData({ projectId, groupHash }, { startDate, endDate, groupBy, timezoneOffset }, context) {
8787
const factory = getEventsFactory(context, projectId);
8888

89-
return factory.getChartData(groupingBy, rangeValue, timezoneOffset, projectId, groupHash);
89+
return factory.getChartData(startDate, endDate, groupBy, timezoneOffset, projectId, groupHash);
9090
},
9191

9292
/**

src/resolvers/project.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,10 @@ module.exports = {
465465
*
466466
* @return {Promise<ProjectChartItem[]>}
467467
*/
468-
async chartData(project, { groupingBy, rangeValue, timezoneOffset }, context) {
468+
async chartData(project, { startDate, endDate, groupBy, timezoneOffset }, context) {
469469
const factory = getEventsFactory(context, project._id);
470470

471-
return factory.getChartData(groupingBy, rangeValue, timezoneOffset, project._id);
471+
return factory.getChartData(startDate, endDate, groupBy, timezoneOffset, project._id);
472472
},
473473

474474
/**

src/typeDefs/event.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,18 +278,23 @@ type Event {
278278
usersAffected: Int
279279
280280
"""
281-
Return graph of the error rate for the last few days
281+
Return graph of the error rate for the specified period
282282
"""
283283
chartData(
284284
"""
285-
Grouping mode: 'hours' or 'days'
285+
Start date (ISO string or Unix timestamp in seconds)
286286
"""
287-
groupingBy: String! = "hours"
287+
startDate: String!
288288
289289
"""
290-
Range value: number of hours or days depending on groupingBy
290+
End date (ISO string or Unix timestamp in seconds)
291291
"""
292-
rangeValue: Int! = 0
292+
endDate: String!
293+
294+
"""
295+
Grouping interval in minutes (1=minute, 60=hour, 1440=day)
296+
"""
297+
groupBy: Int! = 60
293298
294299
"""
295300
User's local timezone offset in minutes

src/typeDefs/project.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,14 +289,19 @@ type Project {
289289
"""
290290
chartData(
291291
"""
292-
Grouping mode: 'hours' or 'days'
292+
Start date (ISO string or Unix timestamp in seconds)
293293
"""
294-
groupingBy: String!
294+
startDate: String!
295295
296296
"""
297-
Range value: number of hours or days depending on groupingBy
297+
End date (ISO string or Unix timestamp in seconds)
298298
"""
299-
rangeValue: Int!
299+
endDate: String!
300+
301+
"""
302+
Grouping interval in minutes (1=minute, 60=hour, 1440=day)
303+
"""
304+
groupBy: Int! = 60
300305
301306
"""
302307
User's local timezone offset in minutes

0 commit comments

Comments
 (0)