Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
url = https://github.com/Statsify/public-assets
[submodule "assets/private"]
path = assets/private
url = https://github.com/Statsify/assets
url = https://github.com/Statsify/assets
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@nestjs/platform-fastify": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@sentry/node": "^7.118.0",
"@sentry/profiling-node": "^7.120.4",
"@statsify/api-client": "workspace:^",
"@statsify/assets": "workspace:^",
"@statsify/logger": "workspace:^",
Expand Down
33 changes: 16 additions & 17 deletions apps/api/src/hypixel/hypixel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/

import * as Sentry from "@sentry/node";
import { CacheLevel } from "@statsify/api-client";
import {
GameCounts,
Expand All @@ -18,8 +17,8 @@ import {
} from "@statsify/schemas";
import { HttpService } from "@nestjs/axios";
import { Injectable } from "@nestjs/common";
import { Logger } from "@statsify/logger";
import { Observable, catchError, lastValueFrom, map, of, tap, throwError } from "rxjs";
import { Logger, startSentrySpan } from "@statsify/logger";
import { Observable, catchError, finalize, lastValueFrom, map, of, tap, throwError } from "rxjs";
import type { APIData } from "@statsify/util";

@Injectable()
Expand Down Expand Up @@ -142,27 +141,27 @@ export class HypixelService {
}

private request<T>(url: string, params?: Record<string, unknown>): Observable<T> {
const transaction = Sentry.getCurrentHub().getScope()?.getTransaction();

const child = transaction?.startChild({
op: "http.client",
const span = startSentrySpan({
op: "hypixel.fetch",
description: `GET ${this.httpService.axiosRef.getUri({ url })}`,
data: {
"http.method": "GET",
"http.route": url,
},
});

return this.httpService.get(url, { params }).pipe(
tap((res) => {
child?.setHttpStatus(res.status);
child?.finish();
span?.setHttpStatus(res.status);
}),
map((res) => res.data),
catchError((err) =>
throwError(
() =>
new Error(`Fetching ${url} failed with reason: ${err.message}`, {
cause: err,
})
)
)
catchError((err) => throwError(
() =>
new Error(`Fetching ${url} failed with reason: ${err.message}`, {
cause: err,
})
)),
finalize(() => span?.finish())
);
}
}
14 changes: 12 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
import { Logger } from "@statsify/logger";
import { NestFactory } from "@nestjs/core";
import { SentryInterceptor } from "./sentry/index.js";
import { SentryInterceptor, instrumentMongooseQueries } from "./sentry/index.js";
import { Severity, setGlobalOptions } from "@typegoose/typegoose";
import { ValidationPipe } from "@nestjs/common";
import { config } from "@statsify/util";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdir } from "node:fs/promises";
import { nodeProfilingIntegration } from "@sentry/profiling-node";

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand All @@ -31,20 +32,29 @@ process.on("uncaughtException", handleError);
process.on("unhandledRejection", handleError);

const sentryDsn = await config("sentry.apiDsn", { required: false });
const sentryTracesSampleRate =
await config("sentry.tracesSampleRate", { required: false }) ?? 0;
const sentryProfilesSampleRate =
await config("sentry.profilesSampleRate", { required: false }) ??
sentryTracesSampleRate;

if (sentryDsn) {
Sentry.init({
dsn: sentryDsn,
integrations: [
new Sentry.Integrations.Http({ tracing: false, breadcrumbs: true }),
new Sentry.Integrations.Mongo({ useMongoose: true }),
nodeProfilingIntegration(),
],
normalizeDepth: 3,
tracesSampleRate: await config("sentry.tracesSampleRate"),
tracesSampleRate: sentryTracesSampleRate,
profilesSampleRate: sentryProfilesSampleRate,
environment: await config("environment"),
});
}

instrumentMongooseQueries();

const mediaRoot = await config("api.mediaRoot");

await mkdir(join(mediaRoot, "badges"), { recursive: true });
Expand Down
49 changes: 17 additions & 32 deletions apps/api/src/leaderboards/leaderboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/

import * as Sentry from "@sentry/node";
import { Constructor, Flatten } from "@statsify/util";
import { DateTime } from "luxon";
import { InjectRedis } from "#redis";
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { LeaderboardEnabledMetadata, LeaderboardScanner } from "@statsify/schemas";
import { LeaderboardQuery } from "@statsify/api-client";
import { Redis } from "ioredis";
import { withSentrySpan } from "@statsify/logger";

const DAYS_IN_WEEK = {
monday: 0,
Expand All @@ -38,12 +38,6 @@ export abstract class LeaderboardService {
remove = false
) {
const fields = LeaderboardScanner.getLeaderboardFields(constructor);
const transaction = Sentry.getCurrentHub().getScope()?.getTransaction();

const child = transaction?.startChild({
op: "redis",
description: `add ${constructor.name} leaderboards`,
});

const pipeline = this.redis.pipeline();
const name = constructor.name.toLowerCase();
Expand All @@ -67,9 +61,10 @@ export abstract class LeaderboardService {
}
}

await pipeline.exec();

child?.finish();
await withSentrySpan({
op: "redis.write",
description: `add ${constructor.name} leaderboards`,
}, () => pipeline.exec());
}

public async getLeaderboard<T>(
Expand Down Expand Up @@ -194,13 +189,6 @@ export abstract class LeaderboardService {
fields: string[],
id: string
) {
const transaction = Sentry.getCurrentHub().getScope()?.getTransaction();

const child = transaction?.startChild({
op: "redis",
description: `get ${constructor.name} rankings`,
});

const pipeline = this.redis.pipeline();
const constructorName = constructor.name.toLowerCase();

Expand All @@ -221,9 +209,10 @@ export abstract class LeaderboardService {
}
});

const responses = await pipeline.exec();

child?.finish();
const responses = await withSentrySpan({
op: "redis.get",
description: `get ${constructor.name} rankings`,
}, () => pipeline.exec());

if (!responses) throw new InternalServerErrorException();

Expand Down Expand Up @@ -274,21 +263,17 @@ export abstract class LeaderboardService {
bottom: number,
sort = "DESC"
) {
const transaction = Sentry.getCurrentHub().getScope()?.getTransaction();

const child = transaction?.startChild({
op: "redis",
description: `get ${constructor.name} leaderboards`,
});

const name = constructor.name.toLowerCase();
field = `${name}.${field}`;

const scores = await (sort === "ASC" ?
this.redis.zrange(field, top, bottom, "WITHSCORES") :
this.redis.zrevrange(field, top, bottom, "WITHSCORES"));

child?.finish();
const scores = await withSentrySpan({
op: "redis.get",
description: `get ${constructor.name} leaderboards`,
}, () =>
sort === "ASC" ?
this.redis.zrange(field, top, bottom, "WITHSCORES") :
this.redis.zrevrange(field, top, bottom, "WITHSCORES")
);

const response: { id: string; score: number; index: number }[] = [];

Expand Down
59 changes: 58 additions & 1 deletion apps/api/src/redis/redis.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,38 @@ import {
REDIS_MODULE_OPTIONS_TOKEN,
} from "./redis.constants.js";
import { Redis } from "ioredis";
import { startSentrySpan } from "@statsify/logger";
import type { RedisModuleOptions } from "./redis.interfaces.js";

const REDIS_READ_COMMANDS = new Set([
"exists",
"get",
"hget",
"hgetall",
"hmget",
"mget",
"ttl",
"zrank",
"zrange",
"zrevrank",
"zrevrange",
"zscore",
"ft.sugget",
]);

const REDIS_WRITE_COMMANDS = new Set([
"del",
"expire",
"expireat",
"hset",
"hmset",
"set",
"zadd",
"zrem",
"ft.sugadd",
"ft.sugdel",
]);

export function getRedisOptionsToken(connection?: string): string {
return `${connection || REDIS_MODULE_CONNECTION}_${REDIS_MODULE_OPTIONS_TOKEN}`;
}
Expand All @@ -24,5 +54,32 @@ export function getRedisConnectionToken(connection?: string): string {

export function createRedisConnection(options: RedisModuleOptions) {
const { config } = options;
return config.url ? new Redis(config.url, config) : new Redis(config);
const redis = config.url ? new Redis(config.url, config) : new Redis(config);
const sendCommand = redis.sendCommand.bind(redis);

redis.sendCommand = ((command, stream) => {
const commandName = String((command as { name: string }).name).toLowerCase();
const span = startSentrySpan({
op: getRedisSpanOperation(commandName),
description: commandName,
data: { "redis.command": commandName },
});

try {
return (sendCommand(command, stream) as Promise<unknown>).finally(() =>
span?.finish()
);
} catch (error) {
span?.finish();
throw error;
}
}) as Redis["sendCommand"];

return redis;
}

function getRedisSpanOperation(commandName: string) {
if (REDIS_READ_COMMANDS.has(commandName)) return "redis.get";
if (REDIS_WRITE_COMMANDS.has(commandName)) return "redis.write";
return "redis.command";
}
1 change: 1 addition & 0 deletions apps/api/src/sentry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
*/

export * from "./sentry.interceptor.js";
export * from "./mongoose.js";
93 changes: 93 additions & 0 deletions apps/api/src/sentry/mongoose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Copyright (c) Statsify
*
* This source code is licensed under the GNU GPL v3 license found in the
* LICENSE file in the root directory of this source tree.
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/

import { Aggregate, Query } from "mongoose";
import { startSentrySpan } from "@statsify/logger";

let mongooseInstrumented = false;

type InstrumentedQuery = Query<unknown, unknown> & {
mongooseCollection?: { name?: string };
op?: string;
};

type InstrumentedAggregate = Aggregate<unknown> & {
_model?: {
collection?: { name?: string };
modelName?: string;
};
};

export function instrumentMongooseQueries() {
if (mongooseInstrumented) return;
mongooseInstrumented = true;

instrumentQueryExec();
instrumentAggregateExec();
}

function instrumentQueryExec() {
const exec = Query.prototype.exec;

Query.prototype.exec = function instrumentedExec(
this: InstrumentedQuery,
...args: Parameters<typeof exec>
): ReturnType<typeof exec> {
const collection = this.mongooseCollection?.name ?? this.model.collection.name;
const operation = this.op ?? "query";
const span = startSentrySpan({
op: "mongo.query",
description: `${collection}.${operation}`,
data: {
"db.collection": collection,
"db.operation": operation,
"db.system": "mongodb",
"mongoose.model": this.model.modelName,
},
});

try {
return exec.apply(this, args).finally(() => span?.finish()) as ReturnType<
typeof exec
>;
} catch (error) {
span?.finish();
throw error;
}
};
}

function instrumentAggregateExec() {
const exec = Aggregate.prototype.exec;

Aggregate.prototype.exec = function instrumentedExec(
this: InstrumentedAggregate,
...args: Parameters<typeof exec>
): ReturnType<typeof exec> {
const collection = this._model?.collection?.name ?? "unknown";
const span = startSentrySpan({
op: "mongo.query",
description: `${collection}.aggregate`,
data: {
"db.collection": collection,
"db.operation": "aggregate",
"db.system": "mongodb",
"mongoose.model": this._model?.modelName ?? "unknown",
},
});

try {
return exec.apply(this, args).finally(() => span?.finish()) as ReturnType<
typeof exec
>;
} catch (error) {
span?.finish();
throw error;
}
};
}
Loading
Loading