Skip to content

Commit d61c826

Browse files
authored
feat(metrics): add metrics logging and configuration support (#1559)
* feat(metrics): add metrics logging and configuration support * refactor folder structure * implement accesslogs service methods * minor fixes * metrics service init part * remove metrics modules and send logs to the graylog * renamings * remove unused package * update example config * add comments for conditional module loading and env variable reuse * improved error message for when no/wrong format json file provided
1 parent ca918de commit d61c826

File tree

7 files changed

+160
-5
lines changed

7 files changed

+160
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ functionalAccounts.json
55
datasetTypes.json
66
proposalTypes.json
77
loggers.json
8+
metricsConfig.json
89

910
# Configs
1011
.env

metricsConfig.example.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"include": [
3+
{
4+
"path": "*",
5+
"method": 0,
6+
"version": "3"
7+
},
8+
{
9+
"path": "datasets/fullquery",
10+
"method": 0,
11+
"version": "3"
12+
},
13+
{
14+
"path": "datasets/:id",
15+
"method": 0,
16+
"version": "3"
17+
}
18+
],
19+
"exclude": [
20+
{
21+
"path": "datasets/fullfacet",
22+
"method": 0,
23+
"version": "3"
24+
},
25+
{
26+
"path": "datasets/metadataKeys",
27+
"method": 0,
28+
"version": "3"
29+
}
30+
]
31+
}

src/app.module.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MongooseModule } from "@nestjs/mongoose";
33
import { DatasetsModule } from "./datasets/datasets.module";
44
import { AuthModule } from "./auth/auth.module";
55
import { UsersModule } from "./users/users.module";
6-
import { ConfigModule, ConfigService } from "@nestjs/config";
6+
import { ConditionalModule, ConfigModule, ConfigService } from "@nestjs/config";
77
import { CaslModule } from "./casl/casl.module";
88
import configuration from "./config/configuration";
99
import { APP_GUARD, Reflector } from "@nestjs/core";
@@ -32,6 +32,7 @@ import { EventEmitterModule } from "@nestjs/event-emitter";
3232
import { AdminModule } from "./admin/admin.module";
3333
import { HealthModule } from "./health/health.module";
3434
import { LoggerModule } from "./loggers/logger.module";
35+
import { MetricsModule } from "./metrics/metrics.module";
3536

3637
@Module({
3738
imports: [
@@ -42,6 +43,13 @@ import { LoggerModule } from "./loggers/logger.module";
4243
ConfigModule.forRoot({
4344
load: [configuration],
4445
}),
46+
// NOTE: `ConditionalModule.registerWhen` directly uses `process.env` as it does not support
47+
// dependency injection for `ConfigService`. This approach ensures compatibility while
48+
// leveraging environment variables for conditional module loading.
49+
ConditionalModule.registerWhen(
50+
MetricsModule,
51+
(env: NodeJS.ProcessEnv) => env.METRICS_ENABLED === "yes",
52+
),
4553
LoggerModule,
4654
DatablocksModule,
4755
DatasetsModule,

src/config/configuration.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const configuration = () => {
4848
loggers: process.env.LOGGERS_CONFIG_FILE || "loggers.json",
4949
datasetTypes: process.env.DATASET_TYPES_FILE || "datasetTypes.json",
5050
proposalTypes: process.env.PROPOSAL_TYPES_FILE || "proposalTypes.json",
51+
metricsConfig: process.env.METRICS_CONFIG_FILE || "metricsConfig.json",
5152
};
5253
Object.keys(jsonConfigFileList).forEach((key) => {
5354
const filePath = jsonConfigFileList[key];
@@ -204,7 +205,13 @@ const configuration = () => {
204205
mongoDBCollection: process.env.MONGODB_COLLECTION,
205206
defaultIndex: process.env.ES_INDEX ?? "dataset",
206207
},
207-
208+
metrics: {
209+
// Note: `process.env.METRICS_ENABLED` is directly used for conditional module loading in
210+
// `ConditionalModule.registerWhen` as it does not support ConfigService injection. The purpose of
211+
// keeping `metrics.enabled` in the configuration is for other modules to use and maintain consistency.
212+
enabled: process.env.METRICS_ENABLED || "no",
213+
config: jsonConfigMap.metricsConfig,
214+
},
208215
registerDoiUri: process.env.REGISTER_DOI_URI,
209216
registerMetadataUri: process.env.REGISTER_METADATA_URI,
210217
doiUsername: process.env.DOI_USERNAME,

src/main.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ async function bootstrap() {
1515
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
1616
bufferLogs: true,
1717
});
18-
const configService: ConfigService<Record<string, unknown>, false> = app.get(
19-
ConfigService,
20-
);
18+
const configService = app.get(ConfigService);
2119
const apiVersion = configService.get<string>("versions.api");
2220
const swaggerPath = `${configService.get<string>("swaggerPath")}`;
2321

src/metrics/metrics.module.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Logger, MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
2+
import { ConfigModule, ConfigService } from "@nestjs/config";
3+
import { AccessTrackingMiddleware } from "./middlewares/accessTracking.middleware";
4+
import { JwtModule } from "@nestjs/jwt";
5+
6+
@Module({
7+
imports: [ConfigModule, JwtModule],
8+
exports: [],
9+
})
10+
export class MetricsModule implements NestModule {
11+
constructor(private readonly configService: ConfigService) {}
12+
13+
configure(consumer: MiddlewareConsumer) {
14+
const { include = [], exclude = [] } =
15+
this.configService.get("metrics.config") || {};
16+
if (!include.length && !exclude.length) {
17+
Logger.error(
18+
'Metrics middleware requires at least one "include" or "exclude" path in the metricsConfig.json file.',
19+
"MetricsModule",
20+
);
21+
return;
22+
}
23+
24+
try {
25+
consumer
26+
.apply(AccessTrackingMiddleware)
27+
.exclude(...exclude)
28+
.forRoutes(...include);
29+
Logger.log("Start collecting metrics", "MetricsModule");
30+
} catch (error) {
31+
Logger.error("Error configuring metrics middleware", error);
32+
}
33+
}
34+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
2+
import { Request, Response, NextFunction } from "express";
3+
import { JwtService } from "@nestjs/jwt";
4+
import { parse } from "url";
5+
6+
@Injectable()
7+
export class AccessTrackingMiddleware implements NestMiddleware {
8+
private requestCache = new Map<string, number>(); // Cache to store recent requests
9+
private logIntervalDuration = 1000; // Log every 1 second to prevent spam
10+
private cacheResetInterval = 10 * 60 * 1000; // Clear cache every 10 minutes to prevent memory leak
11+
12+
constructor(private readonly jwtService: JwtService) {
13+
this.startCacheResetInterval();
14+
}
15+
use(req: Request, res: Response, next: NextFunction) {
16+
const { query, pathname } = parse(req.originalUrl, true);
17+
18+
const userAgent = req.headers["user-agent"];
19+
// TODO: Better to use a library for this?
20+
const isBot = userAgent ? /bot|crawl|spider|slurp/i.test(userAgent) : false;
21+
22+
if (!pathname || isBot) return;
23+
24+
const startTime = Date.now();
25+
const authHeader = req.headers.authorization;
26+
const originIp = req.socket.remoteAddress;
27+
const userId = this.parseToken(authHeader);
28+
29+
const cacheKeyIdentifier = `${userId}-${originIp}-${pathname}`;
30+
31+
res.on("finish", () => {
32+
const statusCode = res.statusCode;
33+
if (statusCode === 304) return;
34+
35+
const responseTime = Date.now() - startTime;
36+
37+
const lastHitTime = this.requestCache.get(cacheKeyIdentifier);
38+
39+
// Log only if the request was not recently logged
40+
if (!lastHitTime || Date.now() - lastHitTime > this.logIntervalDuration) {
41+
Logger.log("SciCatAccessLogs", {
42+
userId,
43+
originIp,
44+
endpoint: pathname,
45+
query: query,
46+
statusCode,
47+
responseTime,
48+
});
49+
50+
this.requestCache.set(cacheKeyIdentifier, Date.now());
51+
}
52+
});
53+
54+
next();
55+
}
56+
57+
private parseToken(authHeader?: string) {
58+
if (!authHeader) return "anonymous";
59+
const token = authHeader.split(" ")[1];
60+
if (!token) return "anonymous";
61+
62+
try {
63+
const { id } = this.jwtService.decode(token);
64+
return id;
65+
} catch (error) {
66+
Logger.error("Error parsing token-> AccessTrackingMiddleware", error);
67+
return null;
68+
}
69+
}
70+
71+
private startCacheResetInterval() {
72+
setInterval(() => {
73+
this.requestCache.clear();
74+
}, this.cacheResetInterval);
75+
}
76+
}

0 commit comments

Comments
 (0)