diff --git a/package.json b/package.json index 6c7fef04..1eee8f71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.26", + "version": "1.2.27", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 91d3d6a1..383387be 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -12,6 +12,14 @@ const { composeEventPayloadByRepetition } = require('../utils/merge'); const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE); +/** + * Chart series labels + */ +const ChartType = { + Accepted: 'accepted', + RateLimited: 'rate-limited', +}; + /** * @typedef {import('mongodb').UpdateWriteOpResult} UpdateWriteOpResult */ @@ -449,25 +457,50 @@ class EventsFactory extends Factory { 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, ''); + const [acceptedSeries, rateLimitedSeries] = await Promise.all([ + this.chartDataService.getProjectChartData( + projectId, + startDate, + endDate, + groupBy, + timezoneOffset, + 'events-accepted' + ), + this.chartDataService.getProjectChartData( + projectId, + startDate, + endDate, + groupBy, + timezoneOffset, + 'events-rate-limited' + ), + ]); + + return [ + { + label: ChartType.Accepted, + data: acceptedSeries, + }, + { + label: ChartType.RateLimited, + data: rateLimitedSeries, + }, + ]; } catch (err) { console.error('[EventsFactory] getProjectChartData error:', err); - // Fallback to Mongo on error (empty groupHash for project-level data) - return this.findChartData(days, timezoneOffset, ''); + const fallbackAccepted = await this.findChartData(days, timezoneOffset, ''); + + return [ + { + label: ChartType.Accepted, + data: fallbackAccepted, + }, + { + label: ChartType.RateLimited, + data: this._composeZeroSeries(fallbackAccepted), + }, + ]; } } @@ -480,7 +513,14 @@ class EventsFactory extends Factory { * @returns {Promise} */ async getEventDailyChart(groupHash, days, timezoneOffset = 0) { - return this.findChartData(days, timezoneOffset, groupHash); + const data = await this.findChartData(days, timezoneOffset, groupHash); + + return [ + { + label: ChartType.Accepted, + data, + }, + ]; } /** @@ -574,6 +614,23 @@ class EventsFactory extends Factory { return result; } + /** + * Compose zero-filled chart series using timestamps from the provided template + * + * @param {Array<{timestamp: number, count: number}>} template - reference series for timestamps + * @returns {Array<{timestamp: number, count: number}>} + */ + _composeZeroSeries(template = []) { + if (!Array.isArray(template) || template.length === 0) { + return []; + } + + return template.map((point) => ({ + timestamp: point.timestamp, + count: 0, + })); + } + /** * Returns number of documents that occurred after the last visit time * diff --git a/src/redisHelper.ts b/src/redisHelper.ts index b8c2b585..82e64ef0 100644 --- a/src/redisHelper.ts +++ b/src/redisHelper.ts @@ -36,6 +36,7 @@ export default class RedisHelper { constructor() { if (!process.env.REDIS_URL) { console.warn('[Redis] REDIS_URL not set, Redis features will be disabled'); + return; } @@ -49,7 +50,9 @@ export default class RedisHelper { * Max wait time: 30 seconds */ const delay = Math.min(retries * 1000, 30000); + console.log(`[Redis] Reconnecting... attempt ${retries}, waiting ${delay}ms`); + return delay; }, }, @@ -93,6 +96,7 @@ export default class RedisHelper { if (!RedisHelper.instance) { RedisHelper.instance = new RedisHelper(); } + return RedisHelper.instance; } @@ -102,6 +106,7 @@ export default class RedisHelper { public async initialize(): Promise { if (!this.redisClient) { console.warn('[Redis] Client not initialized, skipping connection'); + return; } diff --git a/src/services/chartDataService.ts b/src/services/chartDataService.ts index ff7dc21f..808ce862 100644 --- a/src/services/chartDataService.ts +++ b/src/services/chartDataService.ts @@ -19,6 +19,7 @@ export default class ChartDataService { * @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) + * @param metricType - Redis metric type suffix (e.g., 'events-accepted', 'events-rate-limited') * @returns Array of data points with timestamp and count * @throws Error if Redis is not connected (caller should fallback to MongoDB) */ @@ -27,7 +28,8 @@ export default class ChartDataService { startDate: string, endDate: string, groupBy: number, - timezoneOffset = 0 + timezoneOffset = 0, + metricType = 'events-accepted' ): Promise<{ timestamp: number; count: number }[]> { // Check if Redis is connected if (!this.redisHelper.isConnected()) { @@ -37,7 +39,7 @@ export default class ChartDataService { // Determine granularity and compose key const granularity = getTimeSeriesSuffix(groupBy); - const key = composeProjectMetricsKey(granularity, projectId); + const key = composeProjectMetricsKey(granularity, projectId, metricType); // Parse ISO date strings to milliseconds const start = new Date(startDate).getTime(); @@ -46,6 +48,7 @@ export default class ChartDataService { // Fetch data from Redis let result: TsRangeResult[] = []; + try { result = await this.redisHelper.tsRange( key, @@ -65,8 +68,10 @@ export default class ChartDataService { // Transform data from Redis const dataPoints: { [ts: number]: number } = {}; + for (const [tsStr, valStr] of result) { const tsMs = Number(tsStr); + dataPoints[tsMs] = Number(valStr) || 0; } @@ -79,6 +84,7 @@ export default class ChartDataService { while (current <= end) { const count = dataPoints[current] || 0; + filled.push({ timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000), count, diff --git a/src/typeDefs/chart.ts b/src/typeDefs/chart.ts index 1224f1eb..28e169f0 100644 --- a/src/typeDefs/chart.ts +++ b/src/typeDefs/chart.ts @@ -12,4 +12,19 @@ export default gql` """ count: Int } + + """ + Chart line definition + """ + type ChartLine { + """ + Series label (e.g., events-accepted) + """ + label: String! + + """ + Data points for the series + """ + data: [ChartDataItem!]! + } `; diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index 2bc0bd9b..a82b6762 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -295,7 +295,7 @@ type Event { User's local timezone offset in minutes """ timezoneOffset: Int! = 0 - ): [ChartDataItem!]! + ): [ChartLine!]! } """ diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index f5ce891e..8ee70f92 100644 --- a/src/typeDefs/project.ts +++ b/src/typeDefs/project.ts @@ -372,7 +372,7 @@ type Project { User's local timezone offset in minutes """ timezoneOffset: Int! = 0 - ): [ChartDataItem] + ): [ChartLine!]! """ Returns number of unread events """