Skip to content

Commit 01b2c7f

Browse files
authored
Merge pull request #562 from codex-team/feat/rate-limits-settings
Feat/rate limits settings
2 parents 6762347 + b53e191 commit 01b2c7f

File tree

13 files changed

+332
-55
lines changed

13 files changed

+332
-55
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.2.4",
3+
"version": "1.2.7",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/index.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import PlansFactory from './models/plansFactory';
2626
import BusinessOperationsFactory from './models/businessOperationsFactory';
2727
import schema from './schema';
2828
import { graphqlUploadExpress } from 'graphql-upload';
29-
import morgan from 'morgan';
3029
import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics';
30+
import { requestLogger } from './utils/logger';
3131

3232
/**
3333
* Option to enable playground
@@ -85,19 +85,17 @@ class HawkAPI {
8585
next();
8686
});
8787

88-
/**
89-
* Setup request logger.
90-
* Uses 'combined' format in production for Apache-style logging,
91-
* and 'dev' format in development for colored, concise output.
92-
*/
93-
this.app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
94-
9588
/**
9689
* Add metrics middleware to track HTTP requests
9790
*/
9891
this.app.use(metricsMiddleware);
9992

10093
this.app.use(express.json());
94+
95+
/**
96+
* Setup request logger with custom formatters (GraphQL operation name support)
97+
*/
98+
this.app.use(requestLogger);
10199
this.app.use(bodyParser.urlencoded({ extended: false }));
102100
this.app.use('/static', express.static(`./static`));
103101

src/metrics/mongodb.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import promClient from 'prom-client';
22
import { MongoClient, MongoClientOptions } from 'mongodb';
3+
import { Effect, sgr } from '../utils/ansi';
34

45
/**
56
* MongoDB command duration histogram
@@ -113,12 +114,155 @@ export function withMongoMetrics(options: MongoClientOptions = {}): MongoClientO
113114
};
114115
}
115116

117+
/**
118+
* Format filter/update parameters for logging
119+
* @param params - Parameters to format
120+
* @returns Formatted string
121+
*/
122+
function formatParams(params: any): string {
123+
if (!params || Object.keys(params).length === 0) {
124+
return '';
125+
}
126+
127+
try {
128+
return JSON.stringify(params);
129+
} catch (e) {
130+
return String(params);
131+
}
132+
}
133+
134+
/**
135+
* Colorize duration based on performance thresholds
136+
* @param duration - Duration in milliseconds
137+
* @returns Colorized duration string
138+
*/
139+
function colorizeDuration(duration: number): string {
140+
let color: Effect;
141+
142+
if (duration < 50) {
143+
color = Effect.ForegroundGreen;
144+
} else if (duration < 100) {
145+
color = Effect.ForegroundYellow;
146+
} else {
147+
color = Effect.ForegroundRed;
148+
}
149+
150+
return sgr(`${duration}ms`, color);
151+
}
152+
153+
/**
154+
* Interface for storing command information with timestamp
155+
*/
156+
interface StoredCommandInfo {
157+
formattedCommand: string;
158+
timestamp: number;
159+
}
160+
161+
/**
162+
* Map to store formatted command information by requestId
163+
*/
164+
const commandInfoMap = new Map<number, StoredCommandInfo>();
165+
166+
/**
167+
* Timeout for cleaning up stale command info (30 seconds)
168+
*/
169+
const COMMAND_INFO_TIMEOUT_MS = 30000;
170+
171+
/**
172+
* Cleanup stale command info to prevent memory leaks
173+
* Removes entries older than COMMAND_INFO_TIMEOUT_MS
174+
*/
175+
function cleanupStaleCommandInfo(): void {
176+
const now = Date.now();
177+
const keysToDelete: number[] = [];
178+
179+
for (const [requestId, info] of commandInfoMap.entries()) {
180+
if (now - info.timestamp > COMMAND_INFO_TIMEOUT_MS) {
181+
keysToDelete.push(requestId);
182+
}
183+
}
184+
185+
if (keysToDelete.length > 0) {
186+
console.warn(`Cleaning up ${keysToDelete.length} stale MongoDB command info entries (possible memory leak)`);
187+
for (const key of keysToDelete) {
188+
commandInfoMap.delete(key);
189+
}
190+
}
191+
}
192+
193+
/**
194+
* Periodic cleanup interval
195+
*/
196+
setInterval(cleanupStaleCommandInfo, COMMAND_INFO_TIMEOUT_MS);
197+
198+
/**
199+
* Store MongoDB command details for later logging
200+
* @param event - MongoDB command event
201+
*/
202+
function storeCommandInfo(event: any): void {
203+
const collectionRaw = extractCollectionFromCommand(event.command, event.commandName);
204+
const collection = sgr(normalizeCollectionName(collectionRaw), Effect.ForegroundGreen);
205+
const db = event.databaseName || 'unknown db';
206+
const commandName = sgr(event.commandName, Effect.ForegroundRed);
207+
const filter = event.command.filter;
208+
const update = event.command.update;
209+
const pipeline = event.command.pipeline;
210+
const projection = event.command.projection;
211+
const params = filter || update || pipeline;
212+
const paramsStr = formatParams(params);
213+
const projectionStr = projection ? ` projection: ${formatParams(projection)}` : '';
214+
215+
const formattedCommand = `[${event.requestId}] ${db}.${collection}.${commandName}(${paramsStr})${projectionStr}`;
216+
217+
commandInfoMap.set(event.requestId, {
218+
formattedCommand,
219+
timestamp: Date.now(),
220+
});
221+
}
222+
223+
/**
224+
* Log MongoDB command success to console
225+
* Format: [requestId] db.collection.command(params) ✓ duration
226+
* @param event - MongoDB command event
227+
*/
228+
function logCommandSucceeded(event: any): void {
229+
const info = commandInfoMap.get(event.requestId);
230+
const durationStr = colorizeDuration(event.duration);
231+
232+
if (info) {
233+
console.log(`${info.formattedCommand}${durationStr}`);
234+
commandInfoMap.delete(event.requestId);
235+
} else {
236+
console.log(`[${event.requestId}] ${event.commandName}${durationStr}`);
237+
}
238+
}
239+
240+
/**
241+
* Log MongoDB command failure to console
242+
* Format: [requestId] db.collection.command(params) ✗ error duration
243+
* @param event - MongoDB command event
244+
*/
245+
function logCommandFailed(event: any): void {
246+
const errorMsg = event.failure?.message || event.failure?.errmsg || 'Unknown error';
247+
const info = commandInfoMap.get(event.requestId);
248+
const durationStr = colorizeDuration(event.duration);
249+
250+
if (info) {
251+
console.error(`${info.formattedCommand}${errorMsg} ${durationStr}`);
252+
commandInfoMap.delete(event.requestId);
253+
} else {
254+
console.error(`[${event.requestId}] ${event.commandName}${errorMsg} ${durationStr}`);
255+
}
256+
}
257+
116258
/**
117259
* Setup MongoDB metrics monitoring on a MongoClient
118260
* @param client - MongoDB client to monitor
119261
*/
120262
export function setupMongoMetrics(client: MongoClient): void {
121263
client.on('commandStarted', (event) => {
264+
storeCommandInfo(event);
265+
122266
// Store start time and metadata for this command
123267
const metadataKey = `${event.requestId}`;
124268

@@ -139,6 +283,8 @@ export function setupMongoMetrics(client: MongoClient): void {
139283
});
140284

141285
client.on('commandSucceeded', (event) => {
286+
logCommandSucceeded(event);
287+
142288
const metadataKey = `${event.requestId}`;
143289
// eslint-disable-next-line @typescript-eslint/no-explicit-any
144290
const metadata = (client as any)[metadataKey];
@@ -157,6 +303,8 @@ export function setupMongoMetrics(client: MongoClient): void {
157303
});
158304

159305
client.on('commandFailed', (event) => {
306+
logCommandFailed(event);
307+
160308
const metadataKey = `${event.requestId}`;
161309
// eslint-disable-next-line @typescript-eslint/no-explicit-any
162310
const metadata = (client as any)[metadataKey];

src/models/eventsFactory.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ class EventsFactory extends Factory {
665665
async getEventRelease(eventId) {
666666
const eventOriginal = await this.findById(eventId);
667667

668-
if (!eventOriginal) {
668+
if (!eventOriginal || !eventOriginal.payload.release) {
669669
return null;
670670
}
671671

src/mongo.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ export async function setupConnections(): Promise<void> {
7575
databases.hawk = hawkMongoClient.db();
7676
databases.events = eventsMongoClient.db();
7777

78-
// Setup metrics monitoring for both clients
78+
/**
79+
* Log and and measure MongoDB metrics
80+
*/
7981
setupMongoMetrics(hawkMongoClient);
8082
setupMongoMetrics(eventsMongoClient);
8183
} catch (e) {

src/resolvers/billingNew.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ export default {
111111

112112
let isCardLinkOperation = false;
113113

114-
115114
/**
116115
* We need to only link card and not pay for the whole plan in case
117116
* 1. We are paying for the same plan and

src/resolvers/project.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,37 @@ module.exports = {
176176
}
177177
},
178178

179+
/**
180+
* Update project rate limits settings
181+
*
182+
* @param {ResolverObj} _obj
183+
* @param {string} id - project id
184+
* @param {Object | null} rateLimitSettings - rate limit settings (null to remove)
185+
* @param {UserInContext} user - current authorized user {@see ../index.js}
186+
* @param {ContextFactories} factories - factories for working with models
187+
*
188+
* @returns {Project}
189+
*/
190+
async updateProjectRateLimits(_obj, { id, rateLimitSettings }, { user, factories }) {
191+
const project = await factories.projectsFactory.findById(id);
192+
193+
if (!project) {
194+
throw new ApolloError('There is no project with that id');
195+
}
196+
197+
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
198+
throw new ApolloError('Unable to update demo project');
199+
}
200+
201+
try {
202+
return project.updateProject({
203+
rateLimitSettings: rateLimitSettings || null,
204+
});
205+
} catch (err) {
206+
throw new ApolloError('Something went wrong');
207+
}
208+
},
209+
179210
/**
180211
* Generates new project integration token by id
181212
*

src/resolvers/user.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ export default {
6565
priority: TaskPriorities.IMPORTANT,
6666
});
6767

68-
telegram.sendMessage(`🚶 User "${email}" signed up`);
68+
const source = user.utm?.source;
69+
70+
telegram.sendMessage(`🚶 User "${email}" signed up` + (source ? `, from ${source}` : ''));
6971

7072
return isE2E ? password : true;
7173
} catch (e) {

src/typeDefs/project.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
11
import { gql } from 'apollo-server-express';
22

33
export default gql`
4+
"""
5+
Rate limits configuration input
6+
"""
7+
input RateLimitSettingsInput {
8+
"""
9+
Rate limit threshold (N events)
10+
"""
11+
N: Int!
12+
13+
"""
14+
Rate limit period in seconds (T seconds)
15+
"""
16+
T: Int!
17+
}
18+
19+
"""
20+
Rate limits configuration
21+
"""
22+
type RateLimitSettings {
23+
"""
24+
Rate limit threshold (N events)
25+
"""
26+
N: Int!
27+
28+
"""
29+
Rate limit period in seconds (T seconds)
30+
"""
31+
T: Int!
32+
}
433
534
"""
635
Possible events order
@@ -253,6 +282,11 @@ type Project {
253282
Event grouping patterns
254283
"""
255284
eventGroupingPatterns: [ProjectEventGroupingPattern]
285+
286+
"""
287+
Rate limits configuration
288+
"""
289+
rateLimitSettings: RateLimitSettings
256290
}
257291
258292
extend type Query {
@@ -305,6 +339,26 @@ extend type Mutation {
305339
Project image
306340
"""
307341
image: Upload @uploadImage
342+
343+
"""
344+
Rate limits configuration
345+
"""
346+
rateLimitSettings: RateLimitSettingsInput
347+
): Project! @requireAdmin
348+
349+
"""
350+
Update project rate limits settings
351+
"""
352+
updateProjectRateLimits(
353+
"""
354+
What project to update
355+
"""
356+
id: ID!
357+
358+
"""
359+
Rate limits configuration. Pass null to remove rate limits.
360+
"""
361+
rateLimitSettings: RateLimitSettingsInput
308362
): Project! @requireAdmin
309363
310364
"""

0 commit comments

Comments
 (0)