diff --git a/apps/api/package.json b/apps/api/package.json index 81d7ec0c9..7a79f27fe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -83,6 +83,10 @@ "types": "./src/leaderboards/index.ts", "default": "./dist/leaderboards/index.js" }, + "#metrics": { + "types": "./src/metrics/index.ts", + "default": "./dist/metrics/index.js" + }, "#sentry": { "types": "./src/sentry/index.ts", "default": "./dist/sentry/index.js" diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b7539d41c..0fe70f549 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { AuthModule } from "#auth"; import { CommandsModule } from "#commands"; import { GuildModule } from "#guild"; import { HypixelResourcesModule } from "#hypixel-resources"; +import { MetricsModule } from "#metrics"; import { Module } from "@nestjs/common"; import { PlayerModule } from "#player"; import { RedisModule } from "#redis"; @@ -45,6 +46,7 @@ const redisUrl = await config("database.redisUrl"); AuthModule, UserModule, CommandsModule, + MetricsModule, ], controllers: [AppController], }) diff --git a/apps/api/src/commands/commands.controller.ts b/apps/api/src/commands/commands.controller.ts index c06ca59ce..bf48e18e7 100644 --- a/apps/api/src/commands/commands.controller.ts +++ b/apps/api/src/commands/commands.controller.ts @@ -6,6 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import { ActivityService } from "#metrics"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Auth, AuthRole } from "#auth"; import { CommandDto } from "#dtos"; @@ -15,7 +16,10 @@ import { Controller, Get, Patch, Query } from "@nestjs/common"; @Controller("/commands") @ApiTags("Commands") export class CommandsController { - public constructor(private readonly commandService: CommandsService) {} + public constructor( + private readonly commandService: CommandsService, + private readonly activityService: ActivityService + ) {} @Get() @ApiOperation({ summary: "Get Command Usage" }) @@ -32,9 +36,11 @@ export class CommandsController { @Patch() @ApiOperation({ summary: "Increment Command Usage" }) @Auth({ role: AuthRole.ADMIN }) - public async patchCommandRun(@Query() { command }: CommandDto) { + public async patchCommandRun(@Query() { command, userId }: CommandDto) { await this.commandService.incrementCommandRun(command); + this.activityService.recordActive(userId); + return { success: true }; } } diff --git a/apps/api/src/commands/commands.module.ts b/apps/api/src/commands/commands.module.ts index 97903fd1f..589c9c2a5 100644 --- a/apps/api/src/commands/commands.module.ts +++ b/apps/api/src/commands/commands.module.ts @@ -9,11 +9,12 @@ import { Commands } from "@statsify/schemas"; import { CommandsController } from "./commands.controller.js"; import { CommandsService } from "./commands.service.js"; +import { MetricsModule } from "#metrics"; import { Module } from "@nestjs/common"; import { TypegooseModule } from "@m8a/nestjs-typegoose"; @Module({ - imports: [TypegooseModule.forFeature([Commands])], + imports: [TypegooseModule.forFeature([Commands]), MetricsModule], controllers: [CommandsController], providers: [CommandsService], }) diff --git a/apps/api/src/dtos/active-users.dto.ts b/apps/api/src/dtos/active-users.dto.ts new file mode 100644 index 000000000..a8c12a70a --- /dev/null +++ b/apps/api/src/dtos/active-users.dto.ts @@ -0,0 +1,23 @@ +/** + * 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 { ApiProperty } from "@nestjs/swagger"; +import { IsInt, IsOptional, Min } from "class-validator"; +import { Transform } from "class-transformer"; + +export class ActiveUsersDto { + @IsOptional() + @Transform(({ value }) => +value) + @IsInt() + @Min(1) + @ApiProperty({ + description: "The number of days to count active users over", + required: false, + }) + public days?: number; +} diff --git a/apps/api/src/dtos/command.dto.ts b/apps/api/src/dtos/command.dto.ts index 2e87243d7..463a2d0a9 100644 --- a/apps/api/src/dtos/command.dto.ts +++ b/apps/api/src/dtos/command.dto.ts @@ -7,10 +7,15 @@ */ import { ApiProperty } from "@nestjs/swagger"; -import { IsString } from "class-validator"; +import { IsOptional, IsString } from "class-validator"; export class CommandDto { @IsString() @ApiProperty() public command: string; + + @IsOptional() + @IsString() + @ApiProperty({ required: false }) + public userId?: string; } diff --git a/apps/api/src/dtos/index.ts b/apps/api/src/dtos/index.ts index 975dd1697..d97fdcd9d 100644 --- a/apps/api/src/dtos/index.ts +++ b/apps/api/src/dtos/index.ts @@ -6,6 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +export * from "./active-users.dto.js"; export * from "./cache.dto.js"; export * from "./cached-player.dto.js"; export * from "./guild-leaderboard.dto.js"; diff --git a/apps/api/src/metrics/activity.service.ts b/apps/api/src/metrics/activity.service.ts new file mode 100644 index 000000000..fef900f01 --- /dev/null +++ b/apps/api/src/metrics/activity.service.ts @@ -0,0 +1,53 @@ +/** + * 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 { InjectRedis } from "#redis"; +import { Injectable } from "@nestjs/common"; +import { Redis } from "ioredis"; + +const DAY_IN_SECONDS = 60 * 60 * 24; +const RETENTION_DAYS = 90; + +@Injectable() +export class ActivityService { + public constructor(@InjectRedis() private readonly redis: Redis) {} + + public async recordActive(userId?: string) { + if (!userId) return; + + const key = ActivityService.dayKey(); + + await this.redis.pfadd(key, userId); + await this.redis.expire(key, DAY_IN_SECONDS * RETENTION_DAYS, "NX"); + } + + public async getActiveUsers(days: number): Promise { + const keys = Array.from({ length: days }, (_, index) => { + const date = new Date(); + date.setDate(date.getDate() - index); + + return ActivityService.dayKey(date); + }); + + return this.redis.pfcount(...keys); + } + + public async getMetrics() { + const [dau, wau, mau] = await Promise.all([ + this.getActiveUsers(1), + this.getActiveUsers(7), + this.getActiveUsers(30), + ]); + + return { dau, wau, mau, stickiness: mau ? dau / mau : 0 }; + } + + private static dayKey(d = new Date()): string { + return `hll:active:d:${d.toISOString().slice(0, 10)}`; + } +} diff --git a/apps/api/src/metrics/index.ts b/apps/api/src/metrics/index.ts new file mode 100644 index 000000000..17f4fbf66 --- /dev/null +++ b/apps/api/src/metrics/index.ts @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from "./activity.service.js"; +export * from "./metrics.module.js"; diff --git a/apps/api/src/metrics/metrics.controller.ts b/apps/api/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..d986a4a98 --- /dev/null +++ b/apps/api/src/metrics/metrics.controller.ts @@ -0,0 +1,33 @@ +/** + * 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 { ActiveUsersDto } from "#dtos"; +import { ActivityService } from "./activity.service.js"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Auth, AuthRole } from "#auth"; +import { Controller, Get, Query } from "@nestjs/common"; + +@Controller("/metrics") +@ApiTags("Metrics") +export class MetricsController { + public constructor(private readonly activityService: ActivityService) {} + + @Get("/active") + @ApiOperation({ summary: "Get Active User Metrics" }) + @Auth({ role: AuthRole.ADMIN }) + public async getActiveUsers(@Query() { days }: ActiveUsersDto) { + if (days) { + const activeUsers = await this.activityService.getActiveUsers(days); + return { success: true, activeUsers }; + } + + const metrics = await this.activityService.getMetrics(); + + return { success: true, ...metrics }; + } +} diff --git a/apps/api/src/metrics/metrics.module.ts b/apps/api/src/metrics/metrics.module.ts new file mode 100644 index 000000000..bb77167cb --- /dev/null +++ b/apps/api/src/metrics/metrics.module.ts @@ -0,0 +1,18 @@ +/** + * 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 { ActivityService } from "./activity.service.js"; +import { MetricsController } from "./metrics.controller.js"; +import { Module } from "@nestjs/common"; + +@Module({ + controllers: [MetricsController], + providers: [ActivityService], + exports: [ActivityService], +}) +export class MetricsModule {} diff --git a/apps/discord-bot/src/lib/command.listener.ts b/apps/discord-bot/src/lib/command.listener.ts index d42bbf4f6..d175d864b 100644 --- a/apps/discord-bot/src/lib/command.listener.ts +++ b/apps/discord-bot/src/lib/command.listener.ts @@ -95,7 +95,7 @@ export class CommandListener extends AbstractCommandListener { this.cooldownPrecondition.bind(this, parentCommand, user, id), ]; - this.apiService.incrementCommand(commandName); + this.apiService.incrementCommand(commandName, id); return this.executeCommand({ commandName, diff --git a/apps/support-bot/src/commands/metrics.command.ts b/apps/support-bot/src/commands/metrics.command.ts new file mode 100644 index 000000000..e64e840a7 --- /dev/null +++ b/apps/support-bot/src/commands/metrics.command.ts @@ -0,0 +1,45 @@ +/** + * 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 { ApiService, Command, EmbedBuilder, ErrorMessage } from "@statsify/discord"; +import { STATUS_COLORS } from "@statsify/logger"; +import { UserTier } from "@statsify/schemas"; + +@Command({ + description: (t) => t("commands.metrics"), + tier: UserTier.CORE, + userCommand: false, +}) +export class MetricsCommand { + public constructor(private readonly apiService: ApiService) {} + + public async run() { + const metrics = await this.apiService.getActiveMetrics(); + + if (!metrics) throw new ErrorMessage("errors.unknown"); + + const { dau, wau, mau, stickiness } = metrics; + const weeklyStickiness = mau ? wau / mau : 0; + + const embed = new EmbedBuilder() + .title("Active User Metrics") + .field("Daily Active Users", (t) => `\`${t(dau)}\``, true) + .field("Weekly Active Users", (t) => `\`${t(wau)}\``, true) + .field("Monthly Active Users", (t) => `\`${t(mau)}\``, true) + .field( + "Retention", + (t) => + `\`•\` **DAU/MAU**: \`${t(Math.round(stickiness * 100))}%\`\n\`•\` **WAU/MAU**: \`${t( + Math.round(weeklyStickiness * 100) + )}%\`` + ) + .color(STATUS_COLORS.info); + + return { embeds: [embed] }; + } +} diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..8fa7181e9 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -141,6 +141,7 @@ "leaderboard-woolgames": "$t(commands.leaderboard-command, { \"name\": \"WoolGames\" })", "links": "Statsify Socials", "megawalls": "$t(commands.hypixel-command, { \"name\": \"MegaWalls\" })", + "metrics": "View Statsify active user metrics", "monthly": "View your monthly stats in various games", "murdermystery": "$t(commands.hypixel-command, { \"name\": \"Murder Mystery\" })", "paintball": "$t(commands.hypixel-command, { \"name\": \"Paintball\" })", diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index f43108367..c1baac542 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -15,6 +15,7 @@ import { } from "./constants.js"; import { DeletePlayerResponse, + GetActiveMetricsResponse, GetCommandUsageResponse, GetGamecountsResponse, GetGuildResponse, @@ -263,8 +264,12 @@ export class ApiService { ).catch(() => null); } - public incrementCommand(command: string) { - return this.request("/commands", { command }, "PATCH"); + public getActiveMetrics() { + return this.request("/metrics/active", {}).catch(() => null); + } + + public incrementCommand(command: string, userId?: string) { + return this.request("/commands", { command, userId }, "PATCH"); } private async requestKey( diff --git a/packages/api-client/src/responses/get.active-metrics.response.ts b/packages/api-client/src/responses/get.active-metrics.response.ts new file mode 100644 index 000000000..9f241accb --- /dev/null +++ b/packages/api-client/src/responses/get.active-metrics.response.ts @@ -0,0 +1,24 @@ +/** + * 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 { ApiProperty } from "@nestjs/swagger"; +import { SuccessResponse } from "./success.response.js"; + +export class GetActiveMetricsResponse extends SuccessResponse { + @ApiProperty() + public dau: number; + + @ApiProperty() + public wau: number; + + @ApiProperty() + public mau: number; + + @ApiProperty() + public stickiness: number; +} diff --git a/packages/api-client/src/responses/index.ts b/packages/api-client/src/responses/index.ts index 5457251c2..4ba05ad59 100644 --- a/packages/api-client/src/responses/index.ts +++ b/packages/api-client/src/responses/index.ts @@ -8,6 +8,7 @@ export * from "./delete.player.response.js"; export * from "./error.response.js"; +export * from "./get.active-metrics.response.js"; export * from "./get.gamecounts.response.js"; export * from "./get.guild.response.js"; export * from "./get.session.response.js"; diff --git a/packages/discord/src/services/i18n-loader.service.ts b/packages/discord/src/services/i18n-loader.service.ts index f576166b3..a861c250a 100644 --- a/packages/discord/src/services/i18n-loader.service.ts +++ b/packages/discord/src/services/i18n-loader.service.ts @@ -20,11 +20,24 @@ export class I18nLoaderService { private namespaces: string[] = []; public async init() { - this.languages = await readdir("../../locales"); - this.namespaces = (await readdir(`../../locales/${DEFAULT_LANGUAGE}/`)).map((p) => - p.replace(".json", "") + const languageEntries = await readdir("../../locales", { + withFileTypes: true, + }); + this.languages = languageEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + const namespaceEntries = await readdir( + `../../locales/${DEFAULT_LANGUAGE}/`, + { + withFileTypes: true, + }, ); + this.namespaces = namespaceEntries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => entry.name.replace(".json", "")); + await i18next.use(Backend).init({ backend: { loadPath: "../../locales/{{lng}}/{{ns}}.json", @@ -67,4 +80,4 @@ function format(value: any, format?: string | undefined, lng?: string): string { } return value; -} \ No newline at end of file +}