diff --git a/API/pom.xml b/API/pom.xml index 20295bc..1d9bac2 100644 --- a/API/pom.xml +++ b/API/pom.xml @@ -105,6 +105,20 @@ compile + + + io.sentry + sentry-spring-boot-starter-jakarta + 8.0.0-alpha.4 + compile + + + org.questdb + questdb + 8.1.1 + compile + + com.konghq unirest-java-core diff --git a/API/src/main/java/me/braydon/tether/common/IOperatingSystemMXBean.java b/API/src/main/java/me/braydon/tether/common/IOperatingSystemMXBean.java new file mode 100644 index 0000000..7bfa319 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/common/IOperatingSystemMXBean.java @@ -0,0 +1,16 @@ +package me.braydon.tether.common; + +/** + * @author Braydon + */ +public interface IOperatingSystemMXBean { + /** + * Get the system's CPU usage for the entire system. + * If the value is 0.0, all cores are idle. If the + * value is 1.0, all cores were 100% utilized. If + * the value is negative, the usage is not available. + * + * @return the system cpu load + */ + double getSystemCpuLoad(); +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/common/IPUtils.java b/API/src/main/java/me/braydon/tether/common/IPUtils.java new file mode 100644 index 0000000..a2cf100 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/common/IPUtils.java @@ -0,0 +1,44 @@ +package me.braydon.tether.common; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.NonNull; +import lombok.experimental.UtilityClass; + +/** + * @author Braydon + */ +@UtilityClass +public final class IPUtils { + private static final String[] IP_HEADERS = new String[] { + "CF-Connecting-IP", + "X-Forwarded-For" + }; + + /** + * Get the real IP from the given request. + * + * @param request the request + * @return the real IP + */ + @NonNull + public static String getRealIp(@NonNull HttpServletRequest request) { + String ip = request.getRemoteAddr(); + for (String headerName : IP_HEADERS) { + String header = request.getHeader(headerName); + if (header == null) { + continue; + } + if (!header.contains(",")) { // Handle single IP + ip = header; + break; + } + // Handle multiple IPs + String[] ips = header.split(","); + for (String ipHeader : ips) { + ip = ipHeader; + break; + } + } + return ip; + } +} diff --git a/API/src/main/java/me/braydon/tether/log/RequestLogger.java b/API/src/main/java/me/braydon/tether/log/RequestLogger.java index 83d8067..d2d6158 100644 --- a/API/src/main/java/me/braydon/tether/log/RequestLogger.java +++ b/API/src/main/java/me/braydon/tether/log/RequestLogger.java @@ -5,6 +5,9 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import me.braydon.tether.common.IPUtils; +import me.braydon.tether.metric.impl.RequestsMetric; +import me.braydon.tether.service.MetricsService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; @@ -24,11 +27,21 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; @ControllerAdvice @Slf4j(topic = "Req/Res Transaction") public class RequestLogger implements ResponseBodyAdvice { + /** + * The metrics service to use. + */ + @NonNull private final MetricsService metricsService; + + @Autowired + public RequestLogger(@NonNull MetricsService metricsService) { + this.metricsService = metricsService; + } + @Override public boolean supports(@NonNull MethodParameter returnType, @NonNull Class> converterType) { return true; } - + @Override public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType, @NonNull Class> selectedConverterType, @@ -36,6 +49,11 @@ public class RequestLogger implements ResponseBodyAdvice { HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest(); HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse(); + // Metrics + if (metricsService.isEnabled()) { + RequestsMetric.incrementCodeCount(response.getStatus()); + } + // Get the request ip ip String ip = IPUtils.getRealIp(request); diff --git a/API/src/main/java/me/braydon/tether/metric/Metric.java b/API/src/main/java/me/braydon/tether/metric/Metric.java new file mode 100644 index 0000000..c8675c4 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/metric/Metric.java @@ -0,0 +1,41 @@ +package me.braydon.tether.metric; + +import io.questdb.client.Sender; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import me.braydon.tether.common.EnvironmentUtils; + +/** + * @author Braydon + */ +@RequiredArgsConstructor @Setter @Getter +public abstract class Metric { + /** + * The interval (in millis) at which + * this metric should be tracked. + *

+ * If the interval is -1, it will not + * be automatically tracked. + *

+ */ + private final long interval; + + /** + * The unix time of when this metric was last tracked. + */ + private long lastTrack; + + @NonNull + public static Sender applyEnvColumn(@NonNull Sender sender) { + return sender.stringColumn("env", EnvironmentUtils.isProduction() ? "production" : "staging"); + } + + /** + * Track this metric. + * + * @param sender the sender to use + */ + public abstract void track(@NonNull Sender sender); +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/metric/impl/AppStatisticsMetric.java b/API/src/main/java/me/braydon/tether/metric/impl/AppStatisticsMetric.java new file mode 100644 index 0000000..3f3a720 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/metric/impl/AppStatisticsMetric.java @@ -0,0 +1,56 @@ +package me.braydon.tether.metric.impl; + +import io.questdb.client.Sender; +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +import me.braydon.tether.common.IOperatingSystemMXBean; +import me.braydon.tether.metric.Metric; + +import javax.management.JMX; +import javax.management.ObjectName; +import java.lang.management.ManagementFactory; +import java.util.concurrent.TimeUnit; + +/** + * This metric is responsible for tracking + * the statistics of this application. + * + * @author Braydon + */ +@Log4j2(topic = "App Statistics Collector") +public final class AppStatisticsMetric extends Metric { + /** + * The JMX to use for getting the system's CPU load, if available. + */ + private IOperatingSystemMXBean jmx; + + public AppStatisticsMetric() { + super(TimeUnit.SECONDS.toMillis(10L)); + try { + jmx = JMX.newMXBeanProxy( + ManagementFactory.getPlatformMBeanServer(), + ObjectName.getInstance("java.lang:type=OperatingSystem"), + IOperatingSystemMXBean.class + ); + } catch (Exception ex) { + log.error("OperatingSystemMXBean is not supported by the system, the system CPU usage won't be collected", ex); + } + } + + /** + * Track this metric. + * + * @param sender the sender to use + */ + @Override + public void track(@NonNull Sender sender) { + Runtime runtime = Runtime.getRuntime(); + applyEnvColumn(sender.table("app-statistics") + .doubleColumn("systemCpu", jmx.getSystemCpuLoad() * 100D) + .longColumn("usedMemory", (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)) + .longColumn("freeMemory", runtime.freeMemory() / (1024 * 1024)) + .longColumn("totalMemory", runtime.maxMemory() / (1024 * 1024)) + .longColumn("uptime", ManagementFactory.getRuntimeMXBean().getUptime())) + .atNow(); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/metric/impl/RequestsMetric.java b/API/src/main/java/me/braydon/tether/metric/impl/RequestsMetric.java new file mode 100644 index 0000000..b6baae5 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/metric/impl/RequestsMetric.java @@ -0,0 +1,48 @@ +package me.braydon.tether.metric.impl; + +import io.questdb.client.Sender; +import lombok.NonNull; +import me.braydon.tether.metric.Metric; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * @author Braydon + */ +public final class RequestsMetric extends Metric { + private static final Map codeCounts = new HashMap<>(); + + public RequestsMetric() { + super(TimeUnit.SECONDS.toMillis(10L)); + } + + /** + * Increment the request + * count for the given code. + * + * @param code the request code + */ + public static void incrementCodeCount(int code) { + codeCounts.merge(code, 1, Integer::sum); + } + + /** + * Track this metric. + * + * @param sender the sender to use + */ + @Override + public void track(@NonNull Sender sender) { + if (codeCounts.isEmpty()) { + return; + } + sender = applyEnvColumn(sender.table("requests")); + for (Map.Entry entry : codeCounts.entrySet()) { + sender.longColumn(String.valueOf(entry.getKey()), entry.getValue()); + } + sender.atNow(); + codeCounts.clear(); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/metric/impl/TrackedUsersMetric.java b/API/src/main/java/me/braydon/tether/metric/impl/TrackedUsersMetric.java new file mode 100644 index 0000000..93697e3 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/metric/impl/TrackedUsersMetric.java @@ -0,0 +1,49 @@ +package me.braydon.tether.metric.impl; + +import io.questdb.client.Sender; +import lombok.NonNull; +import me.braydon.tether.metric.Metric; +import me.braydon.tether.service.DiscordService; + +import java.util.concurrent.TimeUnit; + +/** + * @author Braydon + */ +public final class TrackedUsersMetric extends Metric { + /** + * The amount of recently watched users. + */ + private static int recentlyWatchedUsers; + + /** + * The Discord service to use. + */ + @NonNull private final DiscordService discordService; + + public TrackedUsersMetric(@NonNull DiscordService discordService) { + super(TimeUnit.SECONDS.toMillis(10L)); + this.discordService = discordService; + } + + /** + * Increment the amount of recently watched users. + */ + public static void incrementRecentlyWatchedUsers() { + recentlyWatchedUsers++; + } + + /** + * Track this metric. + * + * @param sender the sender to use + */ + @Override + public void track(@NonNull Sender sender) { + applyEnvColumn(sender.table("tracked-users") + .longColumn("current", discordService.getTrackedUsers()) + .longColumn("recent", recentlyWatchedUsers)) + .atNow(); + recentlyWatchedUsers = 0; + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/metric/impl/UserLookupTimingsMetric.java b/API/src/main/java/me/braydon/tether/metric/impl/UserLookupTimingsMetric.java new file mode 100644 index 0000000..f2947a8 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/metric/impl/UserLookupTimingsMetric.java @@ -0,0 +1,37 @@ +package me.braydon.tether.metric.impl; + +import io.questdb.client.Sender; +import lombok.NonNull; +import me.braydon.tether.metric.Metric; + +/** + * @author Braydon + */ +public final class UserLookupTimingsMetric extends Metric { + private static Sender sender; + + public UserLookupTimingsMetric(@NonNull Sender sender) { + super(-1); + UserLookupTimingsMetric.sender = sender; + } + + /** + * Track this metric. + * + * @param timings the timings + */ + public static void track(long timings) { + applyEnvColumn(sender.table("timings") + .stringColumn("type", "user-lookup") + .longColumn("value", timings)) + .atNow(); + } + + /** + * Track this metric. + * + * @param sender the sender to use + */ + @Override + public void track(@NonNull Sender sender) {} +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/service/DiscordService.java b/API/src/main/java/me/braydon/tether/service/DiscordService.java index 81d3382..f8888ad 100644 --- a/API/src/main/java/me/braydon/tether/service/DiscordService.java +++ b/API/src/main/java/me/braydon/tether/service/DiscordService.java @@ -14,6 +14,8 @@ import lombok.extern.log4j.Log4j2; import me.braydon.tether.exception.impl.BadRequestException; import me.braydon.tether.exception.impl.ResourceNotFoundException; import me.braydon.tether.exception.impl.ServiceUnavailableException; +import me.braydon.tether.metric.impl.TrackedUsersMetric; +import me.braydon.tether.metric.impl.UserLookupTimingsMetric; import me.braydon.tether.model.response.DiscordUserResponse; import me.braydon.tether.model.user.CachedDiscordUser; import me.braydon.tether.model.user.DiscordUser; @@ -23,9 +25,12 @@ import net.dv8tion.jda.api.entities.Activity; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.SelfUser; +import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.requests.GatewayIntent; import net.dv8tion.jda.api.utils.cache.CacheFlag; +import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; @@ -40,7 +45,7 @@ import java.util.concurrent.TimeUnit; */ @Service @Log4j2(topic = "Discord") -public final class DiscordService { +public final class DiscordService extends ListenerAdapter { /** * A cache of users retrieved from Discord. */ @@ -48,12 +53,24 @@ public final class DiscordService { .expireAfterWrite(3L, TimeUnit.MINUTES) .build(); + /** + * The token to use for the bot. + */ @Value("${discord.bot-token}") private String botToken; + /** + * The token to use for the user account. + */ @Value("${discord.user-account-token}") private String userAccountToken; + /** + * Are metrics enabled? + */ + @Value("${questdb.enabled}") + private boolean metricsEnabled; + /** * The current instance of the Discord bot. */ @@ -64,6 +81,13 @@ public final class DiscordService { connectBot(); } + @Override + public void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) { + if (metricsEnabled) { + TrackedUsersMetric.incrementRecentlyWatchedUsers(); + } + } + /** * Get a Discord user by their snowflake. * @@ -103,7 +127,11 @@ public final class DiscordService { CachedDiscordUser cachedUser = cachedUsers.getIfPresent(snowflake); boolean fromCache = cachedUser != null; if (cachedUser == null) { // No cache, retrieve fresh data + long before = System.currentTimeMillis(); cachedUser = new CachedDiscordUser(getUser(snowflake, member != null), System.currentTimeMillis()); + if (metricsEnabled) { + UserLookupTimingsMetric.track(System.currentTimeMillis() - before); + } cachedUsers.put(snowflake, cachedUser); } @@ -121,6 +149,20 @@ public final class DiscordService { } } + /** + * Return the amount of users + * the bot is tracking. + * + * @return the tracked user count + */ + public int getTrackedUsers() { + int tracked = 0; + for (Guild guild : jda.getGuilds()) { + tracked += guild.getMemberCount(); + } + return tracked; + } + /** * Connects the bot to the Discord API. */ diff --git a/API/src/main/java/me/braydon/tether/service/MetricsService.java b/API/src/main/java/me/braydon/tether/service/MetricsService.java new file mode 100644 index 0000000..61f6af2 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/service/MetricsService.java @@ -0,0 +1,102 @@ +package me.braydon.tether.service; + +import io.questdb.client.Sender; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +import me.braydon.tether.metric.Metric; +import me.braydon.tether.metric.impl.AppStatisticsMetric; +import me.braydon.tether.metric.impl.RequestsMetric; +import me.braydon.tether.metric.impl.TrackedUsersMetric; +import me.braydon.tether.metric.impl.UserLookupTimingsMetric; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.LinkedList; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; + +/** + * @author Braydon + */ +@Service @Log4j2(topic = "Metrics") +public final class MetricsService { + /** + * The Discord service some metrics use. + */ + @NonNull private final DiscordService discordService; + + /** + * The metrics to automatically track. + */ + @NonNull private final List metrics = new LinkedList<>(); + + /** + * Are metrics enabled? + */ + @Value("${questdb.enabled}") + @Getter private boolean enabled; + + /** + * The URI to the metrics server. + */ + @Value("${questdb.uri}") + private String dbUrl; + + /** + * The sender client. + */ + private Sender sender; + + @Autowired + public MetricsService(@NonNull DiscordService discordService) { + this.discordService = discordService; + } + + @PostConstruct + public void onInitialize() { + if (!enabled) { + return; + } + // Initialize the sender, and schedule a task to start tracking + sender = Sender.fromConfig(dbUrl); + log.info("Sender Configured!"); + + // Register metrics + metrics.add(new AppStatisticsMetric()); + metrics.add(new TrackedUsersMetric(discordService)); + metrics.add(new RequestsMetric()); + new UserLookupTimingsMetric(sender); + log.info("Tracking {} metrics...", metrics.size()); + + new Timer().scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + trackMetrics(); + } + }, 1000L, 1000L); + } + + /** + * Track the registered metrics. + */ + private void trackMetrics() { + int points = 0; + for (Metric metric : metrics) { + long now = System.currentTimeMillis(); + if (metric.getInterval() == -1L || (now - metric.getLastTrack()) < metric.getInterval()) { + continue; + } + metric.setLastTrack(now); + metric.track(sender); + points++; + } + // Record the amount of points recorded a second + Metric.applyEnvColumn(sender.table("data-points") + .longColumn("value", points)) + .atNow(); + } +} \ No newline at end of file diff --git a/API/src/main/resources/application.yml b/API/src/main/resources/application.yml index e195fac..3d019dc 100644 --- a/API/src/main/resources/application.yml +++ b/API/src/main/resources/application.yml @@ -8,6 +8,12 @@ logging: file: path: "./logs" +# Sentry Configuration +sentry: + dsn: "CHANGE_ME" + tracesSampleRate: 1.0 + environment: "development" + # Discord Configuration discord: # The user account token also for general API calls @@ -16,6 +22,11 @@ discord: # The bot token for realtime API calls (online status, activities, etc) bot-token: "CHANGE_ME" +# QuestDB Configuration (Metrics) +questdb: + enabled: false + uri: "http::addr=localhost:9000;username=tether;password=p4$$w0rd;auto_flush_interval=5000;" + # Spring Configuration spring: # Ignore