Skip to content

Commit e9ec203

Browse files
CopilotneSpecc
andcommitted
feat: add GraphQL and MongoDB metrics
Co-authored-by: neSpecc <3684889+neSpecc@users.noreply.github.com>
1 parent 71c0b81 commit e9ec203

7 files changed

Lines changed: 312 additions & 4 deletions

File tree

docs/METRICS.md

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,68 @@ 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.001, 0.005, 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.001, 0.005, 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, and database.
109+
110+
Labels:
111+
- `command` - MongoDB command name (find, insert, update, etc.)
112+
- `collection` - Collection name
113+
- `db` - Database name
114+
115+
Buckets: 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10 seconds
116+
117+
**Purpose**: Detect slow queries and high-latency collections.
118+
119+
#### hawk_mongo_command_errors_total (Counter)
120+
121+
Counter of failed MongoDB commands grouped by command and error code.
122+
123+
Labels:
124+
- `command` - MongoDB command name
125+
- `error_code` - MongoDB error code
126+
127+
**Purpose**: Track transient or persistent database errors.
128+
67129
## Testing
68130

69131
### Manual Testing
@@ -98,11 +160,24 @@ The metrics implementation uses the `prom-client` library and consists of:
98160
- Initializes a Prometheus registry
99161
- Configures default Node.js metrics collection
100162
- Defines custom HTTP metrics (duration histogram and request counter)
163+
- Registers GraphQL and MongoDB metrics
101164
- Provides middleware for tracking HTTP requests
102165
- Creates a separate Express app for serving metrics
103166

104-
2. **Integration** (`src/index.ts`):
167+
2. **GraphQL Metrics** (`src/metrics/graphql.ts`):
168+
- Implements Apollo Server plugin for tracking GraphQL operations
169+
- Tracks operation duration, errors, and resolver execution time
170+
- Automatically captures operation name, type, and field information
171+
172+
3. **MongoDB Metrics** (`src/metrics/mongodb.ts`):
173+
- Implements MongoDB command monitoring
174+
- Tracks command duration and errors
175+
- Uses MongoDB's command monitoring events
176+
177+
4. **Integration** (`src/index.ts`, `src/mongo.ts`):
178+
- Adds GraphQL metrics plugin to Apollo Server
105179
- Adds metrics middleware to the main Express app
180+
- Enables MongoDB command monitoring on database clients
106181
- Starts metrics server on a separate port
107182
- Keeps metrics server isolated from main API traffic
108183

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import BusinessOperationsFactory from './models/businessOperationsFactory';
2727
import schema from './schema';
2828
import { graphqlUploadExpress } from 'graphql-upload';
2929
import morgan from 'morgan';
30-
import { metricsMiddleware, createMetricsServer } from './metrics';
30+
import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics';
3131

3232
/**
3333
* Option to enable playground
@@ -122,6 +122,7 @@ class HawkAPI {
122122
process.env.NODE_ENV === 'production'
123123
? ApolloServerPluginLandingPageDisabled()
124124
: ApolloServerPluginLandingPageGraphQLPlayground(),
125+
graphqlMetricsPlugin,
125126
],
126127
context: ({ req }): ResolverContextBase => req.context,
127128
formatError: (error): GraphQLError => {

src/metrics/graphql.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import client from 'prom-client';
2+
import { GraphQLRequestContext } from 'apollo-server-plugin-base';
3+
import { GraphQLError } from 'graphql';
4+
5+
/**
6+
* GraphQL operation duration histogram
7+
* Tracks GraphQL operation duration by operation name and type
8+
*/
9+
export const gqlOperationDuration = new client.Histogram({
10+
name: 'hawk_gql_operation_duration_seconds',
11+
help: 'Histogram of total GraphQL operation duration by operation name and type',
12+
labelNames: ['operation_name', 'operation_type'],
13+
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10],
14+
});
15+
16+
/**
17+
* GraphQL operation errors counter
18+
* Tracks failed GraphQL operations grouped by operation name and error class
19+
*/
20+
export const gqlOperationErrors = new client.Counter({
21+
name: 'hawk_gql_operation_errors_total',
22+
help: 'Counter of failed GraphQL operations grouped by operation name and error class',
23+
labelNames: ['operation_name', 'error_type'],
24+
});
25+
26+
/**
27+
* GraphQL resolver duration histogram
28+
* Tracks resolver execution time per type, field, and operation
29+
*/
30+
export const gqlResolverDuration = new client.Histogram({
31+
name: 'hawk_gql_resolver_duration_seconds',
32+
help: 'Histogram of resolver execution time per type, field, and operation',
33+
labelNames: ['type_name', 'field_name', 'operation_name'],
34+
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
35+
});
36+
37+
/**
38+
* Apollo Server plugin to track GraphQL metrics
39+
*/
40+
export const graphqlMetricsPlugin = {
41+
async requestDidStart(requestContext: GraphQLRequestContext) {
42+
const startTime = Date.now();
43+
let operationName = 'unknown';
44+
let operationType = 'unknown';
45+
46+
return {
47+
async didResolveOperation(requestContext: GraphQLRequestContext) {
48+
operationName = requestContext.operationName || 'anonymous';
49+
operationType = requestContext.operation?.operation || 'unknown';
50+
},
51+
52+
async executionDidStart() {
53+
return {
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
55+
willResolveField({ info }: any) {
56+
const fieldStartTime = Date.now();
57+
58+
return () => {
59+
const duration = (Date.now() - fieldStartTime) / 1000;
60+
61+
gqlResolverDuration
62+
.labels(
63+
info.parentType.name,
64+
info.fieldName,
65+
operationName
66+
)
67+
.observe(duration);
68+
};
69+
},
70+
};
71+
},
72+
73+
async willSendResponse(requestContext: GraphQLRequestContext) {
74+
const duration = (Date.now() - startTime) / 1000;
75+
76+
gqlOperationDuration
77+
.labels(operationName, operationType)
78+
.observe(duration);
79+
80+
// Track errors if any
81+
if (requestContext.errors && requestContext.errors.length > 0) {
82+
requestContext.errors.forEach((error: GraphQLError) => {
83+
const errorType = error.extensions?.code || error.name || 'unknown';
84+
85+
gqlOperationErrors
86+
.labels(operationName, errorType as string)
87+
.inc();
88+
});
89+
}
90+
},
91+
};
92+
},
93+
};

src/metrics/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import client from 'prom-client';
22
import express from 'express';
3+
import { gqlOperationDuration, gqlOperationErrors, gqlResolverDuration } from './graphql';
4+
import { mongoCommandDuration, mongoCommandErrors } from './mongodb';
35

46
/**
57
* Create a Registry to register the metrics
@@ -34,6 +36,19 @@ const httpRequestCounter = new client.Counter({
3436
registers: [ register ],
3537
});
3638

39+
/**
40+
* Register GraphQL metrics
41+
*/
42+
register.registerMetric(gqlOperationDuration);
43+
register.registerMetric(gqlOperationErrors);
44+
register.registerMetric(gqlResolverDuration);
45+
46+
/**
47+
* Register MongoDB metrics
48+
*/
49+
register.registerMetric(mongoCommandDuration);
50+
register.registerMetric(mongoCommandErrors);
51+
3752
/**
3853
* Express middleware to track HTTP metrics
3954
*/
@@ -71,3 +86,9 @@ export function createMetricsServer(): express.Application {
7186

7287
return metricsApp;
7388
}
89+
90+
/**
91+
* Export GraphQL metrics plugin and MongoDB metrics setup
92+
*/
93+
export { graphqlMetricsPlugin } from './graphql';
94+
export { setupMongoMetrics, withMongoMetrics } from './mongodb';

src/metrics/mongodb.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import client from 'prom-client';
2+
import { MongoClient, MongoClientOptions } from 'mongodb';
3+
4+
/**
5+
* MongoDB command duration histogram
6+
* Tracks MongoDB command duration by command, collection, and database
7+
*/
8+
export const mongoCommandDuration = new client.Histogram({
9+
name: 'hawk_mongo_command_duration_seconds',
10+
help: 'Histogram of MongoDB command duration by command, collection, and db',
11+
labelNames: ['command', 'collection', 'db'],
12+
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10],
13+
});
14+
15+
/**
16+
* MongoDB command errors counter
17+
* Tracks failed MongoDB commands grouped by command and error code
18+
*/
19+
export const mongoCommandErrors = new client.Counter({
20+
name: 'hawk_mongo_command_errors_total',
21+
help: 'Counter of failed MongoDB commands grouped by command and error code',
22+
labelNames: ['command', 'error_code'],
23+
});
24+
25+
/**
26+
* Enhance MongoClient options with monitoring
27+
* @param options - Original MongoDB connection options
28+
* @returns Enhanced options with monitoring enabled
29+
*/
30+
export function withMongoMetrics(options: MongoClientOptions = {}): MongoClientOptions {
31+
return {
32+
...options,
33+
monitorCommands: true,
34+
};
35+
}
36+
37+
/**
38+
* Setup MongoDB metrics monitoring on a MongoClient
39+
* @param client - MongoDB client to monitor
40+
*/
41+
export function setupMongoMetrics(client: MongoClient): void {
42+
client.on('commandStarted', (event) => {
43+
// Store start time for this command
44+
const startTimeKey = `${event.requestId}`;
45+
46+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
47+
(client as any)[startTimeKey] = Date.now();
48+
});
49+
50+
client.on('commandSucceeded', (event) => {
51+
const startTimeKey = `${event.requestId}`;
52+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
53+
const startTime = (client as any)[startTimeKey];
54+
55+
if (startTime) {
56+
const duration = (Date.now() - startTime) / 1000;
57+
const collection = event.command?.collection || event.command?.[event.commandName] || 'unknown';
58+
const db = event.databaseName || 'unknown';
59+
60+
mongoCommandDuration
61+
.labels(event.commandName, collection, db)
62+
.observe(duration);
63+
64+
// Clean up start time
65+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66+
delete (client as any)[startTimeKey];
67+
}
68+
});
69+
70+
client.on('commandFailed', (event) => {
71+
const startTimeKey = `${event.requestId}`;
72+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73+
const startTime = (client as any)[startTimeKey];
74+
75+
if (startTime) {
76+
const duration = (Date.now() - startTime) / 1000;
77+
const collection = event.command?.collection || event.command?.[event.commandName] || 'unknown';
78+
const db = event.databaseName || 'unknown';
79+
80+
mongoCommandDuration
81+
.labels(event.commandName, collection, db)
82+
.observe(duration);
83+
84+
// Track error
85+
const errorCode = event.failure?.code?.toString() || 'unknown';
86+
87+
mongoCommandErrors
88+
.labels(event.commandName, errorCode)
89+
.inc();
90+
91+
// Clean up start time
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
delete (client as any)[startTimeKey];
94+
}
95+
});
96+
}

src/mongo.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Db, MongoClient, MongoClientOptions } from 'mongodb';
22
import HawkCatcher from '@hawk.so/nodejs';
3+
import { setupMongoMetrics, withMongoMetrics } from './metrics';
34

45
const hawkDBUrl = process.env.MONGO_HAWK_DB_URL || 'mongodb://localhost:27017/hawk';
56
const eventsDBUrl = process.env.MONGO_EVENTS_DB_URL || 'mongodb://localhost:27017/events';
@@ -53,10 +54,10 @@ export const mongoClients: MongoClients = {
5354
/**
5455
* Common params for all connections
5556
*/
56-
const connectionConfig: MongoClientOptions = {
57+
const connectionConfig: MongoClientOptions = withMongoMetrics({
5758
useNewUrlParser: true,
5859
useUnifiedTopology: true,
59-
};
60+
});
6061

6162
/**
6263
* Setups connections to the databases (hawk api and events databases)
@@ -73,6 +74,10 @@ export async function setupConnections(): Promise<void> {
7374

7475
databases.hawk = hawkMongoClient.db();
7576
databases.events = eventsMongoClient.db();
77+
78+
// Setup metrics monitoring for both clients
79+
setupMongoMetrics(hawkMongoClient);
80+
setupMongoMetrics(eventsMongoClient);
7681
} catch (e) {
7782
/** Catch start Mongo errors */
7883
HawkCatcher.send(e as Error);

test/integration/cases/metrics.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,21 @@ describe('Prometheus Metrics', () => {
3838
expect(response.data).toContain('http_request_duration_seconds');
3939
expect(response.data).toContain('http_requests_total');
4040
});
41+
42+
test('Metrics endpoint includes GraphQL metrics', async () => {
43+
const response = await metricsInstance.get('/metrics');
44+
45+
// Check for GraphQL metrics
46+
expect(response.data).toContain('hawk_gql_operation_duration_seconds');
47+
expect(response.data).toContain('hawk_gql_operation_errors_total');
48+
expect(response.data).toContain('hawk_gql_resolver_duration_seconds');
49+
});
50+
51+
test('Metrics endpoint includes MongoDB metrics', async () => {
52+
const response = await metricsInstance.get('/metrics');
53+
54+
// Check for MongoDB metrics
55+
expect(response.data).toContain('hawk_mongo_command_duration_seconds');
56+
expect(response.data).toContain('hawk_mongo_command_errors_total');
57+
});
4158
});

0 commit comments

Comments
 (0)