Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 30 additions & 21 deletions app/src/main/java/net/onelitefeather/titan/app/Titan.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions buildSrc/src/main/kotlin/titan.java-conventions.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Jar>().configureEach {
manifest {
attributes("Implementation-Version" to rootProject.version.toString())
}
}

tasks.withType<JavaCompile>().configureEach {
options.release.set(25)
options.encoding = "UTF-8"
Expand Down
7 changes: 7 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>{@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.
*
* <h2>Player attribution</h2>
*
* <p>{@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.
*
* <p>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}.
*
* <h2>Sentry is optional</h2>
*
* <p>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<PlayerIdentity> 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.
*
* <p>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 <T> the event type
* @return a listener that behaves identically but leaves player context behind when it throws
*/
public static <T extends Event> Consumer<T> guard(Consumer<T> 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) {
}
}
45 changes: 45 additions & 0 deletions common/src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
One configuration for both server processes. It lives in :common rather than being copied into
:app and :setup because both fat jars bundle :common, so a single file reaches both - and Logback
reads exactly one logback.xml from the class path anyway.

The Sentry appender is a no-op until TitanObservability.bootstrap() has initialised the SDK, which
it only does when TITAN_SENTRY_DSN is set. Without a DSN this file behaves as if the appender were
not here, so the same jar runs with and without a Sentry instance.
-->
<configuration>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<appender name="SENTRY" class="io.sentry.logback.SentryAppender">
<!-- An empty DSN here is deliberate. The appender is constructed while the first logger is
created, which is necessarily before TitanObservability.bootstrap() can call
Sentry.init - and an appender that finds no DSN at that moment logs "DSN is required"
at WARN, which in turn makes Logback dump its whole configuration status on every
start. Declaring the DSN empty is the documented way to say "disabled for now"; the
appender reports through the global SDK state, so bootstrap() enabling Sentry a few
statements later is what decides whether anything is actually sent. -->
<options>
<dsn>${TITAN_SENTRY_DSN:-}</dsn>
</options>
<!-- Errors become Sentry issues; everything above DEBUG rides along as breadcrumbs so an
issue carries the log lines that led up to it. -->
<minimumEventLevel>ERROR</minimumEventLevel>
<minimumBreadcrumbLevel>INFO</minimumBreadcrumbLevel>
</appender>

<!-- Minestom's registry loading and Netty are chatty at DEBUG and say nothing an operator
needs during normal operation. -->
<logger name="io.netty" level="WARN"/>

<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="SENTRY"/>
</root>

</configuration>
Loading
Loading