From 877494540cdd6bc68bf39fa48a475e6ab483bd80 Mon Sep 17 00:00:00 2001 From: Firas Regaieg Date: Thu, 26 Feb 2026 21:06:21 +0100 Subject: [PATCH 1/6] Analytics service setup with first use in Ping command --- .../togetherjava/tjbot/features/Features.java | 4 +- .../features/analytics/AnalyticsService.java | 84 +++++++++++++++++++ .../features/analytics/package-info.java | 13 +++ .../tjbot/features/basic/PingCommand.java | 28 ++++++- .../db/V16__Add_Analytics_System.sql | 13 +++ .../tjbot/features/basic/PingCommandTest.java | 6 +- 6 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java create mode 100644 application/src/main/resources/db/V16__Add_Analytics_System.sql diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 6febd433b6..54c860a31e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -6,6 +6,7 @@ import org.togetherjava.tjbot.config.FeatureBlacklist; import org.togetherjava.tjbot.config.FeatureBlacklistConfig; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.features.analytics.AnalyticsService; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; @@ -128,6 +129,7 @@ public static Collection createFeatures(JDA jda, Database database, Con TopHelpersService topHelpersService = new TopHelpersService(database); TopHelpersAssignmentRoutine topHelpersAssignmentRoutine = new TopHelpersAssignmentRoutine(config, topHelpersService); + AnalyticsService analyticsService = new AnalyticsService(database); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -184,7 +186,7 @@ public static Collection createFeatures(JDA jda, Database database, Con // Slash commands features.add(new LogLevelCommand()); - features.add(new PingCommand()); + features.add(new PingCommand(analyticsService)); features.add(new TeXCommand()); features.add(new TagCommand(tagSystem)); features.add(new TagManageCommand(tagSystem, modAuditLogWriter)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java new file mode 100644 index 0000000000..4712b1b33a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java @@ -0,0 +1,84 @@ +package org.togetherjava.tjbot.features.analytics; + +import org.jetbrains.annotations.Nullable; +import org.jooq.impl.DSL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.db.Database; + +/** + * Service for tracking and recording command usage analytics. + *

+ * This service records every command execution along with its success/failure status, allowing for + * analysis of command usage patterns, popular commands, error rates, and activity over time. + *

+ * Commands should call {@link #recordCommandExecution(long, String, long, boolean, String)} after + * execution to track their usage. + */ +public final class AnalyticsService { + private static final Logger logger = LoggerFactory.getLogger(AnalyticsService.class); + + private final Database database; + + /** + * Creates a new instance. + * + * @param database the database to use for storing and retrieving analytics data + */ + public AnalyticsService(Database database) { + this.database = database; + } + + /** + * Records a command execution with success/failure status. + *

+ * This method should be called by commands after they complete execution to track usage + * patterns and error rates. + * + * @param guildId the guild ID where the command was executed + * @param commandName the name of the command that was executed + * @param userId the ID of the user who executed the command + * @param success whether the command executed successfully + * @param errorMessage optional error message if the command failed (null if successful) + */ + public void recordCommandExecution(long guildId, String commandName, long userId, + boolean success, @Nullable String errorMessage) { + + database.write(context -> context + .insertInto(DSL.table("command_usage"), DSL.field("guild_id"), + DSL.field("command_name"), DSL.field("user_id"), DSL.field("executed_at"), + DSL.field("success"), DSL.field("error_message")) + .values(guildId, commandName, userId, DSL.currentTimestamp(), success, errorMessage) + .execute()); + + if (!success && errorMessage != null) { + logger.warn("Command '{}' failed in guild {} with error: {}", commandName, guildId, + errorMessage); + } + } + + /** + * Records a successful command execution. + * + * @param guildId the guild ID where the command was executed + * @param commandName the name of the command that was executed + * @param userId the ID of the user who executed the command + */ + public void recordCommandSuccess(long guildId, String commandName, long userId) { + recordCommandExecution(guildId, commandName, userId, true, null); + } + + /** + * Records a failed command execution. + * + * @param guildId the guild ID where the command was executed + * @param commandName the name of the command that was executed + * @param userId the ID of the user who executed the command + * @param errorMessage a description of what went wrong + */ + public void recordCommandFailure(long guildId, String commandName, long userId, + String errorMessage) { + recordCommandExecution(guildId, commandName, userId, false, errorMessage); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java new file mode 100644 index 0000000000..067ce2e8fb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java @@ -0,0 +1,13 @@ +/** + * Analytics system for collecting and persisting bot activity metrics. + *

+ * This package provides services and components that record events for later analysis and reporting + * across multiple feature areas, not limited to commands. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.analytics; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java index c4dc10a7bc..1887fc9a53 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java @@ -1,9 +1,11 @@ package org.togetherjava.tjbot.features.basic; +import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.analytics.AnalyticsService; /** * Implementation of an example command to illustrate how to respond to a user. @@ -11,11 +13,16 @@ * The implemented command is {@code /ping}, upon which the bot will respond with {@code Pong!}. */ public final class PingCommand extends SlashCommandAdapter { + private final AnalyticsService analyticsService; + /** * Creates an instance of the ping pong command. + * + * @param analyticsService the analytics service to track command usage */ - public PingCommand() { + public PingCommand(AnalyticsService analyticsService) { super("ping", "Bot responds with 'Pong!'", CommandVisibility.GUILD); + this.analyticsService = analyticsService; } /** @@ -25,6 +32,23 @@ public PingCommand() { */ @Override public void onSlashCommand(SlashCommandInteractionEvent event) { - event.reply("Pong!").queue(); + Guild guild = event.getGuild(); + if (guild == null) { + event.reply("This command can only be used in a server!").setEphemeral(true).queue(); + return; + } + + try { + event.reply("Pong!").queue(); + + analyticsService.recordCommandSuccess(guild.getIdLong(), getName(), + event.getUser().getIdLong()); + + } catch (Exception e) { + analyticsService.recordCommandFailure(guild.getIdLong(), getName(), + event.getUser().getIdLong(), + e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); + throw e; + } } } diff --git a/application/src/main/resources/db/V16__Add_Analytics_System.sql b/application/src/main/resources/db/V16__Add_Analytics_System.sql new file mode 100644 index 0000000000..36c83da607 --- /dev/null +++ b/application/src/main/resources/db/V16__Add_Analytics_System.sql @@ -0,0 +1,13 @@ +CREATE TABLE command_usage +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER NOT NULL, + command_name TEXT NOT NULL, + user_id INTEGER NOT NULL, + executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN NOT NULL DEFAULT TRUE, + error_message TEXT +); + +CREATE INDEX idx_command_usage_guild ON command_usage(guild_id); +CREATE INDEX idx_command_usage_command_name ON command_usage(command_name); diff --git a/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java index fe82957c21..ca91fe886b 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java @@ -6,13 +6,16 @@ import org.junit.jupiter.api.Test; import org.togetherjava.tjbot.features.SlashCommand; +import org.togetherjava.tjbot.features.analytics.AnalyticsService; import org.togetherjava.tjbot.jda.JdaTester; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; final class PingCommandTest { private JdaTester jdaTester; private SlashCommand command; + private AnalyticsService analyticsService; private SlashCommandInteractionEvent triggerSlashCommand() { SlashCommandInteractionEvent event = @@ -24,7 +27,8 @@ private SlashCommandInteractionEvent triggerSlashCommand() { @BeforeEach void setUp() { jdaTester = new JdaTester(); - command = jdaTester.spySlashCommand(new PingCommand()); + analyticsService = mock(AnalyticsService.class); + command = jdaTester.spySlashCommand(new PingCommand(analyticsService)); } @Test From 5002f75769368859edc56f3ce8b734649c87b565 Mon Sep 17 00:00:00 2001 From: Firas Regaieg Date: Fri, 27 Feb 2026 21:04:15 +0100 Subject: [PATCH 2/6] Analytics: remove AnalyticsService injection in PingCommand and using it from BotCore; --- .../togetherjava/tjbot/features/Features.java | 4 +- .../features/analytics/AnalyticsService.java | 22 +++++------ .../tjbot/features/basic/PingCommand.java | 20 +--------- .../tjbot/features/system/BotCore.java | 38 +++++++++++++------ .../db/V16__Add_Analytics_System.sql | 6 +-- .../tjbot/features/basic/PingCommandTest.java | 6 +-- 6 files changed, 45 insertions(+), 51 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 54c860a31e..6febd433b6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -6,7 +6,6 @@ import org.togetherjava.tjbot.config.FeatureBlacklist; import org.togetherjava.tjbot.config.FeatureBlacklistConfig; import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.features.analytics.AnalyticsService; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; @@ -129,7 +128,6 @@ public static Collection createFeatures(JDA jda, Database database, Con TopHelpersService topHelpersService = new TopHelpersService(database); TopHelpersAssignmentRoutine topHelpersAssignmentRoutine = new TopHelpersAssignmentRoutine(config, topHelpersService); - AnalyticsService analyticsService = new AnalyticsService(database); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -186,7 +184,7 @@ public static Collection createFeatures(JDA jda, Database database, Con // Slash commands features.add(new LogLevelCommand()); - features.add(new PingCommand(analyticsService)); + features.add(new PingCommand()); features.add(new TeXCommand()); features.add(new TagCommand(tagSystem)); features.add(new TagManageCommand(tagSystem, modAuditLogWriter)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java index 4712b1b33a..450bd32217 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java @@ -36,24 +36,24 @@ public AnalyticsService(Database database) { * This method should be called by commands after they complete execution to track usage * patterns and error rates. * - * @param guildId the guild ID where the command was executed + * @param channelId the channel ID where the command was executed * @param commandName the name of the command that was executed * @param userId the ID of the user who executed the command * @param success whether the command executed successfully * @param errorMessage optional error message if the command failed (null if successful) */ - public void recordCommandExecution(long guildId, String commandName, long userId, + public void recordCommandExecution(long channelId, String commandName, long userId, boolean success, @Nullable String errorMessage) { database.write(context -> context - .insertInto(DSL.table("command_usage"), DSL.field("guild_id"), + .insertInto(DSL.table("command_usage"), DSL.field("channel_id"), DSL.field("command_name"), DSL.field("user_id"), DSL.field("executed_at"), DSL.field("success"), DSL.field("error_message")) - .values(guildId, commandName, userId, DSL.currentTimestamp(), success, errorMessage) + .values(channelId, commandName, userId, DSL.currentTimestamp(), success, errorMessage) .execute()); if (!success && errorMessage != null) { - logger.warn("Command '{}' failed in guild {} with error: {}", commandName, guildId, + logger.warn("Command '{}' failed on channel {} with error: {}", commandName, channelId, errorMessage); } } @@ -61,24 +61,24 @@ public void recordCommandExecution(long guildId, String commandName, long userId /** * Records a successful command execution. * - * @param guildId the guild ID where the command was executed + * @param channelId the channel ID where the command was executed * @param commandName the name of the command that was executed * @param userId the ID of the user who executed the command */ - public void recordCommandSuccess(long guildId, String commandName, long userId) { - recordCommandExecution(guildId, commandName, userId, true, null); + public void recordCommandSuccess(long channelId, String commandName, long userId) { + recordCommandExecution(channelId, commandName, userId, true, null); } /** * Records a failed command execution. * - * @param guildId the guild ID where the command was executed + * @param channelId the channel ID where the command was executed * @param commandName the name of the command that was executed * @param userId the ID of the user who executed the command * @param errorMessage a description of what went wrong */ - public void recordCommandFailure(long guildId, String commandName, long userId, + public void recordCommandFailure(long channelId, String commandName, long userId, String errorMessage) { - recordCommandExecution(guildId, commandName, userId, false, errorMessage); + recordCommandExecution(channelId, commandName, userId, false, errorMessage); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java index 1887fc9a53..35663d85d3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java @@ -5,7 +5,6 @@ import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; -import org.togetherjava.tjbot.features.analytics.AnalyticsService; /** * Implementation of an example command to illustrate how to respond to a user. @@ -13,16 +12,12 @@ * The implemented command is {@code /ping}, upon which the bot will respond with {@code Pong!}. */ public final class PingCommand extends SlashCommandAdapter { - private final AnalyticsService analyticsService; /** * Creates an instance of the ping pong command. - * - * @param analyticsService the analytics service to track command usage */ - public PingCommand(AnalyticsService analyticsService) { + public PingCommand() { super("ping", "Bot responds with 'Pong!'", CommandVisibility.GUILD); - this.analyticsService = analyticsService; } /** @@ -38,17 +33,6 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { return; } - try { - event.reply("Pong!").queue(); - - analyticsService.recordCommandSuccess(guild.getIdLong(), getName(), - event.getUser().getIdLong()); - - } catch (Exception e) { - analyticsService.recordCommandFailure(guild.getIdLong(), getName(), - event.getUser().getIdLong(), - e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); - throw e; - } + event.reply("Pong!").queue(); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java index e9d99bc4d1..d0812dba13 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java @@ -23,7 +23,6 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import org.slf4j.Logger; @@ -42,6 +41,7 @@ import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; import org.togetherjava.tjbot.features.VoiceReceiver; +import org.togetherjava.tjbot.features.analytics.AnalyticsService; import org.togetherjava.tjbot.features.componentids.ComponentId; import org.togetherjava.tjbot.features.componentids.ComponentIdParser; import org.togetherjava.tjbot.features.componentids.ComponentIdStore; @@ -79,13 +79,13 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { private static final ExecutorService COMMAND_SERVICE = Executors.newCachedThreadPool(); private static final ScheduledExecutorService ROUTINE_SERVICE = Executors.newScheduledThreadPool(5); - private final Config config; private final Map prefixedNameToInteractor; private final List routines; private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; private final Map channelNameToMessageReceiver = new HashMap<>(); private final Map channelNameToVoiceReceiver = new HashMap<>(); + private final AnalyticsService analyticsService; /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -97,9 +97,11 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { * @param config the configuration to use for this system */ public BotCore(JDA jda, Database database, Config config) { - this.config = config; Collection features = Features.createFeatures(jda, database, config); + // Initialize analytics service + analyticsService = new AnalyticsService(database); + // Message receivers features.stream() .filter(MessageReceiver.class::isInstance) @@ -300,14 +302,14 @@ private Optional selectPreferredAudioChannel(@Nullable AudioChannelUnio } @Override - public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) { selectPreferredAudioChannel(event.getChannelJoined(), event.getChannelLeft()) .ifPresent(channel -> getVoiceReceiversSubscribedTo(channel) .forEach(voiceReceiver -> voiceReceiver.onVoiceUpdate(event))); } @Override - public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) { + public void onGuildVoiceVideo(GuildVoiceVideoEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -319,7 +321,7 @@ public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) { } @Override - public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) { + public void onGuildVoiceStream(GuildVoiceStreamEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -331,7 +333,7 @@ public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) { } @Override - public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) { + public void onGuildVoiceMute(GuildVoiceMuteEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -343,7 +345,7 @@ public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) { } @Override - public void onGuildVoiceDeafen(@NotNull GuildVoiceDeafenEvent event) { + public void onGuildVoiceDeafen(GuildVoiceDeafenEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -380,10 +382,24 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { logger.debug("Received slash command '{}' (#{}) on guild '{}'", name, event.getId(), event.getGuild()); - COMMAND_SERVICE.execute( - () -> requireUserInteractor(UserInteractionType.SLASH_COMMAND.getPrefixedName(name), + COMMAND_SERVICE.execute(() -> { + try { + requireUserInteractor(UserInteractionType.SLASH_COMMAND.getPrefixedName(name), SlashCommand.class) - .onSlashCommand(event)); + .onSlashCommand(event); + + + analyticsService.recordCommandSuccess(event.getChannel().getIdLong(), name, + event.getUser().getIdLong()); + } catch (Exception ex) { + + analyticsService.recordCommandFailure(event.getChannel().getIdLong(), name, + event.getUser().getIdLong(), + ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()); + + throw ex; + } + }); } @Override diff --git a/application/src/main/resources/db/V16__Add_Analytics_System.sql b/application/src/main/resources/db/V16__Add_Analytics_System.sql index 36c83da607..a6b1d6c11f 100644 --- a/application/src/main/resources/db/V16__Add_Analytics_System.sql +++ b/application/src/main/resources/db/V16__Add_Analytics_System.sql @@ -1,13 +1,13 @@ CREATE TABLE command_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, command_name TEXT NOT NULL, user_id INTEGER NOT NULL, - executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL DEFAULT TRUE, error_message TEXT ); -CREATE INDEX idx_command_usage_guild ON command_usage(guild_id); +CREATE INDEX idx_command_usage_channel ON command_usage(channel_id); CREATE INDEX idx_command_usage_command_name ON command_usage(command_name); diff --git a/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java index ca91fe886b..fe82957c21 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/basic/PingCommandTest.java @@ -6,16 +6,13 @@ import org.junit.jupiter.api.Test; import org.togetherjava.tjbot.features.SlashCommand; -import org.togetherjava.tjbot.features.analytics.AnalyticsService; import org.togetherjava.tjbot.jda.JdaTester; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; final class PingCommandTest { private JdaTester jdaTester; private SlashCommand command; - private AnalyticsService analyticsService; private SlashCommandInteractionEvent triggerSlashCommand() { SlashCommandInteractionEvent event = @@ -27,8 +24,7 @@ private SlashCommandInteractionEvent triggerSlashCommand() { @BeforeEach void setUp() { jdaTester = new JdaTester(); - analyticsService = mock(AnalyticsService.class); - command = jdaTester.spySlashCommand(new PingCommand(analyticsService)); + command = jdaTester.spySlashCommand(new PingCommand()); } @Test From 071c1dcf5fa2deaa6379fe10884cbcab921820d9 Mon Sep 17 00:00:00 2001 From: Firas Regaieg Date: Mon, 2 Mar 2026 19:18:58 +0100 Subject: [PATCH 3/6] feat: update command usage analytics to use generated record methods --- .../features/analytics/AnalyticsService.java | 18 +++++++++++------- .../resources/db/V16__Add_Analytics_System.sql | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java index 450bd32217..7e1cebf305 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java @@ -1,11 +1,13 @@ package org.togetherjava.tjbot.features.analytics; import org.jetbrains.annotations.Nullable; -import org.jooq.impl.DSL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.CommandUsage; + +import java.time.Instant; /** * Service for tracking and recording command usage analytics. @@ -45,12 +47,14 @@ public AnalyticsService(Database database) { public void recordCommandExecution(long channelId, String commandName, long userId, boolean success, @Nullable String errorMessage) { - database.write(context -> context - .insertInto(DSL.table("command_usage"), DSL.field("channel_id"), - DSL.field("command_name"), DSL.field("user_id"), DSL.field("executed_at"), - DSL.field("success"), DSL.field("error_message")) - .values(channelId, commandName, userId, DSL.currentTimestamp(), success, errorMessage) - .execute()); + database.write(context -> context.newRecord(CommandUsage.COMMAND_USAGE) + .setChannelId(channelId) + .setCommandName(commandName) + .setUserId(userId) + .setExecutedAt(Instant.now()) + .setSuccess(success) + .setErrorMessage(errorMessage) + .insert()); if (!success && errorMessage != null) { logger.warn("Command '{}' failed on channel {} with error: {}", commandName, channelId, diff --git a/application/src/main/resources/db/V16__Add_Analytics_System.sql b/application/src/main/resources/db/V16__Add_Analytics_System.sql index a6b1d6c11f..12c1efb8d7 100644 --- a/application/src/main/resources/db/V16__Add_Analytics_System.sql +++ b/application/src/main/resources/db/V16__Add_Analytics_System.sql @@ -1,9 +1,9 @@ CREATE TABLE command_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL, + channel_id BIGINT NOT NULL, command_name TEXT NOT NULL, - user_id INTEGER NOT NULL, + user_id BIGINT NOT NULL, executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL DEFAULT TRUE, error_message TEXT From 25a4407f486366586debdaef413a696145e426a3 Mon Sep 17 00:00:00 2001 From: Firas Regaieg Date: Fri, 6 Mar 2026 21:51:43 +0100 Subject: [PATCH 4/6] Analytics: applies changes for zabuzard 1st CR; --- .../org/togetherjava/tjbot/Application.java | 5 +- .../togetherjava/tjbot/features/Features.java | 8 +- .../features/analytics/AnalyticsService.java | 88 ------------------- .../tjbot/features/analytics/Metrics.java | 59 +++++++++++++ .../features/analytics/package-info.java | 2 +- .../tjbot/features/basic/PingCommand.java | 8 -- .../tjbot/features/system/BotCore.java | 29 +++--- .../db/V16__Add_Analytics_System.sql | 15 +--- 8 files changed, 84 insertions(+), 130 deletions(-) delete mode 100644 application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index 4c228cb02a..ec5e92a2f3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -12,6 +12,7 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.Features; import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.system.BotCore; import org.togetherjava.tjbot.logging.LogMarkers; import org.togetherjava.tjbot.logging.discord.DiscordLogging; @@ -82,13 +83,15 @@ public static void runBot(Config config) { } Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); + Metrics metrics = new Metrics(database); + JDA jda = JDABuilder.createDefault(config.getToken()) .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT) .build(); jda.awaitReady(); - BotCore core = new BotCore(jda, database, config); + BotCore core = new BotCore(jda, database, config, metrics); CommandReloading.reloadCommands(jda, core); core.scheduleRoutines(jda); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 6febd433b6..e5e6ec9481 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -6,6 +6,7 @@ import org.togetherjava.tjbot.config.FeatureBlacklist; import org.togetherjava.tjbot.config.FeatureBlacklistConfig; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; @@ -91,7 +92,7 @@ * it with the system. *

* To add a new slash command, extend the commands returned by - * {@link #createFeatures(JDA, Database, Config)}. + * {@link #createFeatures(JDA, Database, Config, Metrics)}. */ public class Features { private Features() { @@ -107,9 +108,12 @@ private Features() { * @param jda the JDA instance commands will be registered at * @param database the database of the application, which features can use to persist data * @param config the configuration features should use + * @param metrics the metrics service for tracking analytics * @return a collection of all features */ - public static Collection createFeatures(JDA jda, Database database, Config config) { + @SuppressWarnings("unused") + public static Collection createFeatures(JDA jda, Database database, Config config, + Metrics metrics) { FeatureBlacklistConfig blacklistConfig = config.getFeatureBlacklistConfig(); JShellEval jshellEval = new JShellEval(config.getJshell(), config.getGitHubApiKey()); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java deleted file mode 100644 index 7e1cebf305..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/AnalyticsService.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.togetherjava.tjbot.features.analytics; - -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.db.generated.tables.CommandUsage; - -import java.time.Instant; - -/** - * Service for tracking and recording command usage analytics. - *

- * This service records every command execution along with its success/failure status, allowing for - * analysis of command usage patterns, popular commands, error rates, and activity over time. - *

- * Commands should call {@link #recordCommandExecution(long, String, long, boolean, String)} after - * execution to track their usage. - */ -public final class AnalyticsService { - private static final Logger logger = LoggerFactory.getLogger(AnalyticsService.class); - - private final Database database; - - /** - * Creates a new instance. - * - * @param database the database to use for storing and retrieving analytics data - */ - public AnalyticsService(Database database) { - this.database = database; - } - - /** - * Records a command execution with success/failure status. - *

- * This method should be called by commands after they complete execution to track usage - * patterns and error rates. - * - * @param channelId the channel ID where the command was executed - * @param commandName the name of the command that was executed - * @param userId the ID of the user who executed the command - * @param success whether the command executed successfully - * @param errorMessage optional error message if the command failed (null if successful) - */ - public void recordCommandExecution(long channelId, String commandName, long userId, - boolean success, @Nullable String errorMessage) { - - database.write(context -> context.newRecord(CommandUsage.COMMAND_USAGE) - .setChannelId(channelId) - .setCommandName(commandName) - .setUserId(userId) - .setExecutedAt(Instant.now()) - .setSuccess(success) - .setErrorMessage(errorMessage) - .insert()); - - if (!success && errorMessage != null) { - logger.warn("Command '{}' failed on channel {} with error: {}", commandName, channelId, - errorMessage); - } - } - - /** - * Records a successful command execution. - * - * @param channelId the channel ID where the command was executed - * @param commandName the name of the command that was executed - * @param userId the ID of the user who executed the command - */ - public void recordCommandSuccess(long channelId, String commandName, long userId) { - recordCommandExecution(channelId, commandName, userId, true, null); - } - - /** - * Records a failed command execution. - * - * @param channelId the channel ID where the command was executed - * @param commandName the name of the command that was executed - * @param userId the ID of the user who executed the command - * @param errorMessage a description of what went wrong - */ - public void recordCommandFailure(long channelId, String commandName, long userId, - String errorMessage) { - recordCommandExecution(channelId, commandName, userId, false, errorMessage); - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java new file mode 100644 index 0000000000..2b44fdedfd --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java @@ -0,0 +1,59 @@ +package org.togetherjava.tjbot.features.analytics; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.Analytics; + +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Service for tracking and recording events for analytics purposes. + */ +public final class Metrics { + private static final Logger logger = LoggerFactory.getLogger(Metrics.class); + + private final Database database; + + private final ExecutorService service = Executors.newSingleThreadExecutor(); + + /** + * Creates a new instance. + * + * @param database the database to use for storing and retrieving analytics data + */ + public Metrics(Database database) { + this.database = database; + } + + /** + * Track an event execution. + * + * @param event the event to save + */ + public void count(String event) { + logger.debug("Counting new record for event: {}", event); + Instant moment = Instant.now(); + service.submit(() -> persist(event, moment)); + + logger.debug("Event {} new record saved successfully", event); + } + + /** + * + * @param event the event to save + * @param moment the moment when the event is dispatched + */ + private void persist(String event, Instant moment) { + logger.debug("Persisting event: {}, at {}", event, moment); + database.write(context -> context.newRecord(Analytics.ANALYTICS) + .setEvent(event) + .setHappenedAt(moment) + .insert()); + logger.debug("Event {} persisted successfully", event); + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java index 067ce2e8fb..d06d76f93d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java @@ -2,7 +2,7 @@ * Analytics system for collecting and persisting bot activity metrics. *

* This package provides services and components that record events for later analysis and reporting - * across multiple feature areas, not limited to commands. + * across multiple feature areas. */ @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java index 35663d85d3..c4dc10a7bc 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/PingCommand.java @@ -1,6 +1,5 @@ package org.togetherjava.tjbot.features.basic; -import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import org.togetherjava.tjbot.features.CommandVisibility; @@ -12,7 +11,6 @@ * The implemented command is {@code /ping}, upon which the bot will respond with {@code Pong!}. */ public final class PingCommand extends SlashCommandAdapter { - /** * Creates an instance of the ping pong command. */ @@ -27,12 +25,6 @@ public PingCommand() { */ @Override public void onSlashCommand(SlashCommandInteractionEvent event) { - Guild guild = event.getGuild(); - if (guild == null) { - event.reply("This command can only be used in a server!").setEphemeral(true).queue(); - return; - } - event.reply("Pong!").queue(); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java index d0812dba13..da4e47fcd8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java @@ -41,7 +41,7 @@ import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; import org.togetherjava.tjbot.features.VoiceReceiver; -import org.togetherjava.tjbot.features.analytics.AnalyticsService; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.componentids.ComponentId; import org.togetherjava.tjbot.features.componentids.ComponentIdParser; import org.togetherjava.tjbot.features.componentids.ComponentIdStore; @@ -85,7 +85,7 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { private final ComponentIdStore componentIdStore; private final Map channelNameToMessageReceiver = new HashMap<>(); private final Map channelNameToVoiceReceiver = new HashMap<>(); - private final AnalyticsService analyticsService; + private final Metrics metrics; /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -95,12 +95,11 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { * @param jda the JDA instance that this command system will be used with * @param database the database that commands may use to persist data * @param config the configuration to use for this system + * @param metrics the metrics service for tracking analytics */ - public BotCore(JDA jda, Database database, Config config) { - Collection features = Features.createFeatures(jda, database, config); - - // Initialize analytics service - analyticsService = new AnalyticsService(database); + public BotCore(JDA jda, Database database, Config config, Metrics metrics) { + this.metrics = metrics; + Collection features = Features.createFeatures(jda, database, config, metrics); // Message receivers features.stream() @@ -383,22 +382,14 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { logger.debug("Received slash command '{}' (#{}) on guild '{}'", name, event.getId(), event.getGuild()); COMMAND_SERVICE.execute(() -> { - try { - requireUserInteractor(UserInteractionType.SLASH_COMMAND.getPrefixedName(name), - SlashCommand.class) - .onSlashCommand(event); + SlashCommand interactor = requireUserInteractor( + UserInteractionType.SLASH_COMMAND.getPrefixedName(name), SlashCommand.class); - analyticsService.recordCommandSuccess(event.getChannel().getIdLong(), name, - event.getUser().getIdLong()); - } catch (Exception ex) { + metrics.count("slash-" + name); - analyticsService.recordCommandFailure(event.getChannel().getIdLong(), name, - event.getUser().getIdLong(), - ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()); + interactor.onSlashCommand(event); - throw ex; - } }); } diff --git a/application/src/main/resources/db/V16__Add_Analytics_System.sql b/application/src/main/resources/db/V16__Add_Analytics_System.sql index 12c1efb8d7..038fae93f9 100644 --- a/application/src/main/resources/db/V16__Add_Analytics_System.sql +++ b/application/src/main/resources/db/V16__Add_Analytics_System.sql @@ -1,13 +1,6 @@ -CREATE TABLE command_usage +CREATE TABLE analytics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id BIGINT NOT NULL, - command_name TEXT NOT NULL, - user_id BIGINT NOT NULL, - executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - success BOOLEAN NOT NULL DEFAULT TRUE, - error_message TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL, + happened_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); - -CREATE INDEX idx_command_usage_channel ON command_usage(channel_id); -CREATE INDEX idx_command_usage_command_name ON command_usage(command_name); From a27f89e5e253c42f794880b7401b717521c378fc Mon Sep 17 00:00:00 2001 From: Firas Regaieg Date: Tue, 10 Mar 2026 19:28:46 +0100 Subject: [PATCH 5/6] Analytics - Metrics > persist(): rename argument moment to happenedAt --- .../togetherjava/tjbot/features/analytics/Metrics.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java index 2b44fdedfd..7717387af6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java @@ -39,19 +39,18 @@ public void count(String event) { Instant moment = Instant.now(); service.submit(() -> persist(event, moment)); - logger.debug("Event {} new record saved successfully", event); } /** * * @param event the event to save - * @param moment the moment when the event is dispatched + * @param happenedAt the moment when the event is dispatched */ - private void persist(String event, Instant moment) { - logger.debug("Persisting event: {}, at {}", event, moment); + private void persist(String event, Instant happenedAt) { + logger.debug("Persisting event: {}, at {}", event, happenedAt); database.write(context -> context.newRecord(Analytics.ANALYTICS) .setEvent(event) - .setHappenedAt(moment) + .setHappenedAt(happenedAt) .insert()); logger.debug("Event {} persisted successfully", event); } From 75618d5147920ed875518635c1ffc994b6c7f14c Mon Sep 17 00:00:00 2001 From: Firas Regaieg Date: Tue, 10 Mar 2026 21:06:39 +0100 Subject: [PATCH 6/6] Analytics update - Metrics: renaming persist() to processEvent(); - resources/db: V16 update, removing default value for happened_at column --- .../togetherjava/tjbot/features/analytics/Metrics.java | 10 ++++------ .../main/resources/db/V16__Add_Analytics_System.sql | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java index 7717387af6..9aa0c797fe 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java @@ -4,7 +4,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.db.generated.tables.Analytics; +import org.togetherjava.tjbot.db.generated.tables.MetricEvents; import java.time.Instant; import java.util.concurrent.ExecutorService; @@ -37,7 +37,7 @@ public Metrics(Database database) { public void count(String event) { logger.debug("Counting new record for event: {}", event); Instant moment = Instant.now(); - service.submit(() -> persist(event, moment)); + service.submit(() -> processEvent(event, moment)); } @@ -46,13 +46,11 @@ public void count(String event) { * @param event the event to save * @param happenedAt the moment when the event is dispatched */ - private void persist(String event, Instant happenedAt) { - logger.debug("Persisting event: {}, at {}", event, happenedAt); - database.write(context -> context.newRecord(Analytics.ANALYTICS) + private void processEvent(String event, Instant happenedAt) { + database.write(context -> context.newRecord(MetricEvents.METRIC_EVENTS) .setEvent(event) .setHappenedAt(happenedAt) .insert()); - logger.debug("Event {} persisted successfully", event); } } diff --git a/application/src/main/resources/db/V16__Add_Analytics_System.sql b/application/src/main/resources/db/V16__Add_Analytics_System.sql index 038fae93f9..a29a62e513 100644 --- a/application/src/main/resources/db/V16__Add_Analytics_System.sql +++ b/application/src/main/resources/db/V16__Add_Analytics_System.sql @@ -1,6 +1,6 @@ -CREATE TABLE analytics +CREATE TABLE metric_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL, - happened_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + happened_at TIMESTAMP NOT NULL );