diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad873b5a..9543e04b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,6 +38,13 @@ dependencies { // it (unrelocated) on the classpath, so bundle it explicitly now. implementation(libs.guava) + // Logging. Without a binding every log call in the shipped jar answered "No SLF4J providers + // were found" and was dropped; sentry-logback is the appender logback.xml refers to. + implementation(libs.slf4j.api) + runtimeOnly(libs.logback.classic) + runtimeOnly(platform(libs.sentry.bom)) + runtimeOnly(libs.sentry.logback) + testImplementation(platform(libs.aonyx.bom)) testImplementation(libs.minestom) testImplementation(libs.aves) diff --git a/app/src/main/java/net/onelitefeather/titan/app/Titan.java b/app/src/main/java/net/onelitefeather/titan/app/Titan.java index 7504a14f..6ef009e4 100644 --- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java +++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java @@ -36,10 +36,13 @@ import net.onelitefeather.titan.common.event.EntityDismountEvent; import net.onelitefeather.titan.common.helper.BlockHandlerHelper; import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.common.observability.TitanObservability; import net.onelitefeather.titan.common.utils.Cancelable; import java.nio.file.Path; +import static net.onelitefeather.titan.common.observability.TitanObservability.guard; + public final class Titan { private final Path path; @@ -78,35 +81,41 @@ private void initCommands() { MinecraftServer.getCommandManager().register(new StopCommand()); } + /** + * Registers every listener through {@link TitanObservability#guard}, so a listener that throws + * leaves behind which player the event belonged to before Minestom's exception handler reports + * it. The wrapper only acts on the failure path; a listener that returns normally is + * unaffected. + */ private void initListeners() { - this.eventNode.addListener(PickupItemEvent.class, Cancelable::cancel); - this.eventNode.addListener(InventoryPreClickEvent.class, Cancelable::cancel); - this.eventNode.addListener(PlayerBlockBreakEvent.class, Cancelable::cancel); - this.eventNode.addListener(PlayerBlockPlaceEvent.class, Cancelable::cancel); - this.eventNode.addListener(PlayerSwapItemEvent.class, Cancelable::cancel); - this.eventNode.addListener(ItemDropEvent.class, Cancelable::cancel); + this.eventNode.addListener(PickupItemEvent.class, guard(Cancelable::cancel)); + this.eventNode.addListener(InventoryPreClickEvent.class, guard(Cancelable::cancel)); + this.eventNode.addListener(PlayerBlockBreakEvent.class, guard(Cancelable::cancel)); + this.eventNode.addListener(PlayerBlockPlaceEvent.class, guard(Cancelable::cancel)); + this.eventNode.addListener(PlayerSwapItemEvent.class, guard(Cancelable::cancel)); + this.eventNode.addListener(ItemDropEvent.class, guard(Cancelable::cancel)); - this.eventNode.addListener(PlayerDeathEvent.class, new DeathListener()); - this.eventNode.addListener(EntityAttackEvent.class, new TickleListener(this.appConfigProvider.getAppConfig())); + this.eventNode.addListener(PlayerDeathEvent.class, guard(new DeathListener())); + this.eventNode.addListener(EntityAttackEvent.class, guard(new TickleListener(this.appConfigProvider.getAppConfig()))); - this.eventNode.addListener(PlayerBlockInteractEvent.class, new SitListener(this.appConfigProvider.getAppConfig())); - this.eventNode.addListener(PlayerPacketEvent.class, new SitLeavePacketListener()); - this.eventNode.addListener(EntityDismountEvent.class, new SitDismountListener()); - this.eventNode.addListener(PlayerDisconnectEvent.class, new SitDisconnectListener()); + this.eventNode.addListener(PlayerBlockInteractEvent.class, guard(new SitListener(this.appConfigProvider.getAppConfig()))); + this.eventNode.addListener(PlayerPacketEvent.class, guard(new SitLeavePacketListener())); + this.eventNode.addListener(EntityDismountEvent.class, guard(new SitDismountListener())); + this.eventNode.addListener(PlayerDisconnectEvent.class, guard(new SitDisconnectListener())); - this.eventNode.addListener(PlayerUseItemEvent.class, new NavigationListener(this.navigationHelper)); + this.eventNode.addListener(PlayerUseItemEvent.class, guard(new NavigationListener(this.navigationHelper))); - this.eventNode.addListener(PlayerStartFlyingWithElytraEvent.class, new ElytraStartFlyingListener()); - this.eventNode.addListener(PlayerStopFlyingWithElytraEvent.class, new ElytraStopFlyingListener()); - this.eventNode.addListener(PlayerUseItemEvent.class, new ElytraBoostListener(this.appConfigProvider.getAppConfig())); + this.eventNode.addListener(PlayerStartFlyingWithElytraEvent.class, guard(new ElytraStartFlyingListener())); + this.eventNode.addListener(PlayerStopFlyingWithElytraEvent.class, guard(new ElytraStopFlyingListener())); + this.eventNode.addListener(PlayerUseItemEvent.class, guard(new ElytraBoostListener(this.appConfigProvider.getAppConfig()))); - this.eventNode.addListener(PlayerRespawnEvent.class, new RespawnListener(this.navigationHelper)); - this.eventNode.addListener(PlayerMoveEvent.class, new PlayerMoveListener(this.appConfigProvider.getAppConfig(), this.mapProvider.getActiveLobby())); + this.eventNode.addListener(PlayerRespawnEvent.class, guard(new RespawnListener(this.navigationHelper))); + this.eventNode.addListener(PlayerMoveEvent.class, guard(new PlayerMoveListener(this.appConfigProvider.getAppConfig(), this.mapProvider.getActiveLobby()))); - this.eventNode.addListener(AsyncPlayerConfigurationEvent.class, new PlayerConfigurationListener(this.mapProvider)); - this.eventNode.addListener(PlayerSpawnEvent.class, new PlayerSpawnListener( - this.appConfigProvider.getAppConfig(), this.mapProvider.getActiveLobby(), this.navigationHelper)); + this.eventNode.addListener(AsyncPlayerConfigurationEvent.class, guard(new PlayerConfigurationListener(this.mapProvider))); + this.eventNode.addListener(PlayerSpawnEvent.class, guard(new PlayerSpawnListener( + this.appConfigProvider.getAppConfig(), this.mapProvider.getActiveLobby(), this.navigationHelper))); MinecraftServer.getGlobalEventHandler().addChild(eventNode); } diff --git a/app/src/main/java/net/onelitefeather/titan/app/TitanApplication.java b/app/src/main/java/net/onelitefeather/titan/app/TitanApplication.java index 8e0d17cc..671d63c4 100644 --- a/app/src/main/java/net/onelitefeather/titan/app/TitanApplication.java +++ b/app/src/main/java/net/onelitefeather/titan/app/TitanApplication.java @@ -21,6 +21,7 @@ import net.minestom.server.Auth; import net.minestom.server.MinecraftServer; import net.minestom.server.command.CommandManager; +import net.onelitefeather.titan.common.observability.TitanObservability; import net.onelitefeather.titan.common.permission.TitanPermissionBridge; import java.io.BufferedReader; @@ -40,11 +41,18 @@ public class TitanApplication { private static final Path VELOCITY_SECRET_FILE = Path.of("forwarding.secret"); public static void main(String[] args) { + // First statement: anything logged before this reaches the console but not Sentry. + TitanObservability.bootstrap(); + // minestom-extensions loads platform extensions (the CloudNet bridge among // them) from the extensions/ folder; running standalone simply loads none. // This replaces the manual MinestomBridgeExtension wiring + .wrapper guard. ExtensionBootstrap bootstrap = bootstrap(); + // Needs an initialised MinecraftServer, which the line above provides. Replaces + // Minestom's Throwable::printStackTrace default with SLF4J logging. + TitanObservability.installExceptionHandler(); + me.lucko.luckperms.minestom.loader.MinestomLoader.get().load().registerShutdownHook().start(); // Let the CloudNet bridge (running in a separate extension classloader, see the diff --git a/buildSrc/src/main/kotlin/titan.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/titan.java-conventions.gradle.kts index 98deac52..66989b55 100644 --- a/buildSrc/src/main/kotlin/titan.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/titan.java-conventions.gradle.kts @@ -13,6 +13,15 @@ java { } } +// TitanObservability reports this as the Sentry release, so an issue can be traced back to the +// deploy that introduced it. Package.getImplementationVersion() reads it from the jar the classes +// were loaded from, which for both server processes is the shaded jar. +tasks.withType().configureEach { + manifest { + attributes("Implementation-Version" to rootProject.version.toString()) + } +} + tasks.withType().configureEach { options.release.set(25) options.encoding = "UTF-8" diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 59deb07d..24f9f5e0 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -9,6 +9,12 @@ dependencies { implementation(libs.togglz) implementation(libs.aves) implementation(libs.adventure.minimessage) + // Logging was relying on Minestom's transitive slf4j-api; declare it where it is used. + implementation(libs.slf4j.api) + // TitanObservability compiles against the Sentry API. The Logback appender that actually + // reports is a runtime concern of the two application modules. + implementation(platform(libs.sentry.bom)) + implementation(libs.sentry) // No CloudNet here anymore: anything touching the CloudNet bridge lives in the // :bridge extension; common only talks to it through the JDK-typed @@ -19,6 +25,7 @@ dependencies { testImplementation(libs.cyano) testImplementation(libs.aves) testImplementation(libs.junit.api) + testImplementation(libs.junit.params) testImplementation(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.engine) } diff --git a/common/src/main/java/net/onelitefeather/titan/common/observability/TitanObservability.java b/common/src/main/java/net/onelitefeather/titan/common/observability/TitanObservability.java new file mode 100644 index 00000000..8c6f152c --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/observability/TitanObservability.java @@ -0,0 +1,198 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.observability; + +import io.sentry.Sentry; +import java.util.function.Consumer; +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.trait.PlayerEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * Error reporting for the Titan server processes. + * + *

Two problems are solved here, and they are related. Minestom's default + * {@link net.minestom.server.exception.ExceptionManager ExceptionManager} handler is + * {@code Throwable::printStackTrace} - every exception thrown inside an event listener or a tick + * went to {@code System.err} unformatted, past SLF4J entirely. And because no SLF4J binding was + * ever + * declared, the shipped fat jars answered every log call with "No SLF4J providers were found" and + * dropped it. Together that meant a crashing listener left nothing behind but a bare stack trace on + * the service's stdout. + * + *

{@link #installExceptionHandler()} routes those exceptions through SLF4J instead, and + * {@code logback.xml} attaches Sentry's appender to the root logger. Sentry therefore has exactly + * one way in - an {@code ERROR} log record - rather than a second, parallel reporting path that + * would have to be kept in sync and would double-report every event. + * + *

Player attribution

+ * + *

{@link net.minestom.server.event.EventNodeImpl EventNodeImpl} catches whatever a listener + * throws and hands it to the exception manager one frame up, on the same thread. {@link #guard} + * sits + * inside that frame: it records who the failing event belonged to and rethrows, so the handler can + * tag the log record - and with it the Sentry event - with the player's UUID and name. + * + *

The recording happens in a {@code catch} block, never on the healthy path. A listener that + * returns normally pays for an entered {@code try} and nothing else, which matters because the + * guarded listeners include {@code PlayerMoveEvent} and {@code PlayerPacketEvent}. + * + *

Sentry is optional

+ * + *

Without {@value #DSN_ENVIRONMENT_VARIABLE} in the environment {@link Sentry#init} is never + * called, so nothing is installed and the process behaves exactly as it does today - the state an + * operator without a Sentry instance is already in. The same jar serves both. + */ +public final class TitanObservability { + + private static final Logger LOGGER = LoggerFactory.getLogger(TitanObservability.class); + + /** Sentry connection string. Absent or blank disables reporting entirely. */ + public static final String DSN_ENVIRONMENT_VARIABLE = "TITAN_SENTRY_DSN"; + + /** Deployment name Sentry groups issues by ({@code production}, {@code beta}, ...). */ + public static final String ENVIRONMENT_ENVIRONMENT_VARIABLE = "TITAN_SENTRY_ENVIRONMENT"; + + private static final String DEFAULT_ENVIRONMENT = "unknown"; + private static final String DEVELOPMENT_RELEASE = "dev"; + + static final String PLAYER_UUID_KEY = "player.uuid"; + static final String PLAYER_NAME_KEY = "player.name"; + + /** + * Set by {@link #guard} on the failure path and consumed by {@link #handleException}. Both run + * on the same thread within one dispatch, so a plain thread local carries the value across the + * rethrow without touching the healthy path. + */ + private static final ThreadLocal FAILING_PLAYER = new ThreadLocal<>(); + + private TitanObservability() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Initialises Sentry when {@value #DSN_ENVIRONMENT_VARIABLE} is set, and does nothing + * otherwise. + * + *

Call this as early in {@code main} as possible: log records emitted before it - LuckPerms' + * bootstrap, for instance - are written to the console but not reported. + */ + public static void bootstrap() { + bootstrap(release()); + } + + static void bootstrap(String release) { + String dsn = System.getenv(DSN_ENVIRONMENT_VARIABLE); + if (dsn == null || dsn.isBlank()) { + LOGGER.info("Sentry reporting disabled - {} is not set", DSN_ENVIRONMENT_VARIABLE); + return; + } + String environment = environment(); + Sentry.init(options -> { + options.setDsn(dsn); + options.setRelease(release); + options.setEnvironment(environment); + // The SDK's PII defaults collect request headers and IP addresses, which say nothing + // useful about a Minestom crash. The player identity that does is attached + // deliberately in handleException instead. + options.setSendDefaultPii(false); + }); + LOGGER.info("Sentry reporting enabled - release {}, environment {}", release, environment); + } + + /** + * Replaces Minestom's {@code Throwable::printStackTrace} default with one that logs through + * SLF4J, so exceptions reach both the console and Sentry's appender. + */ + public static void installExceptionHandler() { + MinecraftServer.getExceptionManager().setExceptionHandler(TitanObservability::handleException); + } + + /** + * Wraps a listener so a failure records which player the event belonged to. + * + * @param listener the listener to wrap + * @param the event type + * @return a listener that behaves identically but leaves player context behind when it throws + */ + public static Consumer guard(Consumer listener) { + return event -> { + try { + listener.accept(event); + } catch (Throwable throwable) { + FAILING_PLAYER.set(identityOf(event)); + throw throwable; + } + }; + } + + /** + * Returns the identity {@link #guard} recorded for this thread's most recent failure, and + * clears it. Clearing is unconditional: a stale identity left behind would mis-attribute the + * next exception this thread reports. + * + * @return the player the failing event belonged to, or {@code null} if there was none + */ + static PlayerIdentity consumeFailingPlayer() { + PlayerIdentity identity = FAILING_PLAYER.get(); + FAILING_PLAYER.remove(); + return identity; + } + + static void handleException(Throwable throwable) { + PlayerIdentity identity = consumeFailingPlayer(); + if (identity == null) { + LOGGER.error("Unhandled exception", throwable); + return; + } + try (MDC.MDCCloseable ignoredUuid = MDC.putCloseable(PLAYER_UUID_KEY, identity.uuid()); MDC.MDCCloseable ignoredName = MDC.putCloseable(PLAYER_NAME_KEY, identity.name())) { + LOGGER.error("Unhandled exception while handling an event for {}", identity.name(), throwable); + } + } + + static PlayerIdentity identityOf(Event event) { + if (!(event instanceof PlayerEvent playerEvent)) { + return null; + } + Player player = playerEvent.getPlayer(); + return new PlayerIdentity(player.getUuid().toString(), player.getUsername()); + } + + /** + * The release reported to Sentry, read from the fat jar's {@code Implementation-Version} + * manifest attribute. Returns {@value #DEVELOPMENT_RELEASE} when the classes are not loaded + * from a jar, which is the case in tests and when running from an IDE. + */ + static String release() { + String version = TitanObservability.class.getPackage().getImplementationVersion(); + return version == null || version.isBlank() ? DEVELOPMENT_RELEASE : version; + } + + private static String environment() { + String environment = System.getenv(ENVIRONMENT_ENVIRONMENT_VARIABLE); + return environment == null || environment.isBlank() ? DEFAULT_ENVIRONMENT : environment; + } + + /** + * The player an exception is attributed to. Strings, so nothing keeps a {@link Player} alive. + */ + record PlayerIdentity(String uuid, String name) { + } +} diff --git a/common/src/main/resources/logback.xml b/common/src/main/resources/logback.xml new file mode 100644 index 00000000..f7547cc5 --- /dev/null +++ b/common/src/main/resources/logback.xml @@ -0,0 +1,45 @@ + + + + + + + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + + + + + + + ${TITAN_SENTRY_DSN:-} + + + ERROR + INFO + + + + + + + + + + + diff --git a/common/src/test/java/net/onelitefeather/titan/common/observability/TitanObservabilityTest.java b/common/src/test/java/net/onelitefeather/titan/common/observability/TitanObservabilityTest.java new file mode 100644 index 00000000..b1e9ce88 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/observability/TitanObservabilityTest.java @@ -0,0 +1,140 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.observability; + +import io.sentry.Sentry; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.trait.PlayerEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(MicrotusExtension.class) +class TitanObservabilityTest { + + /** A listener failure that has nothing to do with a player. */ + private record PlainEvent() implements Event { + } + + private record PlayerBoundEvent(Player player) implements PlayerEvent { + + @Override + public Player getPlayer() { + return this.player; + } + } + + @AfterEach + void clearRecordedIdentity() { + TitanObservability.consumeFailingPlayer(); + } + + @DisplayName("A listener that returns normally is passed through and records no player") + @Test + void guardDelegatesWithoutRecordingOnTheHealthyPath() { + AtomicInteger calls = new AtomicInteger(); + Consumer guarded = TitanObservability.guard(event -> calls.incrementAndGet()); + + guarded.accept(new PlainEvent()); + + Assertions.assertEquals(1, calls.get(), "the wrapped listener must still be invoked"); + Assertions.assertNull(TitanObservability.consumeFailingPlayer(), "a successful dispatch must not leave player context behind"); + } + + @DisplayName("A failing listener rethrows the original throwable unchanged") + @Test + void guardRethrowsTheOriginalThrowable() { + IllegalStateException failure = new IllegalStateException("listener broke"); + Consumer guarded = TitanObservability.guard(event -> { + throw failure; + }); + + IllegalStateException thrown = Assertions.assertThrows(IllegalStateException.class, () -> guarded.accept(new PlainEvent())); + + Assertions.assertSame(failure, thrown, "the guard must not wrap or swallow the failure"); + } + + @DisplayName("A failing listener on a player event records that player's uuid and name") + @Test + void guardRecordsThePlayerOfAFailingEvent(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + Consumer guarded = TitanObservability.guard(event -> { + throw new IllegalStateException("listener broke"); + }); + + Assertions.assertThrows(IllegalStateException.class, () -> guarded.accept(new PlayerBoundEvent(player))); + + TitanObservability.PlayerIdentity identity = TitanObservability.consumeFailingPlayer(); + Assertions.assertNotNull(identity, "a failure on a player event must record the player"); + Assertions.assertEquals(player.getUuid().toString(), identity.uuid()); + Assertions.assertEquals(player.getUsername(), identity.name()); + } + + @DisplayName("Recorded player context is consumed once, so it cannot mis-attribute a later failure") + @Test + void recordedIdentityIsClearedAfterBeingConsumed(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + Consumer guarded = TitanObservability.guard(event -> { + throw new IllegalStateException("listener broke"); + }); + Assertions.assertThrows(IllegalStateException.class, () -> guarded.accept(new PlayerBoundEvent(player))); + + Assertions.assertNotNull(TitanObservability.consumeFailingPlayer()); + Assertions.assertNull(TitanObservability.consumeFailingPlayer(), "the second read must be empty - otherwise the next exception is blamed on this player"); + } + + @DisplayName("A failure on an event without a player records no identity") + @Test + void guardRecordsNothingForAnEventWithoutAPlayer() { + Consumer guarded = TitanObservability.guard(event -> { + throw new IllegalStateException("listener broke"); + }); + + Assertions.assertThrows(IllegalStateException.class, () -> guarded.accept(new PlainEvent())); + + Assertions.assertNull(TitanObservability.consumeFailingPlayer()); + } + + @DisplayName("Without a DSN Sentry is never initialised") + @Test + void bootstrapLeavesSentryDisabledWithoutADsn() { + // The environment variable is not set for this build, which is the operator-without-Sentry + // case: bootstrap must be a no-op rather than a failure. + Assertions.assertNull(System.getenv(TitanObservability.DSN_ENVIRONMENT_VARIABLE), "this test asserts the disabled path - unset " + TitanObservability.DSN_ENVIRONMENT_VARIABLE); + + TitanObservability.bootstrap("test-release"); + + Assertions.assertFalse(Sentry.isEnabled(), "no DSN must leave the SDK untouched"); + } + + @DisplayName("Outside a jar the release falls back to dev rather than null") + @Test + void releaseFallsBackToDevWhenNoManifestIsPresent() { + // Tests run from a class directory, so the Implementation-Version attribute the shaded jar + // carries is absent here. + Assertions.assertEquals("dev", TitanObservability.release()); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 415662c6..f520c090 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,6 +48,10 @@ dependencyResolutionManagement { version("mockito", "5.23.0") + version("slf4j", "2.0.18") + version("logback", "1.5.20") + version("sentry", "8.30.0") + // Minestom library("aonyx-bom", "net.onelitefeather", "aonyx-bom").versionRef("aonyx-bom") library("minestom","net.minestom", "minestom").withoutVersion() @@ -89,6 +93,19 @@ dependencyResolutionManagement { // Guava: unrelocated, expected by LuckPerms (was transitive via CloudNet). library("guava", "com.google.guava", "guava").versionRef("guava") + + // Logging. slf4j-api used to arrive transitively through Minestom and no binding was + // ever declared, so the shipped fat jars logged nothing at all ("No SLF4J providers + // were found"). Declare both explicitly: the API where code compiles against it, the + // Logback binding as runtimeOnly in the two application modules. + library("slf4j-api", "org.slf4j", "slf4j-api").versionRef("slf4j") + library("logback-classic", "ch.qos.logback", "logback-classic").versionRef("logback") + + // Error reporting. sentry-logback is the appender referenced from logback.xml; it + // pulls io.sentry:sentry, which TitanObservability compiles against. + library("sentry-bom", "io.sentry", "sentry-bom").versionRef("sentry") + library("sentry", "io.sentry", "sentry").withoutVersion() + library("sentry-logback", "io.sentry", "sentry-logback").withoutVersion() } } } diff --git a/setup/build.gradle.kts b/setup/build.gradle.kts index beb8ecb2..70dacdb1 100644 --- a/setup/build.gradle.kts +++ b/setup/build.gradle.kts @@ -15,6 +15,12 @@ dependencies { implementation(libs.adventure.minimessage) implementation(libs.caffeine) + // Logging. See :app - the setup server had the same silent-logger problem. + implementation(libs.slf4j.api) + runtimeOnly(libs.logback.classic) + runtimeOnly(platform(libs.sentry.bom)) + runtimeOnly(libs.sentry.logback) + testImplementation(platform(libs.aonyx.bom)) testImplementation(libs.junit.api) testImplementation(libs.junit.platform.launcher) diff --git a/setup/src/main/java/net/onelitefeather/titan/setup/TitanLauncher.java b/setup/src/main/java/net/onelitefeather/titan/setup/TitanLauncher.java index aefa2a25..4750b5dc 100644 --- a/setup/src/main/java/net/onelitefeather/titan/setup/TitanLauncher.java +++ b/setup/src/main/java/net/onelitefeather/titan/setup/TitanLauncher.java @@ -16,10 +16,15 @@ package net.onelitefeather.titan.setup; import net.minestom.server.MinecraftServer; +import net.onelitefeather.titan.common.observability.TitanObservability; public class TitanLauncher { public static void main(String[] args) { + // First statement: anything logged before this reaches the console but not Sentry. + TitanObservability.bootstrap(); var minecraftServer = MinecraftServer.init(); + // Needs an initialised MinecraftServer, which the line above provides. + TitanObservability.installExceptionHandler(); Titan.instance(); // CloudNet passes the bind address/port via -Dservice.bind.host / // -Dservice.bind.port; fall back to the standalone defaults otherwise.