From c3fd9df1f8685d676b1c75df67781a411954f40c Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Thu, 13 Nov 2025 23:53:46 +0300 Subject: [PATCH 1/8] Expose rate-limited series alongside accepted chart --- src/models/eventsFactory.js | 68 ++++++++++++++++++++++++++------ src/redisHelper.ts | 5 +++ src/services/chartDataService.ts | 10 ++++- src/typeDefs/chart.ts | 15 +++++++ src/typeDefs/project.ts | 2 +- 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 9b27ee4b..91e05bab 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -443,25 +443,52 @@ 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; + 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' + ), + ]); + + const hasAccepted = Array.isArray(acceptedSeries) && acceptedSeries.length > 0; + const hasRateLimited = Array.isArray(rateLimitedSeries) && rateLimitedSeries.length > 0; + + if (hasAccepted || hasRateLimited) { + return { + accepted: acceptedSeries, + rateLimited: hasRateLimited + ? rateLimitedSeries + : this._composeZeroSeries(acceptedSeries), + }; } // Fallback to Mongo (empty groupHash for project-level data) - return this.findChartData(days, timezoneOffset, ''); + const fallbackAccepted = await this.findChartData(days, timezoneOffset, ''); + + return { + accepted: fallbackAccepted, + }; } 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 { + accepted: fallbackAccepted, + }; } } @@ -568,6 +595,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..400f93fe 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: string = '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..c46bbe43 100644 --- a/src/typeDefs/chart.ts +++ b/src/typeDefs/chart.ts @@ -12,4 +12,19 @@ export default gql` """ count: Int } + + """ + Project chart data split by metric type + """ + type ProjectChartSeries { + """ + Accepted events (successfully processed) + """ + accepted: [ChartDataItem!]! + + """ + Events rejected due to rate limiting + """ + rateLimited: [ChartDataItem!] + } `; diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index f5ce891e..017f1864 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] + ): ProjectChartSeries """ Returns number of unread events """ From 2f95428c160ece7eb22d76ff9aff376d13fcd9ba Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Fri, 14 Nov 2025 00:29:56 +0300 Subject: [PATCH 2/8] new type for chart data --- src/models/eventsFactory.js | 49 ++++++++++++++++++++++++++++--------- src/typeDefs/chart.ts | 12 ++++----- src/typeDefs/project.ts | 2 +- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 91e05bab..e20c0336 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -466,29 +466,54 @@ class EventsFactory extends Factory { const hasRateLimited = Array.isArray(rateLimitedSeries) && rateLimitedSeries.length > 0; if (hasAccepted || hasRateLimited) { - return { - accepted: acceptedSeries, - rateLimited: hasRateLimited - ? rateLimitedSeries - : this._composeZeroSeries(acceptedSeries), - }; + let normalizedAccepted = acceptedSeries; + + if (!hasAccepted && hasRateLimited) { + normalizedAccepted = this._composeZeroSeries(rateLimitedSeries); + } + + const normalizedRateLimited = hasRateLimited + ? rateLimitedSeries + : this._composeZeroSeries(normalizedAccepted); + + const series = [ + { + label: 'accepted', + data: normalizedAccepted, + }, + ]; + + if (normalizedRateLimited.length > 0) { + series.push({ + label: 'rate-limited', + data: normalizedRateLimited, + }); + } + + return series; } // Fallback to Mongo (empty groupHash for project-level data) const fallbackAccepted = await this.findChartData(days, timezoneOffset, ''); - return { - accepted: fallbackAccepted, - }; + return [ + { + label: 'accepted', + data: fallbackAccepted, + }, + ]; } catch (err) { console.error('[EventsFactory] getProjectChartData error:', err); // Fallback to Mongo on error (empty groupHash for project-level data) const fallbackAccepted = await this.findChartData(days, timezoneOffset, ''); - return { - accepted: fallbackAccepted, - }; + return [ + { + label: 'accepted', + data: fallbackAccepted, + }, + ]; } } diff --git a/src/typeDefs/chart.ts b/src/typeDefs/chart.ts index c46bbe43..28e169f0 100644 --- a/src/typeDefs/chart.ts +++ b/src/typeDefs/chart.ts @@ -14,17 +14,17 @@ export default gql` } """ - Project chart data split by metric type + Chart line definition """ - type ProjectChartSeries { + type ChartLine { """ - Accepted events (successfully processed) + Series label (e.g., events-accepted) """ - accepted: [ChartDataItem!]! + label: String! """ - Events rejected due to rate limiting + Data points for the series """ - rateLimited: [ChartDataItem!] + data: [ChartDataItem!]! } `; diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index 017f1864..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 - ): ProjectChartSeries + ): [ChartLine!]! """ Returns number of unread events """ From 54e9aed1bd8f221e2fbde6f505eabd6141e9f0db Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:33:34 +0000 Subject: [PATCH 3/8] Bump version up to 1.2.24 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1600018a..dae3d6b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.23", + "version": "1.2.24", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 15b5c82179dd2f9305f7fac5836eb402b9b5566d Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Fri, 14 Nov 2025 00:49:33 +0300 Subject: [PATCH 4/8] Update eventsFactory.js --- src/models/eventsFactory.js | 45 ++++++++----------------------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index e20c0336..faa56265 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -462,50 +462,19 @@ class EventsFactory extends Factory { ), ]); - const hasAccepted = Array.isArray(acceptedSeries) && acceptedSeries.length > 0; - const hasRateLimited = Array.isArray(rateLimitedSeries) && rateLimitedSeries.length > 0; - - if (hasAccepted || hasRateLimited) { - let normalizedAccepted = acceptedSeries; - - if (!hasAccepted && hasRateLimited) { - normalizedAccepted = this._composeZeroSeries(rateLimitedSeries); - } - - const normalizedRateLimited = hasRateLimited - ? rateLimitedSeries - : this._composeZeroSeries(normalizedAccepted); - - const series = [ - { - label: 'accepted', - data: normalizedAccepted, - }, - ]; - - if (normalizedRateLimited.length > 0) { - series.push({ - label: 'rate-limited', - data: normalizedRateLimited, - }); - } - - return series; - } - - // Fallback to Mongo (empty groupHash for project-level data) - const fallbackAccepted = await this.findChartData(days, timezoneOffset, ''); - return [ { label: 'accepted', - data: fallbackAccepted, + data: acceptedSeries, + }, + { + label: 'rate-limited', + data: rateLimitedSeries, }, ]; } catch (err) { console.error('[EventsFactory] getProjectChartData error:', err); - // Fallback to Mongo on error (empty groupHash for project-level data) const fallbackAccepted = await this.findChartData(days, timezoneOffset, ''); return [ @@ -513,6 +482,10 @@ class EventsFactory extends Factory { label: 'accepted', data: fallbackAccepted, }, + { + label: 'rate-limited', + data: this._composeZeroSeries(fallbackAccepted), + }, ]; } } From 55480b86ec82c87a8b382401700d9eff337f732d Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Fri, 14 Nov 2025 00:56:49 +0300 Subject: [PATCH 5/8] Update chartDataService.ts --- src/services/chartDataService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/chartDataService.ts b/src/services/chartDataService.ts index 400f93fe..808ce862 100644 --- a/src/services/chartDataService.ts +++ b/src/services/chartDataService.ts @@ -29,7 +29,7 @@ export default class ChartDataService { endDate: string, groupBy: number, timezoneOffset = 0, - metricType: string = 'events-accepted' + metricType = 'events-accepted' ): Promise<{ timestamp: number; count: number }[]> { // Check if Redis is connected if (!this.redisHelper.isConnected()) { From 37e17117c4f794508022120d0bf8b3acede49057 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Fri, 14 Nov 2025 01:14:34 +0300 Subject: [PATCH 6/8] change response in getEventDailyChart --- src/models/eventsFactory.js | 9 ++++++++- src/typeDefs/event.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index faa56265..b1518d1e 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -499,7 +499,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: 'accepted', + data, + }, + ]; } /** diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c1f4ec77..e9851d33 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -290,7 +290,7 @@ type Event { User's local timezone offset in minutes """ timezoneOffset: Int! = 0 - ): [ChartDataItem!]! + ): [ChartLine!]! } """ From bf110e4ebdf2ebaef4030f02cdb513707d737db1 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:14:44 +0000 Subject: [PATCH 7/8] Bump version up to 1.2.27 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From 0966c88967ff610cf85ae5348d597c555390ab71 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 26 Nov 2025 22:35:42 +0300 Subject: [PATCH 8/8] create ChartType enum --- src/models/eventsFactory.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index e24cc16e..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 */ @@ -470,11 +478,11 @@ class EventsFactory extends Factory { return [ { - label: 'accepted', + label: ChartType.Accepted, data: acceptedSeries, }, { - label: 'rate-limited', + label: ChartType.RateLimited, data: rateLimitedSeries, }, ]; @@ -485,11 +493,11 @@ class EventsFactory extends Factory { return [ { - label: 'accepted', + label: ChartType.Accepted, data: fallbackAccepted, }, { - label: 'rate-limited', + label: ChartType.RateLimited, data: this._composeZeroSeries(fallbackAccepted), }, ]; @@ -509,7 +517,7 @@ class EventsFactory extends Factory { return [ { - label: 'accepted', + label: ChartType.Accepted, data, }, ];