Skip to content

Commit

Permalink
Merge branch 'master' into dependabot/npm_and_yarn/nestjs/event-emitt…
Browse files Browse the repository at this point in the history
…er-3.0.0
  • Loading branch information
nitrosx authored Jan 21, 2025
2 parents 31ded12 + d61c826 commit 6a25370
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 7 deletions.
5 changes: 4 additions & 1 deletion .github/openapi/typescript-angular-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"generatorName": "typescript-angular",
"npmName": "@scicatproject/scicat-sdk-ts-angular",
"ngVersion": "16.2.12",
"withInterfaces": true
"withInterfaces": true,
"paramNaming": "original",
"modelPropertyNaming": "original",
"enumPropertyNaming": "original"
}
5 changes: 4 additions & 1 deletion .github/openapi/typescript-fetch-config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"generatorName": "typescript-fetch",
"npmName": "@scicatproject/scicat-sdk-ts-fetch",
"supportsES6": true
"supportsES6": true,
"paramNaming": "original",
"modelPropertyNaming": "original",
"enumPropertyNaming": "original"
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ functionalAccounts.json
datasetTypes.json
proposalTypes.json
loggers.json
metricsConfig.json

# Configs
.env
Expand Down
31 changes: 31 additions & 0 deletions metricsConfig.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"include": [
{
"path": "*",
"method": 0,
"version": "3"
},
{
"path": "datasets/fullquery",
"method": 0,
"version": "3"
},
{
"path": "datasets/:id",
"method": 0,
"version": "3"
}
],
"exclude": [
{
"path": "datasets/fullfacet",
"method": 0,
"version": "3"
},
{
"path": "datasets/metadataKeys",
"method": 0,
"version": "3"
}
]
}
10 changes: 9 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MongooseModule } from "@nestjs/mongoose";
import { DatasetsModule } from "./datasets/datasets.module";
import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ConditionalModule, ConfigModule, ConfigService } from "@nestjs/config";
import { CaslModule } from "./casl/casl.module";
import configuration from "./config/configuration";
import { APP_GUARD, Reflector } from "@nestjs/core";
Expand Down Expand Up @@ -32,6 +32,7 @@ import { EventEmitterModule } from "@nestjs/event-emitter";
import { AdminModule } from "./admin/admin.module";
import { HealthModule } from "./health/health.module";
import { LoggerModule } from "./loggers/logger.module";
import { MetricsModule } from "./metrics/metrics.module";

@Module({
imports: [
Expand All @@ -42,6 +43,13 @@ import { LoggerModule } from "./loggers/logger.module";
ConfigModule.forRoot({
load: [configuration],
}),
// NOTE: `ConditionalModule.registerWhen` directly uses `process.env` as it does not support
// dependency injection for `ConfigService`. This approach ensures compatibility while
// leveraging environment variables for conditional module loading.
ConditionalModule.registerWhen(
MetricsModule,
(env: NodeJS.ProcessEnv) => env.METRICS_ENABLED === "yes",
),
LoggerModule,
DatablocksModule,
DatasetsModule,
Expand Down
9 changes: 8 additions & 1 deletion src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const configuration = () => {
loggers: process.env.LOGGERS_CONFIG_FILE || "loggers.json",
datasetTypes: process.env.DATASET_TYPES_FILE || "datasetTypes.json",
proposalTypes: process.env.PROPOSAL_TYPES_FILE || "proposalTypes.json",
metricsConfig: process.env.METRICS_CONFIG_FILE || "metricsConfig.json",
};
Object.keys(jsonConfigFileList).forEach((key) => {
const filePath = jsonConfigFileList[key];
Expand Down Expand Up @@ -204,7 +205,13 @@ const configuration = () => {
mongoDBCollection: process.env.MONGODB_COLLECTION,
defaultIndex: process.env.ES_INDEX ?? "dataset",
},

metrics: {
// Note: `process.env.METRICS_ENABLED` is directly used for conditional module loading in
// `ConditionalModule.registerWhen` as it does not support ConfigService injection. The purpose of
// keeping `metrics.enabled` in the configuration is for other modules to use and maintain consistency.
enabled: process.env.METRICS_ENABLED || "no",
config: jsonConfigMap.metricsConfig,
},
registerDoiUri: process.env.REGISTER_DOI_URI,
registerMetadataUri: process.env.REGISTER_METADATA_URI,
doiUsername: process.env.DOI_USERNAME,
Expand Down
4 changes: 1 addition & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bufferLogs: true,
});
const configService: ConfigService<Record<string, unknown>, false> = app.get(
ConfigService,
);
const configService = app.get(ConfigService);
const apiVersion = configService.get<string>("versions.api");
const swaggerPath = `${configService.get<string>("swaggerPath")}`;

Expand Down
34 changes: 34 additions & 0 deletions src/metrics/metrics.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Logger, MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AccessTrackingMiddleware } from "./middlewares/accessTracking.middleware";
import { JwtModule } from "@nestjs/jwt";

@Module({
imports: [ConfigModule, JwtModule],
exports: [],
})
export class MetricsModule implements NestModule {
constructor(private readonly configService: ConfigService) {}

configure(consumer: MiddlewareConsumer) {
const { include = [], exclude = [] } =
this.configService.get("metrics.config") || {};
if (!include.length && !exclude.length) {
Logger.error(
'Metrics middleware requires at least one "include" or "exclude" path in the metricsConfig.json file.',
"MetricsModule",
);
return;
}

try {
consumer
.apply(AccessTrackingMiddleware)
.exclude(...exclude)
.forRoutes(...include);
Logger.log("Start collecting metrics", "MetricsModule");
} catch (error) {
Logger.error("Error configuring metrics middleware", error);
}
}
}
76 changes: 76 additions & 0 deletions src/metrics/middlewares/accessTracking.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
import { JwtService } from "@nestjs/jwt";
import { parse } from "url";

@Injectable()
export class AccessTrackingMiddleware implements NestMiddleware {
private requestCache = new Map<string, number>(); // Cache to store recent requests
private logIntervalDuration = 1000; // Log every 1 second to prevent spam
private cacheResetInterval = 10 * 60 * 1000; // Clear cache every 10 minutes to prevent memory leak

constructor(private readonly jwtService: JwtService) {
this.startCacheResetInterval();
}
use(req: Request, res: Response, next: NextFunction) {
const { query, pathname } = parse(req.originalUrl, true);

const userAgent = req.headers["user-agent"];
// TODO: Better to use a library for this?
const isBot = userAgent ? /bot|crawl|spider|slurp/i.test(userAgent) : false;

if (!pathname || isBot) return;

const startTime = Date.now();
const authHeader = req.headers.authorization;
const originIp = req.socket.remoteAddress;
const userId = this.parseToken(authHeader);

const cacheKeyIdentifier = `${userId}-${originIp}-${pathname}`;

res.on("finish", () => {
const statusCode = res.statusCode;
if (statusCode === 304) return;

const responseTime = Date.now() - startTime;

const lastHitTime = this.requestCache.get(cacheKeyIdentifier);

// Log only if the request was not recently logged
if (!lastHitTime || Date.now() - lastHitTime > this.logIntervalDuration) {
Logger.log("SciCatAccessLogs", {
userId,
originIp,
endpoint: pathname,
query: query,
statusCode,
responseTime,
});

this.requestCache.set(cacheKeyIdentifier, Date.now());
}
});

next();
}

private parseToken(authHeader?: string) {
if (!authHeader) return "anonymous";
const token = authHeader.split(" ")[1];
if (!token) return "anonymous";

try {
const { id } = this.jwtService.decode(token);
return id;
} catch (error) {
Logger.error("Error parsing token-> AccessTrackingMiddleware", error);
return null;
}
}

private startCacheResetInterval() {
setInterval(() => {
this.requestCache.clear();
}, this.cacheResetInterval);
}
}

0 comments on commit 6a25370

Please sign in to comment.