Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,7 @@ const redisUrl = await config("database.redisUrl");
AuthModule,
UserModule,
CommandsModule,
MetricsModule,
],
controllers: [AppController],
})
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/commands/commands.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" })
Expand All @@ -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 };
}
}
3 changes: 2 additions & 1 deletion apps/api/src/commands/commands.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/dtos/active-users.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 6 additions & 1 deletion apps/api/src/dtos/command.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions apps/api/src/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
53 changes: 53 additions & 0 deletions apps/api/src/metrics/activity.service.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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)}`;
}
}
10 changes: 10 additions & 0 deletions apps/api/src/metrics/index.ts
Original file line number Diff line number Diff line change
@@ -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";
33 changes: 33 additions & 0 deletions apps/api/src/metrics/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
18 changes: 18 additions & 0 deletions apps/api/src/metrics/metrics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
2 changes: 1 addition & 1 deletion apps/discord-bot/src/lib/command.listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions apps/support-bot/src/commands/metrics.command.ts
Original file line number Diff line number Diff line change
@@ -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] };
}
}
1 change: 1 addition & 0 deletions locales/en-US/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\" })",
Expand Down
9 changes: 7 additions & 2 deletions packages/api-client/src/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "./constants.js";
import {
DeletePlayerResponse,
GetActiveMetricsResponse,
GetCommandUsageResponse,
GetGamecountsResponse,
GetGuildResponse,
Expand Down Expand Up @@ -263,8 +264,12 @@ export class ApiService {
).catch(() => null);
}

public incrementCommand(command: string) {
return this.request("/commands", { command }, "PATCH");
public getActiveMetrics() {
return this.request<GetActiveMetricsResponse>("/metrics/active", {}).catch(() => null);
}

public incrementCommand(command: string, userId?: string) {
return this.request("/commands", { command, userId }, "PATCH");
}

private async requestKey<T, K extends keyof T>(
Expand Down
24 changes: 24 additions & 0 deletions packages/api-client/src/responses/get.active-metrics.response.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/api-client/src/responses/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
21 changes: 17 additions & 4 deletions packages/discord/src/services/i18n-loader.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -67,4 +80,4 @@ function format(value: any, format?: string | undefined, lng?: string): string {
}

return value;
}
}