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
+);