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/Metrics.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java new file mode 100644 index 0000000000..9aa0c797fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java @@ -0,0 +1,56 @@ +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.MetricEvents; + +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(() -> processEvent(event, moment)); + + } + + /** + * + * @param event the event to save + * @param happenedAt the moment when the event is dispatched + */ + private void processEvent(String event, Instant happenedAt) { + database.write(context -> context.newRecord(MetricEvents.METRIC_EVENTS) + .setEvent(event) + .setHappenedAt(happenedAt) + .insert()); + } + +} 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..d06d76f93d --- /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. + */ +@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/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java index e9d99bc4d1..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 @@ -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.Metrics; 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 Metrics metrics; /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -95,10 +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) { - this.config = config; - Collection features = Features.createFeatures(jda, database, config); + 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() @@ -300,14 +301,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 +320,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 +332,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 +344,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 +381,16 @@ 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), - SlashCommand.class) - .onSlashCommand(event)); + COMMAND_SERVICE.execute(() -> { + + SlashCommand interactor = requireUserInteractor( + UserInteractionType.SLASH_COMMAND.getPrefixedName(name), SlashCommand.class); + + metrics.count("slash-" + name); + + interactor.onSlashCommand(event); + + }); } @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 new file mode 100644 index 0000000000..a29a62e513 --- /dev/null +++ b/application/src/main/resources/db/V16__Add_Analytics_System.sql @@ -0,0 +1,6 @@ +CREATE TABLE metric_events +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL, + happened_at TIMESTAMP NOT NULL +);