Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 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.22",
"version": "1.2.23",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -28,6 +28,7 @@
"jest": "^26.2.2",
"mongodb-memory-server": "^6.6.1",
"nodemon": "^2.0.2",
"redis-mock": "^0.56.3",
"ts-jest": "^26.1.4",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
Expand Down Expand Up @@ -82,6 +83,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
22 changes: 8 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { graphqlUploadExpress } from 'graphql-upload';
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 @@ -148,6 +149,7 @@ class HawkAPI {
/**
* Creates factories to work with models
* @param dataLoaders - dataLoaders for fetching data form database
* @returns factories object
*/
private static setupFactories(dataLoaders: DataLoaders): ContextFactories {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand Down Expand Up @@ -212,23 +214,9 @@ class HawkAPI {

/**
* Initializing accounting SDK
*/
let tlsVerify;

/**
* Checking env variables
* If at least one path is not transmitted, the variable tlsVerify is undefined
*/
if (
![process.env.TLS_CA_CERT, process.env.TLS_CERT, process.env.TLS_KEY].some(value => value === undefined || value.length === 0)
) {
tlsVerify = {
tlsCaCertPath: `${process.env.TLS_CA_CERT}`,
tlsCertPath: `${process.env.TLS_CERT}`,
tlsKeyPath: `${process.env.TLS_KEY}`,
};
}

/*
* const accounting = new Accounting({
* baseURL: `${process.env.CODEX_ACCOUNTING_URL}`,
Expand All @@ -252,6 +240,12 @@ class HawkAPI {
public async start(): Promise<void> {
await mongo.setupConnections();
await rabbitmq.setupConnections();

// Initialize Redis singleton with auto-reconnect
const redis = RedisHelper.getInstance();

await redis.initialize();

await this.server.start();
this.app.use(graphqlUploadExpress());
this.server.applyMiddleware({ app: this.app });
Expand Down
65 changes: 64 additions & 1 deletion src/models/eventsFactory.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates';
import safe from 'safe-regex';
import { createProjectEventsByIdLoader } from '../dataLoaders';
import RedisHelper from '../redisHelper';
import ChartDataService from '../services/chartDataService';

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

/**
* Creates Event instance
* @param {ObjectId} projectId - project ID
* @param {ObjectId} projectId
*/
constructor(projectId) {
super();

/**
* Redis helper instance (singleton)
*/
this.redis = RedisHelper.getInstance();

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

if (!projectId) {
throw new Error('Can not construct Event model, because projectId is not provided');
}
Expand Down Expand Up @@ -414,6 +426,57 @@ 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
168 changes: 168 additions & 0 deletions src/redisHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import HawkCatcher from '@hawk.so/nodejs';
import { createClient, RedisClientType } from 'redis';

// eslint call error: 0:0 error Parsing error: Cannot read properties of undefined (reading 'map')
// export type TsRangeResult = [timestamp: string, value: string];
export type TsRangeResult = any;

/**
* 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 | null = null;

/**
* 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() {
if (!process.env.REDIS_URL) {
console.warn('[Redis] REDIS_URL not set, Redis features will be disabled');
return;
}

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);
HawkCatcher.send(error as Error);
this.redisClient = null;
}
}

/**
* 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> {
if (!this.redisClient) {
console.warn('[Redis] Client not initialized, skipping connection');
return;
}

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 Boolean(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<TsRangeResult[]> {
if (!this.redisClient) {
throw new Error('Redis client not initialized');
}

return (await this.redisClient.sendCommand([
'TS.RANGE',
key,
start,
end,
'AGGREGATION',
aggregationType,
bucketMs,
])) as TsRangeResult[];
}
}
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 @@ -493,10 +493,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
Loading
Loading