From e94980539a8ecd5237252629e4103e4b94a20c2e Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 12 May 2026 06:43:14 -0600 Subject: [PATCH] feat: instrument command latency in sentry --- apps/api/package.json | 1 + apps/api/src/hypixel/hypixel.service.ts | 16 +++++--- apps/api/src/index.ts | 10 ++++- apps/api/src/redis/redis.utils.ts | 18 +++++++- apps/discord-bot/package.json | 1 + .../src/commands/base.hypixel-command.ts | 1 + .../src/commands/ratios/ratios.command.tsx | 18 +++++++- apps/discord-bot/src/index.ts | 14 ++++++- apps/discord-bot/src/lib/command.listener.ts | 21 +++++++++- packages/api-client/src/api.service.ts | 41 +++++++++++++------ .../src/command/abstract-command.listener.ts | 33 +++++++++++++-- .../discord/src/command/command.interface.ts | 5 +++ .../discord/src/command/command.resolvable.ts | 3 ++ .../discord/src/interaction/interaction.ts | 15 ++++++- .../discord/src/services/paginate.service.ts | 24 +++++++++-- packages/rendering/src/jsx/render.ts | 7 ++-- packages/util/src/config.ts | 5 +++ pnpm-lock.yaml | 38 ++++++++++++++--- 18 files changed, 227 insertions(+), 44 deletions(-) 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..ae710c992 100644 --- a/apps/api/src/hypixel/hypixel.service.ts +++ b/apps/api/src/hypixel/hypixel.service.ts @@ -145,8 +145,12 @@ export class HypixelService { const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); const child = transaction?.startChild({ - op: "http.client", + op: "hypixel.api.fetch", description: `GET ${this.httpService.axiosRef.getUri({ url })}`, + data: { + "http.method": "GET", + "http.route": url, + }, }); return this.httpService.get(url, { params }).pipe( @@ -155,14 +159,16 @@ export class HypixelService { child?.finish(); }), map((res) => res.data), - catchError((err) => - throwError( + catchError((err) => { + child?.finish(); + + return throwError( () => new Error(`Fetching ${url} failed with reason: ${err.message}`, { cause: err, }) - ) - ) + ); + }) ); } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2451ba57c..04e3086a7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ 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,9 +44,11 @@ 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"), }); } diff --git a/apps/api/src/redis/redis.utils.ts b/apps/api/src/redis/redis.utils.ts index 8e57a607e..008980f08 100644 --- a/apps/api/src/redis/redis.utils.ts +++ b/apps/api/src/redis/redis.utils.ts @@ -6,6 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import * as Sentry from "@sentry/node"; import { REDIS_MODULE_CONNECTION, REDIS_MODULE_CONNECTION_TOKEN, @@ -24,5 +25,20 @@ 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); + const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const span = transaction?.startChild({ + op: "redis.query", + description: commandName, + data: { "redis.command": commandName }, + }); + + return (sendCommand(command, stream) as Promise).finally(() => span?.finish()); + }) as Redis["sendCommand"]; + + return redis; } 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..3f7ecb5fd 100644 --- a/apps/discord-bot/src/commands/ratios/ratios.command.tsx +++ b/apps/discord-bot/src/commands/ratios/ratios.command.tsx @@ -6,6 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import * as Sentry from "@sentry/node"; import { ARCADE_MODES, ARENA_BRAWL_MODES, @@ -57,7 +58,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, @@ -235,7 +236,20 @@ export class RatiosCommand { }; const canvas = render(, getTheme(user)); - const buffer = await canvas.toBuffer("png"); + const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const span = transaction?.startChild({ + op: "canvas.encode_png", + description: "Encode ratios canvas as PNG", + }); + + let buffer: Buffer; + + try { + buffer = await canvas.toBuffer("png"); + span?.setData("png.bytes", buffer.byteLength); + } finally { + span?.finish(); + } return { files: [{ name: "ratios.png", data: buffer, type: "image/png" }], 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..f5643e9c4 100644 --- a/apps/discord-bot/src/lib/command.listener.ts +++ b/apps/discord-bot/src/lib/command.listener.ts @@ -65,12 +65,31 @@ export class CommandListener extends AbstractCommandListener { parentData ); - const transaction = Sentry.startTransaction({ name: commandName, op: "command" }); + const [name, ...subcommandParts] = commandName.split(" "); + const subcommand = subcommandParts.length ? commandName : undefined; + + const transaction = Sentry.startTransaction({ + name: commandName, + op: "discord.command.total", + data: { + "command.name": name, + "command.group": parentCommand.group ?? command.group ?? "unknown", + "command.subcommand": subcommand, + }, + tags: { + "command.name": name, + "command.group": parentCommand.group ?? command.group ?? "unknown", + "command.subcommand": subcommand ?? "none", + }, + }); Sentry.configureScope((scope) => scope.setSpan(transaction)); Sentry.setContext("command", { command: commandName, + group: parentCommand.group ?? command.group ?? null, + name, + subcommand: subcommand ?? null, options: data.options, guild: interaction.getGuildId() ?? null, }); diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index b2d1d7677..83ccf0723 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -7,7 +7,13 @@ */ 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, @@ -291,21 +297,30 @@ export class ApiService { const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); const child = transaction?.startChild({ - op: "http.client", + op: "statsify.api.fetch", description: `${method} ${url}`, + data: { + "http.method": method, + "http.route": url, + }, }); - const res = await this.axios.request({ - url, - method, - params, - headers, - data: body, - responseType, - }); - - child?.setHttpStatus(res.status); - child?.finish(); + let res: AxiosResponse; + + try { + res = await this.axios.request({ + url, + method, + params, + headers, + data: body, + responseType, + }); + + child?.setHttpStatus(res.status); + } finally { + child?.finish(); + } const data = res.data; diff --git a/packages/discord/src/command/abstract-command.listener.ts b/packages/discord/src/command/abstract-command.listener.ts index 64a460791..3b0cc8cc4 100644 --- a/packages/discord/src/command/abstract-command.listener.ts +++ b/packages/discord/src/command/abstract-command.listener.ts @@ -130,27 +130,43 @@ export abstract class AbstractCommandListener { message, }: ExecuteCommandOptions) { const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const commandSpan = transaction?.startChild({ + op: "discord.command.execute", + description: commandName, + }); try { preconditions.forEach((precondition) => precondition()); const response = await command.execute(context); + commandSpan?.finish(); - if (typeof response !== "object") return; - transaction?.finish(); + if (typeof response !== "object") { + this.setMemoryUsage(transaction); + transaction?.finish(); + return; + } - context.reply({ + await context.reply({ ...message, ...response, }); + + this.setMemoryUsage(transaction); + transaction?.finish(); } catch (err) { if (err instanceof Message) { + commandSpan?.finish(); + await context.reply(err); + this.setMemoryUsage(transaction); transaction?.finish(); - return context.reply(err); + return; } this.logger.error(`An error occurred when running "${commandName}"`); this.logger.error(err); + commandSpan?.finish(); + this.setMemoryUsage(transaction); transaction?.finish(); } } @@ -318,5 +334,14 @@ export abstract class AbstractCommandListener { }); } + private setMemoryUsage(transaction?: Sentry.Transaction) { + if (!transaction) return; + + const { rss, heapUsed } = process.memoryUsage(); + + transaction.setData("memory.rss.bytes", rss); + transaction.setData("memory.heap_used.bytes", heapUsed); + } + protected abstract onCommand(interaction: Interaction): Promise | void; } 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..5aadf84c5 100644 --- a/packages/discord/src/interaction/interaction.ts +++ b/packages/discord/src/interaction/interaction.ts @@ -6,6 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import * as Sentry from "@sentry/node"; import { type APIGuildMember, type APIUser, @@ -154,7 +155,17 @@ export class Interaction { } private async request(options: RestClient.RequestOptions) { - const response = await this.rest.request(options); - return parseDiscordResponse(response); + const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const span = transaction?.startChild({ + op: "discord.reply", + description: `${options.method.toUpperCase()} ${options.path}`, + }); + + try { + const response = await this.rest.request(options); + return parseDiscordResponse(response); + } finally { + span?.finish(); + } } } diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index d53215e53..80c19a0ae 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -6,6 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import * as Sentry from "@sentry/node"; import { ButtonStyle } from "discord-api-types/v10"; import { Canvas } from "skia-canvas"; import { Service } from "typedi"; @@ -229,10 +230,25 @@ 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: [], - }); + if (content instanceof Canvas) { + const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const span = transaction?.startChild({ + op: "canvas.encode_png", + description: "Encode canvas as PNG", + }); + + try { + const data = await content.toBuffer("png"); + span?.setData("png.bytes", data.byteLength); + + return new Message({ + files: [{ name: "image.png", data, type: "image/png" }], + attachments: [], + }); + } finally { + span?.finish(); + } + } return new Message(content); } diff --git a/packages/rendering/src/jsx/render.ts b/packages/rendering/src/jsx/render.ts index 2dea261d8..db12b32c2 100644 --- a/packages/rendering/src/jsx/render.ts +++ b/packages/rendering/src/jsx/render.ts @@ -122,7 +122,7 @@ export function render(node: ElementNode, theme?: Theme): Canvas { const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); const instructionsTransaction = transaction?.startChild({ - op: "jsx.createInstructions", + op: "render.create_instructions", description: "Create instructions", }); @@ -134,8 +134,9 @@ export function render(node: ElementNode, theme?: Theme): Canvas { const height = Math.round(getTotalSize(instructions.y)); const renderTransaction = transaction?.startChild({ - op: "jsx.render", - description: "Render JSX", + op: "render.generate", + description: "Generate render canvas", + data: { width, height }, }); const canvas = createCanvas(width, height); 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..b07445ec2 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 @@ -701,6 +707,8 @@ importers: packages/skin-renderer: {} + packages/skin-renderer/pkg: {} + packages/util: dependencies: '@swc/helpers': @@ -2647,6 +2655,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 +5437,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 +6547,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 +8456,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 +8827,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 +10816,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 is-callable@1.2.7: {} @@ -11010,7 +11033,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 +11442,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 +12127,7 @@ snapshots: semver-truncate@3.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 semver@6.3.1: {} @@ -12110,8 +12137,7 @@ snapshots: semver@7.7.2: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} set-cookie-parser@2.7.1: {}