diff --git a/.gitmodules b/.gitmodules index 5e3e9437a..43b90d09d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,4 @@ url = https://github.com/Statsify/public-assets [submodule "assets/private"] path = assets/private - url = https://github.com/Statsify/assets \ No newline at end of file + url = https://github.com/Statsify/assets diff --git a/apps/api/package.json b/apps/api/package.json index 122cae83f..e3a4cb514 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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:^", diff --git a/apps/api/src/hypixel/hypixel.service.ts b/apps/api/src/hypixel/hypixel.service.ts index ce7398c04..d2c5ec77d 100644 --- a/apps/api/src/hypixel/hypixel.service.ts +++ b/apps/api/src/hypixel/hypixel.service.ts @@ -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, @@ -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() @@ -142,27 +141,27 @@ export class HypixelService { } private request(url: string, params?: Record): Observable { - 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()) ); } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2451ba57c..9f336492c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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)); @@ -31,6 +32,11 @@ 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({ @@ -38,13 +44,17 @@ if (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 }); diff --git a/apps/api/src/leaderboards/leaderboard.service.ts b/apps/api/src/leaderboards/leaderboard.service.ts index fd054864b..13aa21431 100644 --- a/apps/api/src/leaderboards/leaderboard.service.ts +++ b/apps/api/src/leaderboards/leaderboard.service.ts @@ -6,7 +6,6 @@ * 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"; @@ -14,6 +13,7 @@ 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, @@ -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(); @@ -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( @@ -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(); @@ -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(); @@ -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 }[] = []; diff --git a/apps/api/src/redis/redis.utils.ts b/apps/api/src/redis/redis.utils.ts index 8e57a607e..74490c286 100644 --- a/apps/api/src/redis/redis.utils.ts +++ b/apps/api/src/redis/redis.utils.ts @@ -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}`; } @@ -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).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"; } diff --git a/apps/api/src/sentry/index.ts b/apps/api/src/sentry/index.ts index 0bb0a391a..5343cd72e 100644 --- a/apps/api/src/sentry/index.ts +++ b/apps/api/src/sentry/index.ts @@ -7,3 +7,4 @@ */ export * from "./sentry.interceptor.js"; +export * from "./mongoose.js"; diff --git a/apps/api/src/sentry/mongoose.ts b/apps/api/src/sentry/mongoose.ts new file mode 100644 index 000000000..ccf6adae6 --- /dev/null +++ b/apps/api/src/sentry/mongoose.ts @@ -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 & { + mongooseCollection?: { name?: string }; + op?: string; +}; + +type InstrumentedAggregate = Aggregate & { + _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 + ): ReturnType { + 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 + ): ReturnType { + 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; + } + }; +} diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index e81f21f5b..50aaa472d 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@sentry/node": "^7.118.0", + "@sentry/profiling-node": "^7.120.4", "@statsify/api-client": "workspace:^", "@statsify/assets": "workspace:^", "@statsify/discord": "workspace:^", diff --git a/apps/discord-bot/src/commands/base.hypixel-command.ts b/apps/discord-bot/src/commands/base.hypixel-command.ts index 5020e4ba6..02b0a937d 100644 --- a/apps/discord-bot/src/commands/base.hypixel-command.ts +++ b/apps/discord-bot/src/commands/base.hypixel-command.ts @@ -59,6 +59,7 @@ export interface BaseHypixelCommand { description: "", args: [PlayerArgument], cooldown: 10, + group: "hypixel", }) export abstract class BaseHypixelCommand { protected readonly apiService: ApiService; diff --git a/apps/discord-bot/src/commands/ratios/ratios.command.tsx b/apps/discord-bot/src/commands/ratios/ratios.command.tsx index bdd522891..93f185572 100644 --- a/apps/discord-bot/src/commands/ratios/ratios.command.tsx +++ b/apps/discord-bot/src/commands/ratios/ratios.command.tsx @@ -57,7 +57,7 @@ import { render } from "@statsify/rendering"; const args = [PlayerArgument]; -@Command({ description: (t) => t("commands.ratios") }) +@Command({ description: (t) => t("commands.ratios"), group: "hypixel" }) export class RatiosCommand { public constructor( private readonly apiService: ApiService, diff --git a/apps/discord-bot/src/index.ts b/apps/discord-bot/src/index.ts index d899f2006..418be8b1d 100644 --- a/apps/discord-bot/src/index.ts +++ b/apps/discord-bot/src/index.ts @@ -17,6 +17,7 @@ import { VerifyCommand } from "#commands/verify.command"; import { config } from "@statsify/util"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { nodeProfilingIntegration } from "@sentry/profiling-node"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -27,13 +28,22 @@ process.on("uncaughtException", handleError); process.on("unhandledRejection", handleError); const sentryDsn = await config("sentry.discordBotDsn", { 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 })], + integrations: [ + new Sentry.Integrations.Http({ tracing: false, breadcrumbs: true }), + nodeProfilingIntegration(), + ], normalizeDepth: 3, - tracesSampleRate: await config("sentry.tracesSampleRate"), + tracesSampleRate: sentryTracesSampleRate, + profilesSampleRate: sentryProfilesSampleRate, environment: await config("environment"), }); } diff --git a/apps/discord-bot/src/lib/command.listener.ts b/apps/discord-bot/src/lib/command.listener.ts index b75a71e1a..4e6937d70 100644 --- a/apps/discord-bot/src/lib/command.listener.ts +++ b/apps/discord-bot/src/lib/command.listener.ts @@ -65,13 +65,33 @@ export class CommandListener extends AbstractCommandListener { parentData ); - const transaction = Sentry.startTransaction({ name: commandName, op: "command" }); + const [name, ...subcommandParts] = commandName.split(" "); + const group = parentCommand.group ?? command.group ?? "unknown"; + const subcommand = subcommandParts.join(" ") || undefined; + + const transaction = Sentry.startTransaction({ + name: commandName, + op: "command.total", + data: { + "command.name": name, + "command.group": group, + "command.subcommand": subcommand, + }, + tags: { + "command.name": name, + "command.group": group, + "command.subcommand": subcommand ?? "none", + }, + }); Sentry.configureScope((scope) => scope.setSpan(transaction)); - Sentry.setContext("command", { - command: commandName, - options: data.options, + Sentry.setContext("command", { + command: commandName, + group, + name, + subcommand: subcommand ?? null, + options: data.options, guild: interaction.getGuildId() ?? null, }); diff --git a/config.schema.js b/config.schema.js index e4f31c328..07846b4f5 100644 --- a/config.schema.js +++ b/config.schema.js @@ -68,6 +68,7 @@ export default { verifyServerDsn: "", supportBotDsn: "", tracesSampleRate: 1, + profilesSampleRate: 1, }, environment: "dev", }; diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 791a0f748..64a92e9f9 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -13,6 +13,7 @@ "@nestjs/common": "^11.1.6", "@nestjs/swagger": "^11.2.0", "@sentry/node": "^7.118.0", + "@statsify/logger": "workspace:^", "@statsify/rendering": "workspace:^", "@statsify/schemas": "workspace:^", "@statsify/util": "workspace:^", @@ -29,4 +30,4 @@ "default": "./dist/responses/index.js" } } -} \ No newline at end of file +} diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index b2d1d7677..ebdecdc3a 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -6,8 +6,13 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import * as Sentry from "@sentry/node"; -import Axios, { AxiosInstance, AxiosRequestHeaders, Method, ResponseType } from "axios"; +import Axios, { + AxiosInstance, + AxiosRequestHeaders, + AxiosResponse, + Method, + ResponseType, +} from "axios"; import { CacheLevel, GuildQuery, @@ -34,6 +39,7 @@ import { import { User, UserFooter, UserTheme } from "@statsify/schemas"; import { config } from "@statsify/util"; import { loadImage } from "@statsify/rendering"; +import { withSentrySpan } from "@statsify/logger"; interface ExtraData { headers?: AxiosRequestHeaders; @@ -288,25 +294,28 @@ export class ApiService { method: Method = "GET", { body, headers, responseType }: ExtraData = {} ): Promise { - const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); - - const child = transaction?.startChild({ - op: "http.client", + const res = await withSentrySpan({ + op: "statsify.fetch", description: `${method} ${url}`, + data: { + "http.method": method, + "http.route": url, + }, + }, async (span) => { + const response: AxiosResponse = await this.axios.request({ + url, + method, + params, + headers, + data: body, + responseType, + }); + + span?.setHttpStatus(response.status); + + return response; }); - const res = await this.axios.request({ - url, - method, - params, - headers, - data: body, - responseType, - }); - - child?.setHttpStatus(res.status); - child?.finish(); - const data = res.data; if (data.success === false) throw new Error("API request was unsuccessful"); diff --git a/packages/discord/src/command/abstract-command.listener.ts b/packages/discord/src/command/abstract-command.listener.ts index 64a460791..6b7395413 100644 --- a/packages/discord/src/command/abstract-command.listener.ts +++ b/packages/discord/src/command/abstract-command.listener.ts @@ -6,7 +6,6 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import * as Sentry from "@sentry/node"; import { type APIUser, ApplicationCommandOptionType, @@ -17,7 +16,12 @@ import { CommandContext } from "./command.context.js"; import { ErrorMessage } from "#util/error.message"; import { type IMessage, Message } from "#messages"; import { Interaction, type InteractionAttachment } from "#interaction"; -import { Logger } from "@statsify/logger"; +import { + Logger, + getSentryTransaction, + setSentryMemoryUsage, + withSentrySpan, +} from "@statsify/logger"; import { User, UserTier } from "@statsify/schemas"; import { getAssetPath, getLogoPath } from "@statsify/assets"; import { readFileSync } from "node:fs"; @@ -129,28 +133,41 @@ export abstract class AbstractCommandListener { preconditions = [], message, }: ExecuteCommandOptions) { - const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const transaction = getSentryTransaction(); try { - preconditions.forEach((precondition) => precondition()); - - const response = await command.execute(context); + const response = await withSentrySpan({ + op: "command.execute", + description: commandName, + }, async () => { + preconditions.forEach((precondition) => precondition()); + return command.execute(context); + }); - if (typeof response !== "object") return; - transaction?.finish(); + if (typeof response !== "object") { + setSentryMemoryUsage(transaction); + transaction?.finish(); + return; + } - context.reply({ + await context.reply({ ...message, ...response, }); + + setSentryMemoryUsage(transaction); + transaction?.finish(); } catch (err) { if (err instanceof Message) { + await context.reply(err); + setSentryMemoryUsage(transaction); transaction?.finish(); - return context.reply(err); + return; } this.logger.error(`An error occurred when running "${commandName}"`); this.logger.error(err); + setSentryMemoryUsage(transaction); transaction?.finish(); } } diff --git a/packages/discord/src/command/command.interface.ts b/packages/discord/src/command/command.interface.ts index 84ff30af1..da94e8e0c 100644 --- a/packages/discord/src/command/command.interface.ts +++ b/packages/discord/src/command/command.interface.ts @@ -27,6 +27,11 @@ export interface CommandOptions { cooldown?: number; + /** + * The product area this command belongs to. Used for observability. + */ + group?: string; + /** * The minimum user tier required to use this command. */ diff --git a/packages/discord/src/command/command.resolvable.ts b/packages/discord/src/command/command.resolvable.ts index 5279bae4c..f7b0431dc 100644 --- a/packages/discord/src/command/command.resolvable.ts +++ b/packages/discord/src/command/command.resolvable.ts @@ -33,6 +33,7 @@ export class CommandResolvable { public args: AbstractArgument[]; public cooldown: number; + public group?: string; public tier: UserTier; public preview?: string; @@ -49,6 +50,7 @@ export class CommandResolvable { methodName, tier = UserTier.NONE, preview, + group, cooldown = 10, }: CommandMetadata, target: any @@ -72,6 +74,7 @@ export class CommandResolvable { this.type = ApplicationCommandType.ChatInput; this.cooldown = cooldown; + this.group = group; const argsResolved = (args ?? [])?.map((a) => a instanceof AbstractArgument ? a : new a() diff --git a/packages/discord/src/interaction/interaction.ts b/packages/discord/src/interaction/interaction.ts index 6707683f1..6f5a84dcc 100644 --- a/packages/discord/src/interaction/interaction.ts +++ b/packages/discord/src/interaction/interaction.ts @@ -14,6 +14,7 @@ import { } from "discord-api-types/v10"; import { type IMessage, Message, getLocalizeFunction } from "#messages"; import { parseDiscordResponse } from "#util/parse-discord-error"; +import { withSentrySpan } from "@statsify/logger"; import type { InteractionServer, RestClient, @@ -154,7 +155,31 @@ export class Interaction { } private async request(options: RestClient.RequestOptions) { - const response = await this.rest.request(options); - return parseDiscordResponse(response); + const route = this.getRouteName(options.path); + + return withSentrySpan({ + op: "discord.reply", + description: `${options.method.toUpperCase()} ${route}`, + data: { + "http.method": options.method.toUpperCase(), + "http.route": route, + }, + }, async () => { + const response = await this.rest.request(options); + return parseDiscordResponse(response); + }); + } + + private getRouteName(path: string) { + return path + .replace( + /^\/interactions\/[^/]+\/[^/]+\/callback$/, + "/interactions/:interactionId/:interactionToken/callback" + ) + .replace( + /^\/webhooks\/[^/]+\/[^/]+/, + "/webhooks/:applicationId/:interactionToken" + ) + .replace(/\/messages\/[^/]+$/, "/messages/:messageId"); } } diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index d53215e53..10144e2fe 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -6,9 +6,9 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { ButtonStyle } from "discord-api-types/v10"; -import { Canvas } from "skia-canvas"; -import { Service } from "typedi"; +import { ButtonStyle } from "discord-api-types/v10"; +import { Canvas } from "skia-canvas"; +import { Service } from "typedi"; import { ActionRowBuilder, @@ -227,15 +227,15 @@ export class PaginateService { } private async toMessage(content: PaginateInteractionContent): Promise { - if (content instanceof Message) return content; - if (content instanceof EmbedBuilder) return new Message({ embeds: [content] }); - if (content instanceof Canvas) return new Message({ - files: [{ name: "image.png", data: await content.toBuffer("png"), type: "image/png" }], - attachments: [], - }); - - return new Message(content); - } + if (content instanceof Message) return content; + if (content instanceof EmbedBuilder) return new Message({ embeds: [content] }); + if (content instanceof Canvas) return new Message({ + files: [{ name: "image.png", data: await content.toBuffer("png"), type: "image/png" }], + attachments: [], + }); + + return new Message(content); + } } class PageController { diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 1d9975ed6..ec84c85c2 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -14,6 +14,15 @@ import type { ConsoleLoggerOptions, LogLevel, LoggerService } from "@nestjs/comm const DEFAULT_LOG_LEVELS: LogLevel[] = ["log", "error", "warn", "debug", "verbose", "fatal"]; +type SentryTagValue = boolean | number | string | null | undefined; + +export interface SentrySpanOptions { + op: string; + description?: string; + data?: Record; + tags?: Record; +} + export const STATUS_COLORS = { debug: 0xC700E7, warn: 0xFAB627, @@ -25,6 +34,61 @@ export const STATUS_COLORS = { const isProduction = await config("environment") === "prod"; +export function getSentryTransaction() { + return Sentry.getCurrentHub().getScope()?.getTransaction(); +} + +export function startSentrySpan({ + op, + description, + data, + tags, +}: SentrySpanOptions) { + const span = getSentryTransaction()?.startChild({ op, description, data }); + + for (const [key, value] of Object.entries(tags ?? {})) { + if (value === null || value === undefined) continue; + span?.setTag(key, String(value)); + } + + return span; +} + +export async function withSentrySpan( + options: SentrySpanOptions, + callback: (span?: Sentry.Span) => Promise +): Promise { + const span = startSentrySpan(options); + + try { + return await callback(span); + } finally { + span?.finish(); + } +} + +export function withSentrySpanSync( + options: SentrySpanOptions, + callback: (span?: Sentry.Span) => T +): T { + const span = startSentrySpan(options); + + try { + return callback(span); + } finally { + span?.finish(); + } +} + +export function setSentryMemoryUsage(span = getSentryTransaction()) { + if (!span) return; + + const { rss, heapUsed } = process.memoryUsage(); + + span.setData("memory.rss.bytes", rss); + span.setData("memory.heap_used.bytes", heapUsed); +} + /** * A logger implementing the NestJS LoggerService interface. However can be used anywhere. * Outputs: {icon} {context} {time} {message} diff --git a/packages/rendering/package.json b/packages/rendering/package.json index 8acf09b38..d3003d6ad 100644 --- a/packages/rendering/package.json +++ b/packages/rendering/package.json @@ -21,6 +21,7 @@ "dependencies": { "@sentry/node": "^7.118.0", "@statsify/assets": "workspace:^", + "@statsify/logger": "workspace:^", "@statsify/util": "workspace:^", "@swc/helpers": "^0.5.12", "axios": "1.11.0", @@ -49,4 +50,4 @@ "default": "./dist/jsx/index.js" } } -} \ No newline at end of file +} diff --git a/packages/rendering/src/canvas.ts b/packages/rendering/src/canvas.ts index 609cc0b68..05c3e61c3 100644 --- a/packages/rendering/src/canvas.ts +++ b/packages/rendering/src/canvas.ts @@ -7,8 +7,61 @@ */ import { Canvas } from "skia-canvas"; +import { startSentrySpan } from "@statsify/logger"; type CanvasOptions = ConstructorParameters[2] & { gpu?: boolean }; +type CanvasToBuffer = typeof Canvas.prototype.toBuffer; + +let canvasToBufferInstrumented = false; + +function instrumentCanvasToBuffer() { + if (canvasToBufferInstrumented) return; + canvasToBufferInstrumented = true; + + const toBuffer = Canvas.prototype.toBuffer; + + Canvas.prototype.toBuffer = function instrumentedToBuffer( + this: Canvas, + ...args: Parameters + ): ReturnType { + const format = args[0]; + const isPng = format === undefined || format === "png"; + + if (!isPng) return toBuffer.apply(this, args) as ReturnType; + + const span = startSentrySpan({ + op: "png.encode", + description: "Encode canvas as PNG", + data: { + width: this.width, + height: this.height, + }, + }); + + let result: ReturnType; + + try { + result = toBuffer.apply(this, args) as ReturnType; + } catch (error) { + span?.finish(); + throw error; + } + + if (!result || typeof (result as Promise).then !== "function") { + span?.finish(); + return result; + } + + return (result as Promise) + .then((buffer) => { + span?.setData("png.bytes", buffer.byteLength); + return buffer; + }) + .finally(() => span?.finish()) as ReturnType; + }; +} + +instrumentCanvasToBuffer(); export function createCanvas( width?: number, diff --git a/packages/rendering/src/jsx/render.ts b/packages/rendering/src/jsx/render.ts index 2dea261d8..7c7f9a575 100644 --- a/packages/rendering/src/jsx/render.ts +++ b/packages/rendering/src/jsx/render.ts @@ -6,7 +6,6 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import * as Sentry from "@sentry/node"; import { type Canvas, type CanvasRenderingContext2D } from "skia-canvas"; import { Container } from "typedi"; import { FontRenderer } from "#font"; @@ -15,6 +14,7 @@ import { createCanvas } from "../canvas.js"; import { createInstructions } from "./create-instructions.js"; import { getPositionalDelta, getTotalSize } from "./util.js"; import { noop } from "@statsify/util"; +import { withSentrySpanSync } from "@statsify/logger"; import type { ComputedThemeContext, ElementNode, @@ -119,48 +119,41 @@ const _render = ( }; export function render(node: ElementNode, theme?: Theme): Canvas { - const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); - - const instructionsTransaction = transaction?.startChild({ - op: "jsx.createInstructions", + const instructions = withSentrySpanSync({ + op: "render.instructions", description: "Create instructions", - }); - - const instructions = createInstructions(node); - - instructionsTransaction?.finish(); + }, () => createInstructions(node)); const width = Math.round(getTotalSize(instructions.x)); const height = Math.round(getTotalSize(instructions.y)); - const renderTransaction = transaction?.startChild({ - op: "jsx.render", - description: "Render JSX", + return withSentrySpanSync({ + op: "render.generate", + description: "Generate render canvas", + data: { width, height }, + }, () => { + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + const context: ComputedThemeContext = { + renderer: noop(), + ...theme?.context, + canvasWidth: width, + canvasHeight: height, + }; + + if (!context.renderer) context.renderer = Container.get(FontRenderer); + + _render( + ctx, + context, + { ...intrinsicRenders, ...theme?.elements }, + instructions, + 0, + 0 + ); + + return canvas; }); - - const canvas = createCanvas(width, height); - const ctx = canvas.getContext("2d"); - ctx.imageSmoothingEnabled = false; - - const context: ComputedThemeContext = { - renderer: noop(), - ...theme?.context, - canvasWidth: width, - canvasHeight: height, - }; - - if (!context.renderer) context.renderer = Container.get(FontRenderer); - - _render( - ctx, - context, - { ...intrinsicRenders, ...theme?.elements }, - instructions, - 0, - 0 - ); - - renderTransaction?.finish(); - - return canvas; } diff --git a/packages/util/src/config.ts b/packages/util/src/config.ts index 4d968fcdb..b184260c9 100644 --- a/packages/util/src/config.ts +++ b/packages/util/src/config.ts @@ -228,6 +228,11 @@ export interface Config { * The percentage of transactions to send to Sentry */ tracesSampleRate?: number; + + /** + * The percentage of sampled transactions to profile with Sentry + */ + profilesSampleRate?: number; }; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e289028fb..5750299d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: '@sentry/node': specifier: ^7.118.0 version: 7.120.4 + '@sentry/profiling-node': + specifier: ^7.120.4 + version: 7.120.4 '@statsify/api-client': specifier: workspace:^ version: link:../../packages/api-client @@ -189,6 +192,9 @@ importers: '@sentry/node': specifier: ^7.118.0 version: 7.120.4 + '@sentry/profiling-node': + specifier: ^7.120.4 + version: 7.120.4 '@statsify/api-client': specifier: workspace:^ version: link:../../packages/api-client @@ -524,6 +530,9 @@ importers: '@sentry/node': specifier: ^7.118.0 version: 7.120.4 + '@statsify/logger': + specifier: workspace:^ + version: link:../logger '@statsify/rendering': specifier: workspace:^ version: link:../rendering @@ -652,6 +661,9 @@ importers: '@statsify/assets': specifier: workspace:^ version: link:../assets + '@statsify/logger': + specifier: workspace:^ + version: link:../logger '@statsify/util': specifier: workspace:^ version: link:../util @@ -701,6 +713,8 @@ importers: packages/skin-renderer: {} + packages/skin-renderer/pkg: {} + packages/util: dependencies: '@swc/helpers': @@ -2647,6 +2661,11 @@ packages: resolution: {integrity: sha512-qq3wZAXXj2SRWhqErnGCSJKUhPSlZ+RGnCZjhfjHpP49KNpcd9YdPTIUsFMgeyjdh6Ew6aVCv23g1hTP0CHpYw==} engines: {node: '>=8'} + '@sentry/profiling-node@7.120.4': + resolution: {integrity: sha512-2Eb/LcYk7ohUx1KNnxcrN6hiyFTbD8Q9ffAvqtx09yJh1JhasvA+XCAcY72ONI5Aia4rCVkql9eEPSyhkmhsbA==} + engines: {node: '>=8.0.0'} + hasBin: true + '@sentry/types@7.120.4': resolution: {integrity: sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q==} engines: {node: '>=8'} @@ -5424,6 +5443,10 @@ packages: sass: optional: true + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -6530,6 +6553,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validator@13.15.15: @@ -8438,6 +8462,11 @@ snapshots: '@sentry/types': 7.120.4 '@sentry/utils': 7.120.4 + '@sentry/profiling-node@7.120.4': + dependencies: + detect-libc: 2.1.2 + node-abi: 3.92.0 + '@sentry/types@7.120.4': {} '@sentry/utils@7.120.4': @@ -8804,7 +8833,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -10793,7 +10822,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 is-callable@1.2.7: {} @@ -11010,7 +11039,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.2 + semver: 7.7.3 jsx-ast-utils@3.3.5: dependencies: @@ -11419,6 +11448,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.92.0: + dependencies: + semver: 7.7.3 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -12100,7 +12133,7 @@ snapshots: semver-truncate@3.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 semver@6.3.1: {} @@ -12110,8 +12143,7 @@ snapshots: semver@7.7.2: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} set-cookie-parser@2.7.1: {}