diff --git a/PP.md b/PP.md index 31544ac609..144f5c848c 100644 --- a/PP.md +++ b/PP.md @@ -44,6 +44,7 @@ In certain circumstances, you have the following data protection rights: The databases may store * `user_id` of users (the unique id of a Discord account), +* `user_name` of users (the username of a Discord account), stored as part of metric event dimensions when tracking command usage (e.g. slash commands, tag lookups), * `timestamp`s of actions (for example when a command has been used), * `guild_id` of guilds the **bot** is member of (the unique id of a Discord guild), * `channel_id` of channels belonging to guilds the **bot** is member of (the unique id of a Discord channel), @@ -51,7 +52,8 @@ The databases may store * `participant_count` of no of people who participated in help thread discussions, * `tags` aka categories to which these help threads belong to, * `timestamp`s for both when thread was created and closed, -* `message_count` the no of messages that were sent in lifecycle of any help thread +* `message_count` the no of messages that were sent in lifecycle of any help thread, +* `dimensions` optional JSON metadata attached to metric events, which may include the `user_name` of the user who triggered the event and contextual details such as the command name or tag id _Note: Help threads are just threads that are created via forum channels, used for anyone to ask questions and get help in certain problems._ diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java index f90b11b68b..a5c054bee9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java @@ -8,6 +8,8 @@ import org.togetherjava.tjbot.features.MessageReceiverAdapter; +import java.util.Map; + /** * Listener that tracks custom emoji usage across all channels for analytics purposes. *

@@ -18,6 +20,7 @@ * custom emojis are tracked separately (e.g. {@code emoji-custom-animated-123456789}). */ public final class EmojiTrackerListener extends MessageReceiverAdapter { + private static final String METRIC_NAME = "emoji"; private final Metrics metrics; /** @@ -37,7 +40,11 @@ public void onMessageReceived(MessageReceivedEvent event) { return; } - event.getMessage().getMentions().getCustomEmojis().forEach(this::trackCustomEmoji); + event.getMessage() + .getMentions() + .getCustomEmojis() + .forEach(customEmoji -> trackCustomEmoji("message", customEmoji.getIdLong(), + customEmoji.isAnimated())); } @Override @@ -47,11 +54,12 @@ public void onMessageReactionAdd(MessageReactionAddEvent event) { return; } - trackCustomEmoji(emoji.asCustom()); + CustomEmoji customEmoji = emoji.asCustom(); + + trackCustomEmoji("reaction", customEmoji.getIdLong(), customEmoji.isAnimated()); } - private void trackCustomEmoji(CustomEmoji emoji) { - String prefix = emoji.isAnimated() ? "emoji-custom-animated-" : "emoji-custom-"; - metrics.count(prefix + emoji.getIdLong()); + private void trackCustomEmoji(String type, long id, boolean isAnimated) { + metrics.count(METRIC_NAME, Map.of("type", type, "id", id, "animated", isAnimated)); } } 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 9aa0c797fe..9da595eaab 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 @@ -1,12 +1,17 @@ package org.togetherjava.tjbot.features.analytics; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.MetricEvents; +import javax.annotation.Nullable; + import java.time.Instant; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -15,6 +20,7 @@ */ public final class Metrics { private static final Logger logger = LoggerFactory.getLogger(Metrics.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final Database database; @@ -35,21 +41,48 @@ public Metrics(Database database) { * @param event the event to save */ public void count(String event) { + count(event, Map.of()); + } + + /** + * Track an event execution with additional contextual data. + * + * @param event the name of the event to record (e.g. "user_signup", "purchase") + * @param dimensions optional key-value pairs providing extra context about the event. These are + * often referred to as "metadata" and can include things like: userId: "12345", name: + * "John Smith", channel_name: "chit-chat" etc. This data helps with filtering, grouping, + * and analyzing events later. Note: A value for a metric should be a Java primitive + * (String, int, double, long float). + */ + public void count(String event, Map dimensions) { logger.debug("Counting new record for event: {}", event); - Instant moment = Instant.now(); - service.submit(() -> processEvent(event, moment)); + Instant happenedAt = Instant.now(); + String serializedDimensions = serializeDimensions(dimensions); + + service.submit(() -> processEvent(event, happenedAt, + dimensions.isEmpty() ? null : serializedDimensions)); + } + + private static String serializeDimensions(Map dimensions) { + try { + return OBJECT_MAPPER.writeValueAsString(dimensions); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to serialize dimensions", e); + } } /** * * @param event the event to save * @param happenedAt the moment when the event is dispatched + * @param dimensionsJson optional JSON-serialized dimensions, or null */ - private void processEvent(String event, Instant happenedAt) { + private void processEvent(String event, Instant happenedAt, @Nullable String dimensionsJson) { database.write(context -> context.newRecord(MetricEvents.METRIC_EVENTS) .setEvent(event) .setHappenedAt(happenedAt) + .setDimensions(dimensionsJson) .insert()); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java index 38b2c44164..bddd391352 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java @@ -188,7 +188,7 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { CodeFence code = extractCodeOrFallback(originalMessage.get().getContentRaw()); // Apply the selected action - metrics.count("code_action-" + codeAction.getLabel()); + metrics.count("code_action", Map.of("name", codeAction.getLabel())); return event.getHook() .editOriginalEmbeds(codeAction.apply(code)) .setActionRow(createButtons(originalMessageId, codeAction)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java index 34ac5140ba..86d952fbc6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java @@ -112,7 +112,7 @@ private void pruneRoleIfFull(List members, Role targetRole, if (isRoleFull(withRole)) { logger.debug("Helper role {} is full, starting to prune.", targetRole.getName()); - metrics.count("autoprune_helper-" + targetRole.getName()); + metrics.count("autoprune_helper", Map.of("role", targetRole.getName())); pruneRole(targetRole, withRole, selectRoleChannel, when); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java index 459fc5a904..b20799c1f5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java @@ -162,7 +162,7 @@ private void changeCategory(SlashCommandInteractionEvent event, ThreadChannel he event.deferReply().queue(); refreshCooldownFor(Subcommand.CHANGE_CATEGORY, helpThread); - metrics.count("help-category-" + category); + metrics.count("help-category", Map.of(CHANGE_CATEGORY_SUBCOMMAND, category)); helper.changeChannelCategory(helpThread, category) .flatMap(_ -> sendCategoryChangedMessage(helpThread.getGuild(), event.getHook(), helpThread, category)) 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 5dcdc0a81a..eaef464b5d 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 @@ -385,11 +385,16 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { SlashCommand interactor = requireUserInteractor( UserInteractionType.SLASH_COMMAND.getPrefixedName(name), SlashCommand.class); - String eventName = "slash-" + name; + Map dimensions = new HashMap<>(); + dimensions.put("name", name); + dimensions.put("user", event.getUser().getName()); + dimensions.put("userId", event.getUser().getIdLong()); + if (event.getSubcommandName() != null) { - eventName += "_" + event.getSubcommandName(); + dimensions.put("subCommandName", event.getSubcommandName()); } - metrics.count(eventName); + + metrics.count("slash", dimensions); interactor.onSlashCommand(event); }); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java index 7581c5c750..ac0386338f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -91,7 +92,8 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { if (tagSystem.handleIsUnknownTag(id, event)) { return; } - metrics.count("tag-" + id); + metrics.count("tag", Map.of("id", id, "user", event.getUser().getName(), "userId", + event.getUser().getIdLong())); String tagContent = tagSystem.getTag(id).orElseThrow(); MessageEmbed contentEmbed = new EmbedBuilder().setDescription(tagContent) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index cb6449a24f..bc571e200c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -260,7 +260,7 @@ private void manageTopHelperRole(Collection currentTopHelpers, } for (long topHelperUserId : selectedTopHelperIds) { - metrics.count("top_helper-" + topHelperUserId); + metrics.count("top_helper", Map.of("userId", topHelperUserId)); } reportRoleManageSuccess(event); } diff --git a/application/src/main/resources/db/V17__Add_Metric_Dimensions.sql b/application/src/main/resources/db/V17__Add_Metric_Dimensions.sql new file mode 100644 index 0000000000..01685ad953 --- /dev/null +++ b/application/src/main/resources/db/V17__Add_Metric_Dimensions.sql @@ -0,0 +1,41 @@ +ALTER TABLE metric_events ADD COLUMN dimensions TEXT; + +UPDATE metric_events +SET event = 'code_action', + dimensions = json_object('name', SUBSTR(event, LENGTH('code_action-') + 1)) +WHERE event LIKE 'code_action-%'; + +UPDATE metric_events +SET event = 'autoprune_helper', + dimensions = json_object('role', SUBSTR(event, LENGTH('autoprune_helper-') + 1)) +WHERE event LIKE 'autoprune_helper-%'; + +UPDATE metric_events +SET event = 'help-category', + dimensions = json_object('category', SUBSTR(event, LENGTH('help-category-') + 1)) +WHERE event LIKE 'help-category-%'; + +UPDATE metric_events +SET event = 'tag', + dimensions = json_object('id', SUBSTR(event, LENGTH('tag-') + 1)) +WHERE event LIKE 'tag-%'; + +UPDATE metric_events +SET event = 'top_helper', + dimensions = json_object('userId', CAST(SUBSTR(event, LENGTH('top_helper-') + 1) AS INTEGER)) +WHERE event LIKE 'top_helper-%'; + +UPDATE metric_events +SET event = 'slash', + dimensions = json_object( + 'name', SUBSTR(event, LENGTH('slash-') + 1, INSTR(SUBSTR(event, LENGTH('slash-') + 1), '_') - 1), + 'subCommandName', SUBSTR(event, LENGTH('slash-') + 1 + INSTR(SUBSTR(event, LENGTH('slash-') + 1), '_')) + ) +WHERE event LIKE 'slash-%' + AND INSTR(SUBSTR(event, LENGTH('slash-') + 1), '_') > 0; + +UPDATE metric_events +SET event = 'slash', + dimensions = json_object('name', SUBSTR(event, LENGTH('slash-') + 1)) +WHERE event LIKE 'slash-%' + AND INSTR(SUBSTR(event, LENGTH('slash-') + 1), '_') = 0; \ No newline at end of file