Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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.
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
* <p>
* 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.
* <p>
* Commands should call {@link #recordCommandExecution(long, String, long, boolean, String)} after
* execution to track their usage.
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
*/
public final class AnalyticsService {
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
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.
* <p>
* 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,
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
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());
Comment thread
Zabuzard marked this conversation as resolved.
Outdated

if (!success && errorMessage != null) {
logger.warn("Command '{}' failed on channel {} with error: {}", commandName, channelId,
errorMessage);
}
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
}

/**
* 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);
}
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Analytics system for collecting and persisting bot activity metrics.
* <p>
* This package provides services and components that record events for later analysis and reporting
* across multiple feature areas, not limited to commands.
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
*/
@MethodsReturnNonnullByDefault
@ParametersAreNonnullByDefault
package org.togetherjava.tjbot.features.analytics;

import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault;

import javax.annotation.ParametersAreNonnullByDefault;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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;
Expand All @@ -11,6 +12,7 @@
* 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.
*/
Expand All @@ -25,6 +27,12 @@ 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;
}

Comment thread
Zabuzard marked this conversation as resolved.
Outdated
event.reply("Pong!").queue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
firasrg marked this conversation as resolved.
Comment thread
Zabuzard marked this conversation as resolved.
private final Map<String, UserInteractor> prefixedNameToInteractor;
private final List<Routine> routines;
private final ComponentIdParser componentIdParser;
private final ComponentIdStore componentIdStore;
private final Map<Pattern, MessageReceiver> channelNameToMessageReceiver = new HashMap<>();
private final Map<Pattern, VoiceReceiver> channelNameToVoiceReceiver = new HashMap<>();
private final AnalyticsService analyticsService;
Comment thread
Zabuzard marked this conversation as resolved.
Outdated

/**
* Creates a new command system which uses the given database to allow commands to persist data.
Expand All @@ -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;
Comment thread
Zabuzard marked this conversation as resolved.
Collection<Feature> features = Features.createFeatures(jda, database, config);

// Initialize analytics service
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
analyticsService = new AnalyticsService(database);
Comment thread
Zabuzard marked this conversation as resolved.
Outdated

// Message receivers
features.stream()
.filter(MessageReceiver.class::isInstance)
Expand Down Expand Up @@ -300,14 +302,14 @@ private Optional<Channel> selectPreferredAudioChannel(@Nullable AudioChannelUnio
}

@Override
public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
Comment thread
Zabuzard marked this conversation as resolved.
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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
});
}

@Override
Expand Down
13 changes: 13 additions & 0 deletions application/src/main/resources/db/V16__Add_Analytics_System.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE command_usage
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
(
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
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
);

CREATE INDEX idx_command_usage_channel ON command_usage(channel_id);
CREATE INDEX idx_command_usage_command_name ON command_usage(command_name);
Comment thread
Zabuzard marked this conversation as resolved.
Outdated
Loading