Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement metrics tracking system[proof of concept] #1504

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@
"@nestjs/mongoose": "^10.0.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.3.8",
"@nestjs/schedule": "^4.1.1",
"@nestjs/swagger": "^7.1.7",
"@nestjs/terminus": "^10.1.1",
"@user-office-software/duo-logger": "^2.1.1",
"@user-office-software/duo-message-broker": "^1.4.0",
"@willsoto/nestjs-prometheus": "^6.0.1",
"bcrypt": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
Expand Down
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ 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";
import { ScheduleModule } from "@nestjs/schedule";

@Module({
imports: [
Expand Down Expand Up @@ -94,6 +96,8 @@ import { LoggerModule } from "./loggers/logger.module";
UsersModule,
AdminModule,
HealthModule,
MetricsModule,
ScheduleModule.forRoot(),
],
controllers: [],
providers: [
Expand Down
2 changes: 2 additions & 0 deletions src/datasets/datasets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
PartialUpdateDatasetDto,
UpdateDatasetDto,
} from "./dto/update-dataset.dto";
import { MetricsTrackInterceptor } from "src/metrics/interceptors/metrics-track.interceptor";

@ApiBearerAuth()
@ApiExtraModels(
Expand Down Expand Up @@ -1144,6 +1145,7 @@ export class DatasetsController {
// GET /datasets/:id
//@UseGuards(PoliciesGuard)
@UseGuards(PoliciesGuard)
@UseInterceptors(MetricsTrackInterceptor)
@CheckPolicies("datasets", (ability: AppAbility) =>
ability.can(Action.DatasetRead, DatasetClass),
)
Expand Down
2 changes: 2 additions & 0 deletions src/datasets/datasets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { LogbooksModule } from "src/logbooks/logbooks.module";
import { PoliciesService } from "src/policies/policies.service";
import { PoliciesModule } from "src/policies/policies.module";
import { ElasticSearchModule } from "src/elastic-search/elastic-search.module";
import { MetricsModule } from "src/metrics/metrics.module";

@Module({
imports: [
MetricsModule,
AttachmentsModule,
ConfigModule,
DatablocksModule,
Expand Down
39 changes: 39 additions & 0 deletions src/metrics/interceptors/metrics-track.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
Injectable,
CallHandler,
ExecutionContext,
NestInterceptor,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { MetricsService } from "../metrics.service";

@Injectable()
export class MetricsTrackInterceptor implements NestInterceptor {
constructor(private metricsService: MetricsService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();

const requestPath = request.path.toLowerCase();
// Check if the route matches, e.g., "/datasets/"

// TODO: MUST: It will match any path after datasets/
// Implement with cautious
if (requestPath.includes("/datasets/")) {
return next.handle().pipe(
map((data) => {
if (response.statusCode === 200) {
this.metricsService.incrementViewCount(data.pid, data.isPublished);
}

return data;
}),
);
}

// Proceed with the request normally if route doesn't match
return next.handle();
}
}
5 changes: 5 additions & 0 deletions src/metrics/interfaces/metrics-type.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum MetricsType {
VIEWS = "METRICS_DATASET_VIEWS",
DOWNLOADS = "METRICS_DATASET_DOWNLOADS",
QUERIES = "METRICS_DATASET_QUERIES",
}
113 changes: 113 additions & 0 deletions src/metrics/metrics-daily-sync.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Injectable } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import { MetricsClass, MetricsDocument } from "./schemas/metrics.schema";
import axios from "axios";
import { MetricsType } from "./interfaces/metrics-type.enum";

@Injectable()
export class MetricsDailySyncService {
constructor(
@InjectModel(MetricsClass.name)
private metricsModel: Model<MetricsDocument>,
) {
this.aggregateMetrics();
}

private getEmptyMetricsTemplate(): Record<
string,
{ public: number; private: number }
> {
return {}; // Empty object to start from scratch
}

@Cron("*/10 * * * * *") // Runs every 5 seconds
async aggregateMetrics() {
console.log("Aggregating metrics...");
try {
// Start with a fresh, empty template each time
const counts = this.getEmptyMetricsTemplate();

// TODO: MUST: change the URL!!!
const { data } = await axios.get("http://localhost:3000/api/v3/metrics");

// Process Prometheus metrics for view, download, and query events
const viewCounts = this.parseMetric(data, MetricsType.VIEWS);
const downloadCounts = this.parseMetric(data, MetricsType.DOWNLOADS);
const queryCounts = this.parseMetric(data, MetricsType.QUERIES);

// Update counts based on parsed data
Object.assign(counts, viewCounts, downloadCounts, queryCounts);

// Aggregate each metric type per dataset and update MongoDB
await this.updateMetrics("view", viewCounts);
await this.updateMetrics("download", downloadCounts);
await this.updateMetrics("query", queryCounts);
} catch (error) {
console.error("Failed to aggregate metrics:", error);
}
}
private async updateMetrics(
eventType: string,
counts: Record<string, { public: number; private: number }>,
) {
const date = new Date().toISOString().slice(11, 19);

console.log("===counts", counts);

for (const [
datasetId,

Check warning on line 60 in src/metrics/metrics-daily-sync.service.ts

View workflow job for this annotation

GitHub Actions / eslint

'datasetId' is assigned a value but never used
{ public: publicCount, private: privateCount },

Check warning on line 61 in src/metrics/metrics-daily-sync.service.ts

View workflow job for this annotation

GitHub Actions / eslint

'publicCount' is assigned a value but never used

Check warning on line 61 in src/metrics/metrics-daily-sync.service.ts

View workflow job for this annotation

GitHub Actions / eslint

'privateCount' is assigned a value but never used
] of Object.entries(counts)) {
// Create a document with unique identifier as date
this.createDocument(eventType, date);
}
}

// Parses Prometheus metrics data and returns counts for public and private datasets
private parseMetric(
data: string,
metricName: string,
): Record<string, { public: number; private: number }> {
const lines = data.split("\n");
const counts: Record<string, { public: number; private: number }> = {};

for (const line of lines) {
if (line.startsWith(`${metricName}{`)) {
const datasetIdMatch = line.match(/datasetPid="([^"]+)"/);
const isPublished = /isPublished="true"/.test(line);
const value = parseFloat(line.split(" ")[1]);

if (datasetIdMatch && !isNaN(value)) {
const datasetId = datasetIdMatch[1];
if (!counts[datasetId]) {
counts[datasetId] = { public: 0, private: 0 };
}
if (isPublished) counts[datasetId].public += value;
else counts[datasetId].private += value;
}
}
}

return counts;
}

private createDocument = async (eventType: string, date: string) => {
await this.metricsModel.create(
{
date,
},
{
$setOnInsert: {
eventType,
date,
publicDataCount: 0, // Start with zero counts initially
privateDataCount: 0,
datasetCounts: [], // Start with an empty array for datasetCounts
},
},
{},
);
};
}
43 changes: 43 additions & 0 deletions src/metrics/metrics.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Module } from "@nestjs/common";
import { MetricsService } from "./metrics.service";
import {
makeCounterProvider,
PrometheusModule,
} from "@willsoto/nestjs-prometheus";
import { MongooseModule } from "@nestjs/mongoose";
import { MetricsClass, MetricsSchema } from "./schemas/metrics.schema";
import { MetricsDailySyncService } from "./metrics-daily-sync.service";
import { MetricsType } from "./interfaces/metrics-type.enum";

@Module({
imports: [
PrometheusModule.register(),
MongooseModule.forFeature([
{
name: MetricsClass.name,
schema: MetricsSchema,
},
]),
],
providers: [
MetricsService,
MetricsDailySyncService,
makeCounterProvider({
name: MetricsType.VIEWS,
help: "Counter for dataset views",
labelNames: ["datasetPid", "isPublished"], // TODO: is it possible to not hardcode this here?
}),
makeCounterProvider({
name: MetricsType.DOWNLOADS,
help: "Counter for dataset downloads",
labelNames: ["datasetPid", "isPublished"],
}),
makeCounterProvider({
name: MetricsType.QUERIES,
help: "Counter for dataset queries",
labelNames: ["datasetPid", "isPublished"],
}),
],
exports: [MetricsService],
})
export class MetricsModule {}
Loading
Loading