Skip to content

Commit 8b647bb

Browse files
committed
Merge branch 'master' of github.com:codex-team/hawk.api.nodejs into feat/add-daily-events-indexes
2 parents 47b3ef3 + 393465c commit 8b647bb

File tree

27 files changed

+1331
-173
lines changed

27 files changed

+1331
-173
lines changed

.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ JWT_SECRET_REFRESH_TOKEN=abacaba
2020
# JWT secret for user's access token
2121
JWT_SECRET_ACCESS_TOKEN=belarus
2222

23+
# max document could for one read request to the database
24+
MAX_DB_READ_BATCH_SIZE=100000
2325

2426
# JWT secret for signing tokens for processing billing requests
2527
JWT_SECRET_BILLING_CHECKSUM=checksum_secret

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ To execute the request, enter it in the input field on the left and click on the
2525
On the right side you will see the result of the query.
2626

2727
## GraphQL Voyager
28-
You can view API Schema visualization in `/voyager` page in your browser. To see current production schema go to [here](https://api.beta.hawk.so/voyager)
28+
You can view API Schema visualization in `/voyager` page in your browser.
29+
To see current production schema go to [here](https://api.beta.hawk.so/voyager)
2930

3031
## Migrations
3132

docs/METRICS.md

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Duration of HTTP requests in seconds, labeled by:
5555
- `route` - Request route/path
5656
- `status_code` - HTTP status code
5757

58-
Buckets: 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10 seconds
58+
Buckets: 0.01, 0.05, 0.1, 0.5, 1, 5, 10 seconds
5959

6060
#### http_requests_total (Counter)
6161

@@ -64,6 +64,77 @@ Total number of HTTP requests, labeled by:
6464
- `route` - Request route/path
6565
- `status_code` - HTTP status code
6666

67+
### GraphQL Metrics
68+
69+
#### hawk_gql_operation_duration_seconds (Histogram)
70+
71+
Histogram of total GraphQL operation duration by operation name and type.
72+
73+
Labels:
74+
- `operation_name` - Name of the GraphQL operation
75+
- `operation_type` - Type of operation (query, mutation, subscription)
76+
77+
Buckets: 0.01, 0.05, 0.1, 0.5, 1, 5, 10 seconds
78+
79+
**Purpose**: Identify slow API operations (P95/P99 latency).
80+
81+
#### hawk_gql_operation_errors_total (Counter)
82+
83+
Counter of failed GraphQL operations grouped by operation name and error class.
84+
85+
Labels:
86+
- `operation_name` - Name of the GraphQL operation
87+
- `error_type` - Type/class of the error
88+
89+
**Purpose**: Detect increased error rates and failing operations.
90+
91+
#### hawk_gql_resolver_duration_seconds (Histogram)
92+
93+
Histogram of resolver execution time per type, field, and operation.
94+
95+
Labels:
96+
- `type_name` - GraphQL type name
97+
- `field_name` - Field name being resolved
98+
- `operation_name` - Name of the GraphQL operation
99+
100+
Buckets: 0.01, 0.05, 0.1, 0.5, 1, 5 seconds
101+
102+
**Purpose**: Find slow or CPU-intensive resolvers that degrade overall performance.
103+
104+
### MongoDB Metrics
105+
106+
#### hawk_mongo_command_duration_seconds (Histogram)
107+
108+
Histogram of MongoDB command duration by command, collection family, and database.
109+
110+
Labels:
111+
- `command` - MongoDB command name (find, insert, update, etc.)
112+
- `collection_family` - Collection family name (extracted from dynamic collection names to reduce cardinality)
113+
- `db` - Database name
114+
115+
Buckets: 0.01, 0.05, 0.1, 0.5, 1, 5, 10 seconds
116+
117+
**Purpose**: Detect slow queries and high-latency collections.
118+
119+
**Note on Collection Families**: To reduce metric cardinality, dynamic collection names are grouped into families. For example:
120+
- `events:projectId``events`
121+
- `dailyEvents:projectId``dailyEvents`
122+
- `repetitions:projectId``repetitions`
123+
- `membership:userId``membership`
124+
- `team:workspaceId``team`
125+
126+
This prevents metric explosion when dealing with thousands of projects, users, or workspaces, while still providing meaningful insights into collection performance patterns.
127+
128+
#### hawk_mongo_command_errors_total (Counter)
129+
130+
Counter of failed MongoDB commands grouped by command and error code.
131+
132+
Labels:
133+
- `command` - MongoDB command name
134+
- `error_code` - MongoDB error code
135+
136+
**Purpose**: Track transient or persistent database errors.
137+
67138
## Testing
68139

69140
### Manual Testing
@@ -98,11 +169,25 @@ The metrics implementation uses the `prom-client` library and consists of:
98169
- Initializes a Prometheus registry
99170
- Configures default Node.js metrics collection
100171
- Defines custom HTTP metrics (duration histogram and request counter)
172+
- Registers GraphQL and MongoDB metrics
101173
- Provides middleware for tracking HTTP requests
102174
- Creates a separate Express app for serving metrics
103175

104-
2. **Integration** (`src/index.ts`):
176+
2. **GraphQL Metrics** (`src/metrics/graphql.ts`):
177+
- Implements Apollo Server plugin for tracking GraphQL operations
178+
- Tracks operation duration, errors, and resolver execution time
179+
- Automatically captures operation name, type, and field information
180+
181+
3. **MongoDB Metrics** (`src/metrics/mongodb.ts`):
182+
- Implements MongoDB command monitoring
183+
- Tracks command duration and errors
184+
- Uses MongoDB's command monitoring events
185+
- Extracts collection families from dynamic collection names to reduce cardinality
186+
187+
4. **Integration** (`src/index.ts`, `src/mongo.ts`):
188+
- Adds GraphQL metrics plugin to Apollo Server
105189
- Adds metrics middleware to the main Express app
190+
- Enables MongoDB command monitoring on database clients
106191
- Starts metrics server on a separate port
107192
- Keeps metrics server isolated from main API traffic
108193

jest-mongodb-config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module.exports = {
55
dbName: 'hawk',
66
},
77
binary: {
8-
version: '4.2.13',
8+
version: '6.0.2',
99
skipMD5: true,
1010
},
1111
autoStart: false,

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.1.43",
3+
"version": "1.2.13",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/billing/cloudpayments.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,10 @@ export default class CloudPaymentsWebhooks {
272272

273273
try {
274274
await businessOperation.setStatus(BusinessOperationStatus.Confirmed);
275-
await workspace.changePlan(tariffPlan._id);
275+
276+
if (!data.isCardLinkOperation) {
277+
await workspace.changePlan(tariffPlan._id);
278+
}
276279

277280
const subscriptionId = body.SubscriptionId;
278281

@@ -339,17 +342,22 @@ export default class CloudPaymentsWebhooks {
339342
* }
340343
*/
341344

342-
try {
343-
await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({
344-
type: 'unblock-workspace',
345-
workspaceId: data.workspaceId,
346-
}));
347-
} catch (e) {
348-
const error = e as Error;
345+
/**
346+
* If it is not a card linking operation then unblock workspace
347+
*/
348+
if (!data.isCardLinkOperation) {
349+
try {
350+
await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({
351+
type: 'unblock-workspace',
352+
workspaceId: data.workspaceId,
353+
}));
354+
} catch (e) {
355+
const error = e as Error;
349356

350-
this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while sending task to limiter worker ${error.toString()}`, body);
357+
this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while sending task to limiter worker ${error.toString()}`, body);
351358

352-
return;
359+
return;
360+
}
353361
}
354362

355363
try {

src/dataLoaders.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import DataLoader from 'dataloader';
22
import { Db, ObjectId } from 'mongodb';
3-
import { PlanDBScheme, UserDBScheme, WorkspaceDBScheme, ProjectDBScheme } from '@hawk.so/types';
3+
import { PlanDBScheme, UserDBScheme, WorkspaceDBScheme, ProjectDBScheme, EventData, EventAddons } from '@hawk.so/types';
4+
5+
type EventDbScheme = {
6+
_id: ObjectId;
7+
} & EventData<EventAddons>;
48

59
/**
610
* Class for setting up data loaders
@@ -65,7 +69,7 @@ export default class DataLoaders {
6569
* @param collectionName - collection name to get entities
6670
* @param ids - ids for resolving
6771
*/
68-
private async batchByIds<T extends {_id: ObjectId}>(collectionName: string, ids: ReadonlyArray<string>): Promise<(T | null | Error)[]> {
72+
private async batchByIds<T extends { _id: ObjectId }>(collectionName: string, ids: ReadonlyArray<string>): Promise<(T | null | Error)[]> {
6973
return this.batchByField<T, ObjectId>(collectionName, ids.map(id => new ObjectId(id)), '_id');
7074
}
7175

@@ -77,12 +81,18 @@ export default class DataLoaders {
7781
*/
7882
private async batchByField<
7983
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80-
T extends {[key: string]: any},
81-
FieldType extends object | string
82-
>(collectionName: string, values: ReadonlyArray<FieldType>, fieldName: string): Promise<(T | null | Error)[]> {
84+
T extends { [key: string]: any },
85+
FieldType extends ObjectId | string
86+
>(collectionName: string, values: ReadonlyArray<FieldType>, fieldName: string): Promise<(T | null | Error)[]> {
87+
const valuesMap = new Map<string, FieldType>();
88+
89+
for (const value of values) {
90+
valuesMap.set(value.toString(), value);
91+
}
92+
8393
const queryResult = await this.dbConnection.collection(collectionName)
8494
.find({
85-
[fieldName]: { $in: values },
95+
[fieldName]: { $in: Array.from(valuesMap.values()) },
8696
})
8797
.toArray();
8898

@@ -99,3 +109,34 @@ export default class DataLoaders {
99109
return values.map((field) => entitiesMap[field.toString()] || null);
100110
}
101111
}
112+
113+
/**
114+
* Create DataLoader for events in dynamic collections `events:<projectId>` stored in the events DB
115+
*
116+
* @param eventsDb - MongoDB connection to the events database
117+
* @param projectId - project id used to pick a dynamic collection
118+
*/
119+
export function createProjectEventsByIdLoader(
120+
eventsDb: Db,
121+
projectId: string
122+
): DataLoader<string, EventDbScheme | null> {
123+
return new DataLoader<string, EventDbScheme | null>(async (ids) => {
124+
/**
125+
* Deduplicate only for the DB query; keep original ids array for mapping
126+
*/
127+
const objectIds = [ ...new Set(ids) ].map(id => new ObjectId(id));
128+
129+
const docs = await eventsDb
130+
.collection(`events:${projectId}`)
131+
.find({ _id: { $in: objectIds } })
132+
.toArray();
133+
134+
const map: Record<string, EventDbScheme> = {};
135+
136+
docs.forEach((doc) => {
137+
map[doc._id.toString()] = doc as EventDbScheme;
138+
});
139+
140+
return ids.map((id) => map[id] || null);
141+
}, { cache: true });
142+
}

src/index.ts

Lines changed: 8 additions & 9 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';
30-
import { metricsMiddleware, createMetricsServer } from './metrics';
29+
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

@@ -122,6 +120,7 @@ class HawkAPI {
122120
process.env.NODE_ENV === 'production'
123121
? ApolloServerPluginLandingPageDisabled()
124122
: ApolloServerPluginLandingPageGraphQLPlayground(),
123+
graphqlMetricsPlugin,
125124
],
126125
context: ({ req }): ResolverContextBase => req.context,
127126
formatError: (error): GraphQLError => {

0 commit comments

Comments
 (0)