diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 32f6aa4..d31de45 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -37,6 +37,9 @@ dependencies { api("org.xerial:sqlite-jdbc:3.51.1.0") api("it.unimi.dsi:fastutil:8.5.18") + api("io.micrometer:micrometer-core:1.13.6") + api("io.micrometer:micrometer-registry-prometheus:1.13.6") + api("org.jetbrains:annotations:26.0.2-1") // api("org.apache.logging.log4j:log4j-core:3.0.0-beta3") diff --git a/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java b/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java index 16259b0..3e866b5 100644 --- a/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java +++ b/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java @@ -4,9 +4,14 @@ import dev.felnull.itts.core.audio.VoiceAudioManager; import dev.felnull.itts.core.cache.CacheManager; import dev.felnull.itts.core.config.ConfigManager; +import dev.felnull.itts.core.config.MetricsConfig; import dev.felnull.itts.core.dict.DictionaryManager; import dev.felnull.itts.core.discord.Bot; +import dev.felnull.itts.core.metrics.MetricsRegistry; +import dev.felnull.itts.core.metrics.PrometheusHttpExposer; +import dev.felnull.itts.core.metrics.PrometheusMetricsRegistry; import dev.felnull.itts.core.savedata.SaveDataManager; +import dev.felnull.itts.core.tts.TTSCountRecorder; import dev.felnull.itts.core.tts.TTSManager; import dev.felnull.itts.core.voice.VoiceManager; import org.apache.commons.lang3.concurrent.BasicThreadFactory; @@ -129,6 +134,22 @@ public class ITTSRuntime { */ private long startupTime; + /** + * メトリクスレジストリ + * 公開無効時はNoOp実装が入る + */ + private MetricsRegistry metricsRegistry; + + /** + * Prometheusメトリクス公開HTTPサーバー + */ + private PrometheusHttpExposer prometheusHttpExposer; + + /** + * 読み上げ文字数のレコーダー + */ + private TTSCountRecorder ttsCountRecorder; + private ITTSRuntime(ITTSRuntimeContext runtimeContext) { if (instance != null) { throw new IllegalStateException("ITTSRuntime must be a singleton instance"); @@ -202,9 +223,53 @@ public void execute() { logger.info("Setup complete"); + initMetrics(); + registerShutdownHooks(); + bot.start(); } + /** + * メトリクス関連コンポーネントを生成し起動する + * 公開無効時はNoOp実装を採用しnullを返さない + */ + private void initMetrics() { + MetricsConfig metricsConfig = configManager.getConfig().getMetricsConfig(); + + if (!metricsConfig.isEnabled()) { + this.metricsRegistry = MetricsRegistry.noop(); + this.ttsCountRecorder = new TTSCountRecorder(metricsRegistry); + logger.info("Prometheus metrics is disabled"); + return; + } + + PrometheusMetricsRegistry prometheusRegistry = new PrometheusMetricsRegistry(); + this.metricsRegistry = prometheusRegistry; + this.ttsCountRecorder = new TTSCountRecorder(metricsRegistry); + + try { + this.prometheusHttpExposer = new PrometheusHttpExposer(prometheusRegistry); + this.prometheusHttpExposer.start(metricsConfig.getBindAddress(), metricsConfig.getPort()); + logger.info("Prometheus metrics endpoint started on {}:{}/metrics", metricsConfig.getBindAddress(), metricsConfig.getPort()); + } catch (Exception e) { + logger.warn("Failed to start Prometheus HTTP exposer", e); + this.prometheusHttpExposer = null; + } + } + + /** + * シャットダウンフックを登録する + * メトリクスHTTPサーバなどライフサイクル管理対象を集約する + */ + private void registerShutdownHooks() { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + PrometheusHttpExposer exposer = this.prometheusHttpExposer; + if (exposer != null) { + exposer.stop(); + } + }, "prometheus-exposer-shutdown")); + } + public long getStartupTime() { return startupTime; } @@ -273,4 +338,23 @@ public ITTSNetworkManager getNetworkManager() { public Bot getBot() { return bot; } + + /** + * 読み上げ文字数のレコーダーを取得 + * + * @return レコーダー + */ + public TTSCountRecorder getTTSCountRecorder() { + return ttsCountRecorder; + } + + /** + * メトリクスレジストリを取得 + * 公開無効時はNoOp実装が返るためnullにはならない + * + * @return メトリクスレジストリ + */ + public MetricsRegistry getMetricsRegistry() { + return metricsRegistry; + } } diff --git a/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java b/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java index 8c7c8f3..6664d7c 100644 --- a/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java +++ b/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java @@ -5,6 +5,8 @@ import dev.felnull.itts.core.config.ConfigManager; import dev.felnull.itts.core.dict.DictionaryManager; import dev.felnull.itts.core.discord.Bot; +import dev.felnull.itts.core.metrics.MetricsRegistry; +import dev.felnull.itts.core.tts.TTSCountRecorder; import dev.felnull.itts.core.tts.TTSManager; import dev.felnull.itts.core.voice.VoiceManager; import org.apache.logging.log4j.Logger; @@ -83,4 +85,23 @@ default Bot getBot() { default ITTSNetworkManager getNetworkManager() { return getITTSRuntime().getNetworkManager(); } + + /** + * 読み上げ文字数のレコーダーを取得 + * + * @return TTSカウントレコーダー + */ + default TTSCountRecorder getTTSCountRecorder() { + return getITTSRuntime().getTTSCountRecorder(); + } + + /** + * メトリクスレジストリを取得 + * 公開無効時もNoOp実装が返るためnullにはならない + * + * @return メトリクスレジストリ + */ + default MetricsRegistry getMetricsRegistry() { + return getITTSRuntime().getMetricsRegistry(); + } } diff --git a/core/src/main/java/dev/felnull/itts/core/audio/VoiceAudioScheduler.java b/core/src/main/java/dev/felnull/itts/core/audio/VoiceAudioScheduler.java index b47b31b..053cbd3 100644 --- a/core/src/main/java/dev/felnull/itts/core/audio/VoiceAudioScheduler.java +++ b/core/src/main/java/dev/felnull/itts/core/audio/VoiceAudioScheduler.java @@ -6,6 +6,7 @@ import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; import dev.felnull.itts.core.ITTSRuntimeUse; import dev.felnull.itts.core.audio.loader.VoiceTrackLoader; +import dev.felnull.itts.core.tts.TTSCountRecorder; import dev.felnull.itts.core.tts.saidtext.SaidText; import dev.felnull.itts.core.util.TTSUtils; import dev.felnull.itts.core.voice.Voice; @@ -94,7 +95,15 @@ public CompletableFuture load(SaidText saidText) { Objects.requireNonNull(voice, "Voice is null"); - return Pair.of(TTSUtils.roundText(voice, guildId, sayText, false), voice); + String finalText = TTSUtils.roundText(voice, guildId, sayText, false); + + TTSCountRecorder recorder = getTTSCountRecorder(); + if (recorder != null && finalText != null) { + long botId = getBot().getBotId(); + recorder.record(botId, guildId, finalText.length()); + } + + return Pair.of(finalText, voice); }, getAsyncExecutor()) .thenComposeAsync((sayTextVoice) -> { VoiceTrackLoader vtl = sayTextVoice.getRight().createVoiceTrackLoader(sayTextVoice.getLeft()); diff --git a/core/src/main/java/dev/felnull/itts/core/config/Config.java b/core/src/main/java/dev/felnull/itts/core/config/Config.java index 5d68ede..6a733d0 100644 --- a/core/src/main/java/dev/felnull/itts/core/config/Config.java +++ b/core/src/main/java/dev/felnull/itts/core/config/Config.java @@ -82,4 +82,13 @@ public interface Config { * @return DB関係のコンフィグ */ DataBaseConfig getDataBaseConfig(); + + /** + * Prometheusメトリクスのコンフィグを取得 + * + * @return メトリクスコンフィグ + */ + default MetricsConfig getMetricsConfig() { + return MetricsConfig.DEFAULT; + } } diff --git a/core/src/main/java/dev/felnull/itts/core/config/MetricsConfig.java b/core/src/main/java/dev/felnull/itts/core/config/MetricsConfig.java new file mode 100644 index 0000000..7516723 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/config/MetricsConfig.java @@ -0,0 +1,66 @@ +package dev.felnull.itts.core.config; + +import org.jetbrains.annotations.NotNull; + +/** + * Prometheusメトリクス公開のコンフィグ + */ +public interface MetricsConfig { + + /** + * デフォルトの有効状態 + */ + boolean DEFAULT_ENABLED = false; + + /** + * デフォルトのバインドアドレス + */ + String DEFAULT_BIND_ADDRESS = "127.0.0.1"; + + /** + * デフォルトのポート番号 + */ + int DEFAULT_PORT = 9095; + + /** + * デフォルトのコンフィグインスタンス + */ + MetricsConfig DEFAULT = new MetricsConfig() { + @Override + public boolean isEnabled() { + return DEFAULT_ENABLED; + } + + @Override + public @NotNull String getBindAddress() { + return DEFAULT_BIND_ADDRESS; + } + + @Override + public int getPort() { + return DEFAULT_PORT; + } + }; + + /** + * 有効かどうかを取得 + * + * @return 有効かどうか + */ + boolean isEnabled(); + + /** + * バインドアドレスを取得 + * + * @return バインドアドレス + */ + @NotNull + String getBindAddress(); + + /** + * ポート番号を取得 + * + * @return ポート番号 + */ + int getPort(); +} diff --git a/core/src/main/java/dev/felnull/itts/core/discord/Bot.java b/core/src/main/java/dev/felnull/itts/core/discord/Bot.java index 7f38fc9..ac1dab7 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/Bot.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/Bot.java @@ -74,6 +74,7 @@ private void registeringCommands() { registerCommand(new AdminCommand()); registerCommand(new DictCommand()); registerCommand(new SkipCommand()); + registerCommand(new StatCommand()); } private void registerCommand(BaseCommand command) { diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/BaseCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/BaseCommand.java index 993ad67..ae7dc18 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/BaseCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/BaseCommand.java @@ -24,6 +24,11 @@ public abstract class BaseCommand implements ITTSRuntimeUse { */ protected static final DefaultMemberPermissions OWNERS_PERMISSIONS = DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER); + /** + * 時間を相対的に表示するフォーマット + */ + protected static final String RELATIVE_TIME_FORMAT = ""; + /** * コマンド名 */ diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/InfoCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/InfoCommand.java index d6cd5c5..737acc0 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/InfoCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/InfoCommand.java @@ -28,11 +28,6 @@ public class InfoCommand extends BaseCommand { */ private static final String SOURCE_URL = "https://github.com/TeamFelnull/I-TTS"; - /** - * 時間を相対的に表示するフォーマット - */ - private static final String RELATIVE_TIME_FORMAT = ""; - /** * コンストラクタ */ diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/StatCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/StatCommand.java new file mode 100644 index 0000000..c993688 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/StatCommand.java @@ -0,0 +1,155 @@ +package dev.felnull.itts.core.discord.command; + +import dev.felnull.itts.core.savedata.SaveDataManager; +import dev.felnull.itts.core.savedata.dao.TTSCountRecord; +import dev.felnull.itts.core.savedata.repository.DataRepository; +import dev.felnull.itts.core.savedata.repository.TTSCountData; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionContextType; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.Objects; +import java.util.Optional; + +/** + * 読み上げ統計表示コマンド + */ +public class StatCommand extends BaseCommand { + + /** + * コンストラクタ + */ + public StatCommand() { + super("stat"); + } + + @NotNull + @Override + public SlashCommandData createSlashCommand() { + return Commands.slash("stat", "読み上げ統計を表示") + .setContexts(InteractionContextType.GUILD) + .setDefaultPermissions(OWNERS_PERMISSIONS) + .addSubcommands(new SubcommandData("today", "本日のBOT全体の読み上げ統計")) + .addSubcommands(new SubcommandData("week", "過去7日のBOT全体の読み上げ統計")) + .addSubcommands(new SubcommandData("all", "BOT全体の累計統計")) + .addSubcommands(new SubcommandData("server", "現在のサーバーの累計統計")); + } + + @Override + public void commandInteraction(SlashCommandInteractionEvent event) { + switch (Objects.requireNonNull(event.getSubcommandName())) { + case "today" -> today(event); + case "week" -> week(event); + case "all" -> all(event); + case "server" -> server(event); + default -> { + } + } + } + + private void today(SlashCommandInteractionEvent event) { + DataRepository repo = SaveDataManager.getInstance().getRepository(); + long botId = getBot().getBotId(); + LocalDate today = LocalDate.now(ZoneOffset.UTC); + TTSCountData data = repo.getGlobalTTSCount(botId, today); + + Optional record = data.getRecord(); + long charCount = record.map(TTSCountRecord::charCount).orElse(0L); + long messageCount = record.map(TTSCountRecord::messageCount).orElse(0L); + + EmbedBuilder builder = baseEmbed("本日の読み上げ統計 (UTC)"); + builder.addField("文字数", charCount + "文字", true); + builder.addField("メッセージ数", messageCount + "件", true); + addUptimeFields(builder); + + event.replyEmbeds(builder.build()).setEphemeral(true).queue(); + } + + private void week(SlashCommandInteractionEvent event) { + DataRepository repo = SaveDataManager.getInstance().getRepository(); + long botId = getBot().getBotId(); + LocalDate today = LocalDate.now(ZoneOffset.UTC); + LocalDate from = today.minusDays(6); + + long charSum = repo.sumGlobalCharCount(botId, from, today); + long messageSum = repo.sumGlobalMessageCount(botId, from, today); + + EmbedBuilder builder = baseEmbed("過去7日の読み上げ統計 (UTC)"); + builder.addField("期間", from + " - " + today, false); + builder.addField("文字数", charSum + "文字", true); + builder.addField("メッセージ数", messageSum + "件", true); + addUptimeFields(builder); + + event.replyEmbeds(builder.build()).setEphemeral(true).queue(); + } + + private void all(SlashCommandInteractionEvent event) { + DataRepository repo = SaveDataManager.getInstance().getRepository(); + long botId = getBot().getBotId(); + + long charSum = repo.sumGlobalAllCharCount(botId); + long messageSum = repo.sumGlobalAllMessageCount(botId); + + EmbedBuilder builder = baseEmbed("BOT全体の累計読み上げ統計"); + builder.addField("累計文字数", charSum + "文字", true); + builder.addField("累計メッセージ数", messageSum + "件", true); + addUptimeFields(builder); + + event.replyEmbeds(builder.build()).setEphemeral(true).queue(); + } + + private void server(SlashCommandInteractionEvent event) { + Guild guild = Objects.requireNonNull(event.getGuild()); + DataRepository repo = SaveDataManager.getInstance().getRepository(); + long botId = getBot().getBotId(); + long serverId = guild.getIdLong(); + + long charSum = repo.sumServerAllCharCount(botId, serverId); + long messageSum = repo.sumServerAllMessageCount(botId, serverId); + + EmbedBuilder builder = baseEmbed("このサーバーの累計読み上げ統計"); + builder.addField("サーバー名", guild.getName(), false); + builder.addField("累計文字数", charSum + "文字", true); + builder.addField("累計メッセージ数", messageSum + "件", true); + addUptimeFields(builder); + + event.replyEmbeds(builder.build()).setEphemeral(true).queue(); + } + + private EmbedBuilder baseEmbed(String title) { + EmbedBuilder builder = new EmbedBuilder(); + builder.setColor(getConfigManager().getConfig().getThemeColor()); + builder.setTitle(title); + return builder; + } + + private void addUptimeFields(EmbedBuilder builder) { + long startup = getITTSRuntime().getStartupTime(); + long now = System.currentTimeMillis(); + Duration uptime = Duration.ofMillis(now - startup); + builder.addField("稼働開始", String.format(RELATIVE_TIME_FORMAT, startup / 1000), true); + builder.addField("稼働時間", formatDuration(uptime), true); + } + + private String formatDuration(Duration duration) { + long days = duration.toDays(); + long hours = duration.toHoursPart(); + long minutes = duration.toMinutesPart(); + long seconds = duration.toSecondsPart(); + if (days > 0) { + return days + "日" + hours + "時間" + minutes + "分"; + } + if (hours > 0) { + return hours + "時間" + minutes + "分" + seconds + "秒"; + } + return minutes + "分" + seconds + "秒"; + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/metrics/MetricsRegistry.java b/core/src/main/java/dev/felnull/itts/core/metrics/MetricsRegistry.java new file mode 100644 index 0000000..b3420c3 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/metrics/MetricsRegistry.java @@ -0,0 +1,46 @@ +package dev.felnull.itts.core.metrics; + +import io.micrometer.core.instrument.Counter; +import org.jetbrains.annotations.NotNull; + +/** + * メトリクスレジストリの抽象 + * 実装はPrometheus版とNoOp版を切り替える + */ +public interface MetricsRegistry { + + /** + * BOT全体合計を表すサーバーIDの予約値 + */ + long GLOBAL_SERVER_ID = 0L; + + /** + * NoOp実装を返すファクトリ + * + * @return 何もしないMetricsRegistry + */ + @NotNull + static MetricsRegistry noop() { + return NoOpMetricsRegistry.INSTANCE; + } + + /** + * 文字数Counterをラベル付きで取得 + * + * @param botId BOTのID + * @param serverId サーバーID GLOBAL_SERVER_IDの場合はBOT全体 + * @return Counter + */ + @NotNull + Counter getOrCreateCharCounter(long botId, long serverId); + + /** + * メッセージ数Counterをラベル付きで取得 + * + * @param botId BOTのID + * @param serverId サーバーID GLOBAL_SERVER_IDの場合はBOT全体 + * @return Counter + */ + @NotNull + Counter getOrCreateMessageCounter(long botId, long serverId); +} diff --git a/core/src/main/java/dev/felnull/itts/core/metrics/NoOpMetricsRegistry.java b/core/src/main/java/dev/felnull/itts/core/metrics/NoOpMetricsRegistry.java new file mode 100644 index 0000000..8f1b9f7 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/metrics/NoOpMetricsRegistry.java @@ -0,0 +1,50 @@ +package dev.felnull.itts.core.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.noop.NoopCounter; +import org.jetbrains.annotations.NotNull; + +/** + * 何もしないMetricsRegistry実装 + * メトリクス公開無効時に利用しnullチェックを不要にする + */ +final class NoOpMetricsRegistry implements MetricsRegistry { + + /** + * 共有インスタンス + */ + static final NoOpMetricsRegistry INSTANCE = new NoOpMetricsRegistry(); + + /** + * 文字数Counterの共有NoOpインスタンス + */ + private static final Counter NOOP_CHAR_COUNTER = new NoopCounter( + new Meter.Id("itts_spoken_chars_total", Tags.empty(), null, null, Meter.Type.COUNTER)); + + /** + * メッセージ数Counterの共有NoOpインスタンス + */ + private static final Counter NOOP_MESSAGE_COUNTER = new NoopCounter( + new Meter.Id("itts_spoken_messages_total", Tags.empty(), null, null, Meter.Type.COUNTER)); + + /** + * コンストラクタ + * シングルトン用に外部からの生成を抑止する + */ + private NoOpMetricsRegistry() { + } + + @Override + @NotNull + public Counter getOrCreateCharCounter(long botId, long serverId) { + return NOOP_CHAR_COUNTER; + } + + @Override + @NotNull + public Counter getOrCreateMessageCounter(long botId, long serverId) { + return NOOP_MESSAGE_COUNTER; + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/metrics/PrometheusHttpExposer.java b/core/src/main/java/dev/felnull/itts/core/metrics/PrometheusHttpExposer.java new file mode 100644 index 0000000..d302eab --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/metrics/PrometheusHttpExposer.java @@ -0,0 +1,104 @@ +package dev.felnull.itts.core.metrics; + +import com.sun.net.httpserver.HttpServer; +import org.apache.commons.lang3.concurrent.BasicThreadFactory; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * PrometheusメトリクスをHTTPで公開するエクスポーザ + */ +public final class PrometheusHttpExposer { + + /** + * リクエスト処理スレッドプールのスレッド数 + */ + private static final int EXECUTOR_THREAD_COUNT = 2; + + /** + * Executor停止時の待機秒数 + */ + private static final int EXECUTOR_SHUTDOWN_TIMEOUT_SEC = 5; + + /** + * Prometheus形式のメトリクスレジストリ + */ + private final PrometheusMetricsRegistry metricsRegistry; + + /** + * 内部HTTPサーバー + */ + private HttpServer httpServer; + + /** + * リクエスト処理用スレッドプール + */ + private ExecutorService executor; + + /** + * コンストラクタ + * + * @param metricsRegistry Prometheus形式のメトリクスレジストリ + */ + public PrometheusHttpExposer(@NotNull PrometheusMetricsRegistry metricsRegistry) { + this.metricsRegistry = metricsRegistry; + } + + /** + * 起動する + * + * @param host バインドアドレス + * @param port ポート番号 + * @throws IOException 起動失敗時 + */ + public void start(@NotNull String host, int port) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0); + server.createContext("/metrics", exchange -> { + String body = metricsRegistry.getRegistry().scrape(); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + }); + ExecutorService pool = Executors.newFixedThreadPool(EXECUTOR_THREAD_COUNT, + new BasicThreadFactory.Builder() + .namingPattern("prometheus-exposer-%d") + .daemon(true) + .build()); + server.setExecutor(pool); + server.start(); + this.httpServer = server; + this.executor = pool; + } + + /** + * 停止する + */ + public void stop() { + if (httpServer != null) { + httpServer.stop(0); + httpServer = null; + } + if (executor != null) { + executor.shutdown(); + try { + if (!executor.awaitTermination(EXECUTOR_SHUTDOWN_TIMEOUT_SEC, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + executor = null; + } + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/metrics/PrometheusMetricsRegistry.java b/core/src/main/java/dev/felnull/itts/core/metrics/PrometheusMetricsRegistry.java new file mode 100644 index 0000000..f27edce --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/metrics/PrometheusMetricsRegistry.java @@ -0,0 +1,127 @@ +package dev.felnull.itts.core.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; +import io.micrometer.core.instrument.binder.system.ProcessorMetrics; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Prometheus形式のメトリクスレジストリ実装 + * Counterのキャッシュとシステム系メトリクスのバインドを行う + */ +public final class PrometheusMetricsRegistry implements MetricsRegistry { + + /** + * 文字数Counterのメトリクス名 + */ + private static final String CHAR_METRIC_NAME = "itts_spoken_chars_total"; + + /** + * 文字数Counterの説明 + */ + private static final String CHAR_METRIC_DESCRIPTION = "Total spoken characters delivered to TTS API"; + + /** + * メッセージ数Counterのメトリクス名 + */ + private static final String MESSAGE_METRIC_NAME = "itts_spoken_messages_total"; + + /** + * メッセージ数Counterの説明 + */ + private static final String MESSAGE_METRIC_DESCRIPTION = "Total spoken messages delivered to TTS API"; + + /** + * Prometheus形式のレジストリ + */ + private final PrometheusMeterRegistry registry; + + /** + * 起動時刻のミリ秒 + */ + private final long bootAt; + + /** + * 文字数Counterのキャッシュ + */ + private final ConcurrentMap charCounters = new ConcurrentHashMap<>(); + + /** + * メッセージ数Counterのキャッシュ + */ + private final ConcurrentMap messageCounters = new ConcurrentHashMap<>(); + + /** + * コンストラクタ + */ + public PrometheusMetricsRegistry() { + this.registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + this.bootAt = System.currentTimeMillis(); + + Gauge.builder("itts_up", () -> 1.0d) + .description("Bot liveness indicator") + .register(registry); + + Gauge.builder("itts_uptime_seconds", () -> (System.currentTimeMillis() - bootAt) / 1000.0d) + .description("Bot uptime in seconds") + .register(registry); + + new JvmMemoryMetrics().bindTo(registry); + new JvmGcMetrics().bindTo(registry); + new ProcessorMetrics().bindTo(registry); + } + + /** + * Prometheusレジストリを取得 + * + * @return レジストリ + */ + @NotNull + public PrometheusMeterRegistry getRegistry() { + return registry; + } + + @Override + @NotNull + public Counter getOrCreateCharCounter(long botId, long serverId) { + return getOrCreate(charCounters, CHAR_METRIC_NAME, CHAR_METRIC_DESCRIPTION, botId, serverId); + } + + @Override + @NotNull + public Counter getOrCreateMessageCounter(long botId, long serverId) { + return getOrCreate(messageCounters, MESSAGE_METRIC_NAME, MESSAGE_METRIC_DESCRIPTION, botId, serverId); + } + + /** + * Counterをキャッシュから取得もしくは生成する + * + * @param cache Counterキャッシュ + * @param metricName メトリクス名 + * @param description メトリクスの説明 + * @param botId BOTのID + * @param serverId サーバーID GLOBAL_SERVER_IDの場合はBOT全体 + * @return Counter + */ + @NotNull + private Counter getOrCreate(@NotNull ConcurrentMap cache, + @NotNull String metricName, + @NotNull String description, + long botId, + long serverId) { + String serverTag = serverId == GLOBAL_SERVER_ID ? "global" : String.valueOf(serverId); + String key = botId + "|" + serverTag; + return cache.computeIfAbsent(key, k -> Counter.builder(metricName) + .description(description) + .tag("bot_id", String.valueOf(botId)) + .tag("server_id", serverTag) + .register(registry)); + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/metrics/package-info.java b/core/src/main/java/dev/felnull/itts/core/metrics/package-info.java new file mode 100644 index 0000000..8ab66c6 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/metrics/package-info.java @@ -0,0 +1,4 @@ +/** + * Prometheusメトリクスの収集と公開 + */ +package dev.felnull.itts.core.metrics; diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/dao/DAO.java b/core/src/main/java/dev/felnull/itts/core/savedata/dao/DAO.java index 71f8da9..ef41315 100644 --- a/core/src/main/java/dev/felnull/itts/core/savedata/dao/DAO.java +++ b/core/src/main/java/dev/felnull/itts/core/savedata/dao/DAO.java @@ -8,6 +8,7 @@ import java.sql.Connection; import java.sql.SQLException; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; @@ -135,6 +136,13 @@ public interface DAO { */ GlobalCustomDictionaryTable globalCustomDictionaryTable(); + /** + * 読み上げ文字数集計テーブル + * + * @return テーブルインスタンス + */ + TTSCountTable ttsCountTable(); + /** * 絵文字をサポートしているか確認 * @@ -767,4 +775,108 @@ interface GlobalCustomDictionaryTable extends Table { Map selectRecordByTarget(Connection connection, @NotNull String targetWord) throws SQLException; } + /** + * 期間内の文字数合計を取得する + */ + interface TTSCountTable extends Table { + + /** + * BOT全体合計を表すサーバーキーIDの予約値 + */ + int GLOBAL_SERVER_KEY_ID = 0; + + /** + * 指定日のカウントを増分する + * + * @param connection コネクション + * @param botKeyId BOTキーID + * @param serverKeyId サーバーキーID GLOBAL_SERVER_KEY_IDの場合はBOT全体合計 + * @param date 集計日 + * @param charDelta 文字数の増分 + * @param messageDelta メッセージ数の増分 + * @throws SQLException エラー + */ + void incrementCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate date, + long charDelta, + long messageDelta) throws SQLException; + + /** + * 指定日のカウントを取得する + * + * @param connection コネクション + * @param botKeyId BOTキーID + * @param serverKeyId サーバーキーID GLOBAL_SERVER_KEY_IDの場合はBOT全体合計 + * @param date 集計日 + * @return カウントレコード + * @throws SQLException エラー + */ + Optional getCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate date) throws SQLException; + + /** + * 期間内の文字数合計を取得する + * + * @param connection コネクション + * @param botKeyId BOTキーID + * @param serverKeyId サーバーキーID GLOBAL_SERVER_KEY_IDの場合はBOT全体合計 + * @param from 開始日 (含む) + * @param to 終了日 (含む) + * @return 文字数の合計 + * @throws SQLException エラー + */ + long sumCharCountRange(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to) throws SQLException; + + /** + * 期間内のメッセージ数合計を取得する + * + * @param connection コネクション + * @param botKeyId BOTキーID + * @param serverKeyId サーバーキーID GLOBAL_SERVER_KEY_IDの場合はBOT全体合計 + * @param from 開始日 (含む) + * @param to 終了日 (含む) + * @return メッセージ数の合計 + * @throws SQLException エラー + */ + long sumMessageCountRange(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to) throws SQLException; + + /** + * 全期間の文字数合計を取得する + * + * @param connection コネクション + * @param botKeyId BOTキーID + * @param serverKeyId サーバーキーID GLOBAL_SERVER_KEY_IDの場合はBOT全体合計 + * @return 文字数の合計 + * @throws SQLException エラー + */ + long sumAllCharCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId) throws SQLException; + + /** + * 全期間のメッセージ数合計を取得する + * + * @param connection コネクション + * @param botKeyId BOTキーID + * @param serverKeyId サーバーキーID GLOBAL_SERVER_KEY_IDの場合はBOT全体合計 + * @return メッセージ数の合計 + * @throws SQLException エラー + */ + long sumAllMessageCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId) throws SQLException; + } + } diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/dao/TTSCountRecord.java b/core/src/main/java/dev/felnull/itts/core/savedata/dao/TTSCountRecord.java new file mode 100644 index 0000000..8ebd0a5 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/savedata/dao/TTSCountRecord.java @@ -0,0 +1,20 @@ +package dev.felnull.itts.core.savedata.dao; + +import java.time.LocalDate; + +/** + * 読み上げ文字数集計のレコード + * + * @param botId BOTのDiscord ID + * @param serverId サーバーのDiscord ID 0の場合はBOT全体合計 + * @param date 集計日 + * @param charCount 読み上げ文字数 + * @param messageCount 読み上げメッセージ数 + */ +public record TTSCountRecord(long botId, + long serverId, + LocalDate date, + long charCount, + long messageCount +) { +} diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/MySQLDAO.java b/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/MySQLDAO.java index 6194953..20de614 100644 --- a/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/MySQLDAO.java +++ b/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/MySQLDAO.java @@ -13,6 +13,7 @@ import org.jetbrains.annotations.Unmodifiable; import java.sql.*; +import java.time.LocalDate; import java.util.*; /** @@ -90,6 +91,11 @@ class MySQLDAO extends BaseDAO { */ private final GlobalCustomDictionaryTable globalCustomDictionaryTable = new GlobalCustomDictionaryTableImpl(); + /** + * 読み上げ文字数集計テーブルのインスタンス + */ + private final TTSCountTable ttsCountTable = new TTSCountTableImpl(); + /** * ホスト名 */ @@ -222,6 +228,11 @@ public GlobalCustomDictionaryTable globalCustomDictionaryTable() { return globalCustomDictionaryTable; } + @Override + public TTSCountTable ttsCountTable() { + return ttsCountTable; + } + @Override public boolean checkEmojiSupport() { throw new AssertionError("TODO"); @@ -2447,4 +2458,165 @@ public Map selectRecordByTarget(Connection connection return ret.build(); } } + + /** + * 読み上げ文字数集計テーブルの実装 + */ + private final class TTSCountTableImpl implements TTSCountTable { + + @Override + public void createTableIfNotExists(@NotNull Connection connection) throws SQLException { + @Language("MySQL") + String sql = """ + create table if not exists tts_count_data( + id integer not null primary key auto_increment, + bot_id integer not null, + server_id integer not null, + target_date date not null, + spoken_char_count bigint not null default 0, + spoken_message_count bigint not null default 0, + + unique(bot_id, server_id, target_date), + foreign key (bot_id) references bot_key(id) + ); + """; + + execute(connection, sql); + } + + @Override + public void incrementCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate date, + long charDelta, + long messageDelta) throws SQLException { + @Language("MySQL") + String sql = """ + insert into tts_count_data(bot_id, server_id, target_date, spoken_char_count, spoken_message_count) + values (?, ?, ?, ?, ?) + on duplicate key update + spoken_char_count = spoken_char_count + values(spoken_char_count), + spoken_message_count = spoken_message_count + values(spoken_message_count) + """; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + statement.setString(3, date.toString()); + statement.setLong(4, charDelta); + statement.setLong(5, messageDelta); + statement.execute(); + } + } + + @Override + public Optional getCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate date) throws SQLException { + @Language("MySQL") + String sql = """ + select spoken_char_count, spoken_message_count + from tts_count_data + where bot_id = ? and server_id = ? and target_date = ? + limit 1 + """; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + statement.setString(3, date.toString()); + + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + long ch = rs.getLong("spoken_char_count"); + long ms = rs.getLong("spoken_message_count"); + return Optional.of(new TTSCountRecord(0L, 0L, date, ch, ms)); + } + } + } + + return Optional.empty(); + } + + @Override + public long sumCharCountRange(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to) throws SQLException { + return sumRangeByColumn(connection, botKeyId, serverKeyId, from, to, "spoken_char_count"); + } + + @Override + public long sumMessageCountRange(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to) throws SQLException { + return sumRangeByColumn(connection, botKeyId, serverKeyId, from, to, "spoken_message_count"); + } + + private long sumRangeByColumn(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to, + @NotNull String column) throws SQLException { + @Language("MySQL") + String sql = "select coalesce(sum(" + column + "), 0) as total from tts_count_data " + + "where bot_id = ? and server_id = ? and target_date between ? and ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + statement.setString(3, from.toString()); + statement.setString(4, to.toString()); + + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + return rs.getLong("total"); + } + } + } + + return 0L; + } + + @Override + public long sumAllCharCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId) throws SQLException { + return sumAllByColumn(connection, botKeyId, serverKeyId, "spoken_char_count"); + } + + @Override + public long sumAllMessageCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId) throws SQLException { + return sumAllByColumn(connection, botKeyId, serverKeyId, "spoken_message_count"); + } + + private long sumAllByColumn(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull String column) throws SQLException { + @Language("MySQL") + String sql = "select coalesce(sum(" + column + "), 0) as total from tts_count_data where bot_id = ? and server_id = ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + return rs.getLong("total"); + } + } + } + + return 0L; + } + } } diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/SQLiteDAO.java b/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/SQLiteDAO.java index bf8c564..c9bb77d 100644 --- a/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/SQLiteDAO.java +++ b/core/src/main/java/dev/felnull/itts/core/savedata/dao/impl/SQLiteDAO.java @@ -15,6 +15,7 @@ import java.io.File; import java.sql.*; +import java.time.LocalDate; import java.util.*; /** @@ -92,6 +93,11 @@ public class SQLiteDAO extends BaseDAO { */ private final GlobalCustomDictionaryTable globalCustomDictionaryTable = new GlobalCustomDictionaryTableImpl(); + /** + * 読み上げ文字数集計テーブルのインスタンス + */ + private final TTSCountTable ttsCountTable = new TTSCountTableImpl(); + /** * DBファイル */ @@ -206,6 +212,11 @@ public GlobalCustomDictionaryTable globalCustomDictionaryTable() { return globalCustomDictionaryTable; } + @Override + public TTSCountTable ttsCountTable() { + return ttsCountTable; + } + @Override public boolean checkEmojiSupport() { return true; @@ -2433,4 +2444,165 @@ public Map selectRecordByTarget(Connection connection return ret.build(); } } + + /** + * 読み上げ文字数集計テーブルの実装 + */ + private final class TTSCountTableImpl implements TTSCountTable { + + @Override + public void createTableIfNotExists(@NotNull Connection connection) throws SQLException { + @Language("SQLite") + String sql = """ + create table if not exists tts_count_data( + id integer not null primary key autoincrement, + bot_id integer not null, + server_id integer not null, + target_date date not null, + spoken_char_count bigint not null default 0, + spoken_message_count bigint not null default 0, + + unique(bot_id, server_id, target_date), + foreign key (bot_id) references bot_key(id) + ); + """; + + execute(connection, sql); + } + + @Override + public void incrementCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate date, + long charDelta, + long messageDelta) throws SQLException { + @Language("SQLite") + String sql = """ + insert into tts_count_data(bot_id, server_id, target_date, spoken_char_count, spoken_message_count) + values (?, ?, ?, ?, ?) + on conflict(bot_id, server_id, target_date) do update set + spoken_char_count = spoken_char_count + excluded.spoken_char_count, + spoken_message_count = spoken_message_count + excluded.spoken_message_count + """; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + statement.setString(3, date.toString()); + statement.setLong(4, charDelta); + statement.setLong(5, messageDelta); + statement.execute(); + } + } + + @Override + public Optional getCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate date) throws SQLException { + @Language("SQLite") + String sql = """ + select spoken_char_count, spoken_message_count + from tts_count_data + where bot_id = ? and server_id = ? and target_date = ? + limit 1 + """; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + statement.setString(3, date.toString()); + + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + long ch = rs.getLong("spoken_char_count"); + long ms = rs.getLong("spoken_message_count"); + return Optional.of(new TTSCountRecord(0L, 0L, date, ch, ms)); + } + } + } + + return Optional.empty(); + } + + @Override + public long sumCharCountRange(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to) throws SQLException { + return sumRangeByColumn(connection, botKeyId, serverKeyId, from, to, "spoken_char_count"); + } + + @Override + public long sumMessageCountRange(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to) throws SQLException { + return sumRangeByColumn(connection, botKeyId, serverKeyId, from, to, "spoken_message_count"); + } + + private long sumRangeByColumn(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull LocalDate from, + @NotNull LocalDate to, + @NotNull String column) throws SQLException { + @Language("SQLite") + String sql = "select coalesce(sum(" + column + "), 0) as total from tts_count_data " + + "where bot_id = ? and server_id = ? and target_date between ? and ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + statement.setString(3, from.toString()); + statement.setString(4, to.toString()); + + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + return rs.getLong("total"); + } + } + } + + return 0L; + } + + @Override + public long sumAllCharCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId) throws SQLException { + return sumAllByColumn(connection, botKeyId, serverKeyId, "spoken_char_count"); + } + + @Override + public long sumAllMessageCount(@NotNull Connection connection, + int botKeyId, + int serverKeyId) throws SQLException { + return sumAllByColumn(connection, botKeyId, serverKeyId, "spoken_message_count"); + } + + private long sumAllByColumn(@NotNull Connection connection, + int botKeyId, + int serverKeyId, + @NotNull String column) throws SQLException { + @Language("SQLite") + String sql = "select coalesce(sum(" + column + "), 0) as total from tts_count_data where bot_id = ? and server_id = ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, botKeyId); + statement.setInt(2, serverKeyId); + + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + return rs.getLong("total"); + } + } + } + + return 0L; + } + } } \ No newline at end of file diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/repository/DataRepository.java b/core/src/main/java/dev/felnull/itts/core/savedata/repository/DataRepository.java index d247241..a0b51f6 100644 --- a/core/src/main/java/dev/felnull/itts/core/savedata/repository/DataRepository.java +++ b/core/src/main/java/dev/felnull/itts/core/savedata/repository/DataRepository.java @@ -7,6 +7,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Unmodifiable; +import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -147,4 +148,101 @@ static DataRepository create(DAO dao) { @NotNull @Unmodifiable Map getAllBotStateData(long botId); + + /** + * サーバー単位の読み上げ文字数集計データを取得 + * + * @param botId BOTのID + * @param serverId サーバーID + * @param date 集計日 + * @return カウントデータ + */ + @NotNull + TTSCountData getServerTTSCount(long botId, long serverId, @NotNull LocalDate date); + + /** + * BOT全体の読み上げ文字数集計データを取得 + * + * @param botId BOTのID + * @param date 集計日 + * @return カウントデータ + */ + @NotNull + TTSCountData getGlobalTTSCount(long botId, @NotNull LocalDate date); + + /** + * BOT全体の期間内文字数合計を取得 + * + * @param botId BOTのID + * @param from 開始日 + * @param to 終了日 + * @return 文字数合計 + */ + long sumGlobalCharCount(long botId, @NotNull LocalDate from, @NotNull LocalDate to); + + /** + * BOT全体の期間内メッセージ数合計を取得 + * + * @param botId BOTのID + * @param from 開始日 + * @param to 終了日 + * @return メッセージ数合計 + */ + long sumGlobalMessageCount(long botId, @NotNull LocalDate from, @NotNull LocalDate to); + + /** + * サーバー単位の期間内文字数合計を取得 + * + * @param botId BOTのID + * @param serverId サーバーID + * @param from 開始日 + * @param to 終了日 + * @return 文字数合計 + */ + long sumServerCharCount(long botId, long serverId, @NotNull LocalDate from, @NotNull LocalDate to); + + /** + * サーバー単位の期間内メッセージ数合計を取得 + * + * @param botId BOTのID + * @param serverId サーバーID + * @param from 開始日 + * @param to 終了日 + * @return メッセージ数合計 + */ + long sumServerMessageCount(long botId, long serverId, @NotNull LocalDate from, @NotNull LocalDate to); + + /** + * BOT全体の累計文字数を取得 + * + * @param botId BOTのID + * @return 累計文字数 + */ + long sumGlobalAllCharCount(long botId); + + /** + * BOT全体の累計メッセージ数を取得 + * + * @param botId BOTのID + * @return 累計メッセージ数 + */ + long sumGlobalAllMessageCount(long botId); + + /** + * サーバー単位の累計文字数を取得 + * + * @param botId BOTのID + * @param serverId サーバーID + * @return 累計文字数 + */ + long sumServerAllCharCount(long botId, long serverId); + + /** + * サーバー単位の累計メッセージ数を取得 + * + * @param botId BOTのID + * @param serverId サーバーID + * @return 累計メッセージ数 + */ + long sumServerAllMessageCount(long botId, long serverId); } diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/repository/TTSCountData.java b/core/src/main/java/dev/felnull/itts/core/savedata/repository/TTSCountData.java new file mode 100644 index 0000000..3684203 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/savedata/repository/TTSCountData.java @@ -0,0 +1,44 @@ +package dev.felnull.itts.core.savedata.repository; + +import dev.felnull.itts.core.savedata.dao.TTSCountRecord; + +import java.util.Optional; + +/** + * 読み上げ文字数集計データ + */ +public interface TTSCountData { + + /** + * カウントを増分する + * + * @param charDelta 文字数の増分 + * @param messageDelta メッセージ数の増分 + */ + void addCount(long charDelta, long messageDelta); + + /** + * 該当日のレコードを取得 + * + * @return レコード 存在しない場合は空 + */ + Optional getRecord(); + + /** + * 文字数を取得 + * + * @return 文字数 + */ + default long getCharCount() { + return getRecord().map(TTSCountRecord::charCount).orElse(0L); + } + + /** + * メッセージ数を取得 + * + * @return メッセージ数 + */ + default long getMessageCount() { + return getRecord().map(TTSCountRecord::messageCount).orElse(0L); + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/repository/impl/DataRepositoryImpl.java b/core/src/main/java/dev/felnull/itts/core/savedata/repository/impl/DataRepositoryImpl.java index de52305..cdecff9 100644 --- a/core/src/main/java/dev/felnull/itts/core/savedata/repository/impl/DataRepositoryImpl.java +++ b/core/src/main/java/dev/felnull/itts/core/savedata/repository/impl/DataRepositoryImpl.java @@ -13,6 +13,7 @@ import java.sql.Connection; import java.sql.SQLException; +import java.time.LocalDate; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -200,6 +201,7 @@ private void initDataBase() throws SQLException { dao.botStateDataTable().createTableIfNotExists(con); dao.serverCustomDictionaryTable().createTableIfNotExists(con); dao.globalCustomDictionaryTable().createTableIfNotExists(con); + dao.ttsCountTable().createTableIfNotExists(con); } } @@ -361,6 +363,124 @@ KeyData getVoiceTypeKeyData() { } } + @Override + public @NotNull TTSCountData getServerTTSCount(long botId, long serverId, @NotNull LocalDate date) { + return new TTSCountDataImpl(this, botId, serverId, date); + } + + @Override + public @NotNull TTSCountData getGlobalTTSCount(long botId, @NotNull LocalDate date) { + return new TTSCountDataImpl(this, botId, 0L, date); + } + + @Override + public long sumGlobalCharCount(long botId, @NotNull LocalDate from, @NotNull LocalDate to) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + return dao.ttsCountTable().sumCharCountRange(connection, botKeyId, DAO.TTSCountTable.GLOBAL_SERVER_KEY_ID, from, to); + }, 0L); + } + + @Override + public long sumGlobalMessageCount(long botId, @NotNull LocalDate from, @NotNull LocalDate to) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + return dao.ttsCountTable().sumMessageCountRange(connection, botKeyId, DAO.TTSCountTable.GLOBAL_SERVER_KEY_ID, from, to); + }, 0L); + } + + @Override + public long sumServerCharCount(long botId, long serverId, @NotNull LocalDate from, @NotNull LocalDate to) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + int serverKeyId = serverKeyData.getId(serverId); + return dao.ttsCountTable().sumCharCountRange(connection, botKeyId, serverKeyId, from, to); + }, 0L); + } + + @Override + public long sumServerMessageCount(long botId, long serverId, @NotNull LocalDate from, @NotNull LocalDate to) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + int serverKeyId = serverKeyData.getId(serverId); + return dao.ttsCountTable().sumMessageCountRange(connection, botKeyId, serverKeyId, from, to); + }, 0L); + } + + @Override + public long sumGlobalAllCharCount(long botId) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + return dao.ttsCountTable().sumAllCharCount(connection, botKeyId, DAO.TTSCountTable.GLOBAL_SERVER_KEY_ID); + }, 0L); + } + + @Override + public long sumGlobalAllMessageCount(long botId) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + return dao.ttsCountTable().sumAllMessageCount(connection, botKeyId, DAO.TTSCountTable.GLOBAL_SERVER_KEY_ID); + }, 0L); + } + + @Override + public long sumServerAllCharCount(long botId, long serverId) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + int serverKeyId = serverKeyData.getId(serverId); + return dao.ttsCountTable().sumAllCharCount(connection, botKeyId, serverKeyId); + }, 0L); + } + + @Override + public long sumServerAllMessageCount(long botId, long serverId) { + return withConnection(connection -> { + int botKeyId = botKeyData.getId(botId); + int serverKeyId = serverKeyData.getId(serverId); + return dao.ttsCountTable().sumAllMessageCount(connection, botKeyId, serverKeyId); + }, 0L); + } + + /** + * コネクションを取得して処理を実行する + * 例外発生時はエラーリスナーへ通知してフォールバック値を返す + * + * @param function 処理関数 + * @param fallback 例外時のフォールバック値 + * @param 戻り値型 + * @return 処理結果またはフォールバック値 + */ + private T withConnection(SQLFunction function, T fallback) { + try (Connection connection = dao.getConnection()) { + return function.apply(connection); + } catch (Exception e) { + fireErrorEvent(e); + return fallback; + } catch (Throwable throwable) { + fireErrorEvent(throwable); + return fallback; + } + } + + /** + * SQL処理用の関数インタフェース + * + * @param 入力型 + * @param 戻り値型 + */ + @FunctionalInterface + private interface SQLFunction { + + /** + * 処理を適用する + * + * @param input 入力 + * @return 結果 + * @throws Exception 例外 + */ + R apply(T input) throws Exception; + } + /** * サーバーIDとユーザーIDで取得するキャッシュのキー * diff --git a/core/src/main/java/dev/felnull/itts/core/savedata/repository/impl/TTSCountDataImpl.java b/core/src/main/java/dev/felnull/itts/core/savedata/repository/impl/TTSCountDataImpl.java new file mode 100644 index 0000000..dbea9a7 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/savedata/repository/impl/TTSCountDataImpl.java @@ -0,0 +1,61 @@ +package dev.felnull.itts.core.savedata.repository.impl; + +import dev.felnull.itts.core.savedata.dao.DAO; +import dev.felnull.itts.core.savedata.dao.TTSCountRecord; +import dev.felnull.itts.core.savedata.repository.TTSCountData; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * 読み上げ文字数集計データの実装 + */ +class TTSCountDataImpl extends SaveDataBase implements TTSCountData { + + /** + * BOT ID + */ + private final long botId; + + /** + * サーバーID 0の場合はBOT全体合計 + */ + private final long serverId; + + /** + * 集計日 + */ + private final LocalDate date; + + TTSCountDataImpl(DataRepositoryImpl repository, long botId, long serverId, LocalDate date) { + super(repository); + this.botId = botId; + this.serverId = serverId; + this.date = date; + } + + private int resolveServerKeyId() { + if (serverId == 0L) { + return DAO.TTSCountTable.GLOBAL_SERVER_KEY_ID; + } + return repository.getServerKeyData().getId(serverId); + } + + @Override + public void addCount(long charDelta, long messageDelta) { + sqlProc(connection -> { + int botKeyId = repository.getBotKeyData().getId(botId); + int serverKeyId = resolveServerKeyId(); + dao().ttsCountTable().incrementCount(connection, botKeyId, serverKeyId, date, charDelta, messageDelta); + }); + } + + @Override + public Optional getRecord() { + return sqlProcReturnable(connection -> { + int botKeyId = repository.getBotKeyData().getId(botId); + int serverKeyId = resolveServerKeyId(); + return dao().ttsCountTable().getCount(connection, botKeyId, serverKeyId, date); + }); + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/tts/TTSCountRecorder.java b/core/src/main/java/dev/felnull/itts/core/tts/TTSCountRecorder.java new file mode 100644 index 0000000..ca97d74 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/tts/TTSCountRecorder.java @@ -0,0 +1,74 @@ +package dev.felnull.itts.core.tts; + +import dev.felnull.itts.core.ITTSRuntimeUse; +import dev.felnull.itts.core.metrics.MetricsRegistry; +import dev.felnull.itts.core.savedata.SaveDataManager; +import dev.felnull.itts.core.savedata.repository.DataRepository; +import dev.felnull.itts.core.savedata.repository.TTSCountData; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.concurrent.CompletableFuture; + +/** + * 読み上げ文字数の集計を非同期で記録するレコーダー + */ +public final class TTSCountRecorder implements ITTSRuntimeUse { + + /** + * メトリクスレジストリ + * 公開無効時はNoOp実装が渡されるためnullにはならない + */ + private final MetricsRegistry metricsRegistry; + + /** + * コンストラクタ + * + * @param metricsRegistry メトリクスレジストリ + */ + public TTSCountRecorder(MetricsRegistry metricsRegistry) { + this.metricsRegistry = metricsRegistry; + } + + /** + * 読み上げ文字数を記録する + * + * @param botDiscordId BOTのDiscord ID + * @param guildDiscordId サーバーのDiscord ID + * @param charCount 文字数 + */ + public void record(long botDiscordId, long guildDiscordId, int charCount) { + if (charCount <= 0) { + return; + } + + try { + metricsRegistry.getOrCreateCharCounter(botDiscordId, guildDiscordId).increment(charCount); + metricsRegistry.getOrCreateMessageCounter(botDiscordId, guildDiscordId).increment(); + metricsRegistry.getOrCreateCharCounter(botDiscordId, MetricsRegistry.GLOBAL_SERVER_ID).increment(charCount); + metricsRegistry.getOrCreateMessageCounter(botDiscordId, MetricsRegistry.GLOBAL_SERVER_ID).increment(); + } catch (Throwable t) { + getITTSLogger().warn("Failed to update metrics counter", t); + } + + CompletableFuture.runAsync(() -> writeToDatabase(botDiscordId, guildDiscordId, charCount), getAsyncExecutor()) + .exceptionally(throwable -> { + getITTSLogger().warn("Failed to record TTS count", throwable); + return null; + }); + } + + private void writeToDatabase(long botDiscordId, long guildDiscordId, int charCount) { + DataRepository repo = SaveDataManager.getInstance().getRepository(); + if (repo == null) { + return; + } + + LocalDate today = LocalDate.now(ZoneOffset.UTC); + TTSCountData serverData = repo.getServerTTSCount(botDiscordId, guildDiscordId, today); + TTSCountData globalData = repo.getGlobalTTSCount(botDiscordId, today); + + serverData.addCount(charCount, 1L); + globalData.addCount(charCount, 1L); + } +} diff --git a/core/src/test/java/dev/felnull/itts/core/savedata/repository/TTSCountDataTest.java b/core/src/test/java/dev/felnull/itts/core/savedata/repository/TTSCountDataTest.java new file mode 100644 index 0000000..2ddf499 --- /dev/null +++ b/core/src/test/java/dev/felnull/itts/core/savedata/repository/TTSCountDataTest.java @@ -0,0 +1,123 @@ +package dev.felnull.itts.core.savedata.repository; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TTSCountDataTest extends RepoBaseTest { + + @Test + void testServerAndGlobalAccumulation() { + DataRepository repo = createRepository(); + long botId = 1234567890123L; + long serverId = 9876543210987L; + LocalDate date = LocalDate.now(ZoneOffset.UTC); + + TTSCountData server = repo.getServerTTSCount(botId, serverId, date); + TTSCountData global = repo.getGlobalTTSCount(botId, date); + + assertEquals(0L, server.getCharCount()); + assertEquals(0L, server.getMessageCount()); + assertEquals(0L, global.getCharCount()); + assertEquals(0L, global.getMessageCount()); + + server.addCount(10L, 1L); + server.addCount(5L, 1L); + global.addCount(15L, 2L); + + assertEquals(15L, server.getCharCount()); + assertEquals(2L, server.getMessageCount()); + assertEquals(15L, global.getCharCount()); + assertEquals(2L, global.getMessageCount()); + + repo.dispose(); + } + + @Test + void testRangeAndAllSums() { + DataRepository repo = createRepository(); + long botId = 11111L; + long serverId = 22222L; + LocalDate today = LocalDate.now(ZoneOffset.UTC); + LocalDate yesterday = today.minusDays(1); + + repo.getServerTTSCount(botId, serverId, today).addCount(20L, 2L); + repo.getServerTTSCount(botId, serverId, yesterday).addCount(7L, 1L); + repo.getGlobalTTSCount(botId, today).addCount(20L, 2L); + repo.getGlobalTTSCount(botId, yesterday).addCount(7L, 1L); + + assertEquals(27L, repo.sumServerCharCount(botId, serverId, yesterday, today)); + assertEquals(20L, repo.sumServerCharCount(botId, serverId, today, today)); + assertEquals(27L, repo.sumGlobalCharCount(botId, yesterday, today)); + assertEquals(27L, repo.sumServerAllCharCount(botId, serverId)); + assertEquals(3L, repo.sumServerAllMessageCount(botId, serverId)); + assertEquals(27L, repo.sumGlobalAllCharCount(botId)); + assertEquals(3L, repo.sumGlobalAllMessageCount(botId)); + + repo.dispose(); + } + + @Test + void testConcurrentIncrementNoLostWrites() throws InterruptedException { + DataRepository repo = createRepository(); + long botId = 555L; + long serverId = 666L; + LocalDate date = LocalDate.now(ZoneOffset.UTC); + + int threadCount = 8; + int incrementsPerThread = 50; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicInteger errors = new AtomicInteger(); + List errorList = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + start.await(); + TTSCountData server = repo.getServerTTSCount(botId, serverId, date); + TTSCountData global = repo.getGlobalTTSCount(botId, date); + for (int j = 0; j < incrementsPerThread; j++) { + server.addCount(1L, 1L); + global.addCount(1L, 1L); + } + } catch (Throwable t) { + errors.incrementAndGet(); + synchronized (errorList) { + errorList.add(t); + } + } finally { + done.countDown(); + } + }); + } + + start.countDown(); + assertTrue(done.await(60, TimeUnit.SECONDS), "concurrent increment did not finish in time"); + executor.shutdown(); + + assertEquals(0, errors.get(), () -> "errors during concurrent increment: " + errorList); + + long expected = (long) threadCount * incrementsPerThread; + TTSCountData server = repo.getServerTTSCount(botId, serverId, date); + TTSCountData global = repo.getGlobalTTSCount(botId, date); + assertEquals(expected, server.getCharCount(), "server char count lost writes"); + assertEquals(expected, server.getMessageCount(), "server message count lost writes"); + assertEquals(expected, global.getCharCount(), "global char count lost writes"); + assertEquals(expected, global.getMessageCount(), "global message count lost writes"); + + repo.dispose(); + } +} diff --git a/selfhost/src/main/java/dev/felnull/itts/config/ConfigImpl.java b/selfhost/src/main/java/dev/felnull/itts/config/ConfigImpl.java index 704dd5f..0967ec6 100644 --- a/selfhost/src/main/java/dev/felnull/itts/config/ConfigImpl.java +++ b/selfhost/src/main/java/dev/felnull/itts/config/ConfigImpl.java @@ -5,6 +5,7 @@ import dev.felnull.itts.config.old.ConfigV0; import dev.felnull.itts.core.config.Config; import dev.felnull.itts.core.config.DataBaseConfig; +import dev.felnull.itts.core.config.MetricsConfig; import dev.felnull.itts.core.config.voicetype.VoiceTextConfig; import dev.felnull.itts.core.config.voicetype.VoicevoxConfig; import dev.felnull.itts.core.util.NameSerializableEnum; @@ -26,6 +27,7 @@ * @param coeirolnkConfig COEIROLNK コンフィグ * @param sharevoxConfig SHAREVOX コンフィグ * @param dataBaseConfig データベースコンフィグ + * @param metricsConfig Prometheusメトリクスコンフィグ */ public record ConfigImpl( String botToken, @@ -35,7 +37,8 @@ public record ConfigImpl( VoicevoxConfig voicevoxConfig, VoicevoxConfig coeirolnkConfig, VoicevoxConfig sharevoxConfig, - DataBaseConfig dataBaseConfig + DataBaseConfig dataBaseConfig, + MetricsConfig metricsConfig ) implements Config { /** @@ -52,6 +55,7 @@ public ConfigImpl load(JsonObject json5) { VoicevoxConfig coeirolnkConfig = VoicevoxConfigImpl.fromJson(Optional.ofNullable(json5.getObject("coeirolnk")).orElseGet(JsonObject::new)); VoicevoxConfig sharevoxConfig = VoicevoxConfigImpl.fromJson(Optional.ofNullable(json5.getObject("sharevox")).orElseGet(JsonObject::new)); DataBaseConfig dataBaseConfig = DataBaseConfigImpl.fromJson(Optional.ofNullable(json5.getObject("data_base")).orElseGet(JsonObject::new)); + MetricsConfig metricsConfig = MetricsConfigImpl.fromJson(Optional.ofNullable(json5.getObject("metrics")).orElseGet(JsonObject::new)); return new ConfigImpl( botToken, @@ -61,7 +65,8 @@ public ConfigImpl load(JsonObject json5) { voicevoxConfig, coeirolnkConfig, sharevoxConfig, - dataBaseConfig + dataBaseConfig, + metricsConfig ); } @@ -77,7 +82,8 @@ public ConfigImpl migrate(Object oldConfig) { new VoicevoxConfigImpl(configV0.voicevoxConfig().enable(), configV0.voicevoxConfig().apiUrls(), configV0.voicevoxConfig().checkTime()), new VoicevoxConfigImpl(configV0.coeirolnkConfig().enable(), configV0.coeirolnkConfig().apiUrls(), configV0.coeirolnkConfig().checkTime()), new VoicevoxConfigImpl(configV0.sharevoxConfig().enable(), configV0.sharevoxConfig().apiUrls(), configV0.sharevoxConfig().checkTime()), - new DataBaseConfigImpl() + new DataBaseConfigImpl(), + new MetricsConfigImpl() ); } }; @@ -96,7 +102,8 @@ public static ConfigImpl createInitialConfig() { new VoicevoxConfigImpl(), new VoicevoxConfigImpl(), new VoicevoxConfigImpl(), - new DataBaseConfigImpl() + new DataBaseConfigImpl(), + new MetricsConfigImpl() ); } @@ -112,6 +119,7 @@ public void writeToJson(JsonObject json5) { json5.put("coeirolnk", ((VoicevoxConfigImpl) this.coeirolnkConfig).toJson(), "COEIROLNKのコンフィグ"); json5.put("sharevox", ((VoicevoxConfigImpl) this.sharevoxConfig).toJson(), "SHAREVOXのコンフィグ"); json5.put("data_base", ((DataBaseConfigImpl) this.dataBaseConfig).toJson(), "データベースのコンフィグ"); + json5.put("metrics", ((MetricsConfigImpl) this.metricsConfig).toJson(), "Prometheusメトリクスのコンフィグ"); } @Override @@ -154,6 +162,11 @@ public DataBaseConfig getDataBaseConfig() { return dataBaseConfig; } + @Override + public MetricsConfig getMetricsConfig() { + return metricsConfig; + } + /** * VOICETEXTコンフィグの実装 * @@ -313,4 +326,52 @@ public JsonObject toJson() { return password; } } + + /** + * Prometheusメトリクスコンフィグの実装 + * + * @param enabled 有効かどうか + * @param bindAddress バインドアドレス + * @param port ポート番号 + */ + private record MetricsConfigImpl( + boolean enabled, + String bindAddress, + @Range(from = 0, to = 65535) int port + ) implements MetricsConfig { + + private MetricsConfigImpl() { + this(DEFAULT_ENABLED, DEFAULT_BIND_ADDRESS, DEFAULT_PORT); + } + + public static MetricsConfigImpl fromJson(JsonObject jo) { + boolean enabled = jo.getBoolean("enable", DEFAULT_ENABLED); + String bindAddress = Json5Utils.getStringOrElse(jo, "bind_address", DEFAULT_BIND_ADDRESS); + int port = jo.getInt("port", DEFAULT_PORT); + return new MetricsConfigImpl(enabled, bindAddress, port); + } + + public JsonObject toJson() { + JsonObject jo = new JsonObject(); + jo.put("enable", JsonPrimitive.of(enabled), "Prometheusメトリクス公開を有効にするかどうか"); + jo.put("bind_address", JsonPrimitive.of(bindAddress), "メトリクスHTTPサーバーのバインドアドレス"); + jo.put("port", new JsonPrimitive((long) port), "メトリクスHTTPサーバーのポート番号"); + return jo; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public @NotNull String getBindAddress() { + return bindAddress; + } + + @Override + public @Range(from = 0, to = 65535) int getPort() { + return port; + } + } }