feat: log through SLF4J and report errors to Sentry - #208
Merged
Conversation
Titan shipped without an SLF4J binding. slf4j-api arrived transitively
through Minestom, no provider was ever declared, and the fat jars answered
every log call with "No SLF4J providers were found" and dropped it. On top
of that, Minestom's default ExceptionManager handler is
Throwable::printStackTrace, so a listener that threw left a bare stack
trace on stdout and nothing else. A production lobby was effectively
running blind.
Logging
- logback-classic as runtimeOnly in :app and :setup, slf4j-api declared
explicitly where code compiles against it
- one logback.xml in :common, so both fat jars get the same configuration
without copying it
- TitanObservability.installExceptionHandler() replaces Minestom's
printStackTrace default, so exceptions reach the log like everything else
Sentry
- optional, exactly as Sturnus does it: no TITAN_SENTRY_DSN means
Sentry.init is never called and the process behaves as it does today.
One jar serves operators with and without a Sentry instance.
- release comes from the jar's Implementation-Version manifest attribute
(added in titan.java-conventions), so an issue points at a deploy
- environment from TITAN_SENTRY_ENVIRONMENT
- reporting has exactly one entry point, the logback appender at ERROR.
No second path that could double-report or drift out of sync.
Player attribution
EventNodeImpl catches what a listener throws and hands it to the exception
manager one frame up, on the same thread. TitanObservability.guard() sits
inside that frame and records the failing event's player, which the handler
puts into the MDC as player.uuid / player.name. Recording happens in a
catch block only - a listener that returns normally pays for an entered try
and nothing else, which matters because the guarded set includes
PlayerMoveEvent and PlayerPacketEvent.
sendDefaultPii stays false: the SDK's PII defaults are request headers and
IP addresses, which say nothing about a Minestom crash. The player identity
that does is attached deliberately.
Verified against the real fat jar. Without a DSN:
INFO n.o.t.c.o.TitanObservability - Sentry reporting disabled - ...
INFO luckperms - Loading configuration...
ERROR n.o.t.c.o.TitanObservability - Unhandled exception
java.nio.file.NoSuchFileException: worlds
With TITAN_SENTRY_DSN and TITAN_SENTRY_ENVIRONMENT set:
INFO n.o.t.c.o.TitanObservability - Sentry reporting enabled -
release 1.14.0, environment smoketest
Before this change the same run printed "No SLF4J providers were found"
and a bare stack trace.
The appender is declared with an empty DSN on purpose. It is constructed
when the first logger is created, necessarily before bootstrap() can call
Sentry.init, and an appender that finds no DSN then logs "DSN is required"
at WARN - which makes Logback dump its entire configuration status on every
start.
7 new tests in :common (63 total, 0 failures).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtbnMbAj6m2q3vrDYFizDr
TheMeinerLP
merged commit Aug 28, 2026
6b4c4e6
into
build/minestom-extensions
12 of 13 checks passed
TheMeinerLP
added a commit
that referenced
this pull request
Aug 28, 2026
Titan shipped without an SLF4J binding. slf4j-api arrived transitively
through Minestom, no provider was ever declared, and the fat jars answered
every log call with "No SLF4J providers were found" and dropped it. On top
of that, Minestom's default ExceptionManager handler is
Throwable::printStackTrace, so a listener that threw left a bare stack
trace on stdout and nothing else. A production lobby was effectively
running blind.
Logging
- logback-classic as runtimeOnly in :app and :setup, slf4j-api declared
explicitly where code compiles against it
- one logback.xml in :common, so both fat jars get the same configuration
without copying it
- TitanObservability.installExceptionHandler() replaces Minestom's
printStackTrace default, so exceptions reach the log like everything else
Sentry
- optional, exactly as Sturnus does it: no TITAN_SENTRY_DSN means
Sentry.init is never called and the process behaves as it does today.
One jar serves operators with and without a Sentry instance.
- release comes from the jar's Implementation-Version manifest attribute
(added in titan.java-conventions), so an issue points at a deploy
- environment from TITAN_SENTRY_ENVIRONMENT
- reporting has exactly one entry point, the logback appender at ERROR.
No second path that could double-report or drift out of sync.
Player attribution
EventNodeImpl catches what a listener throws and hands it to the exception
manager one frame up, on the same thread. TitanObservability.guard() sits
inside that frame and records the failing event's player, which the handler
puts into the MDC as player.uuid / player.name. Recording happens in a
catch block only - a listener that returns normally pays for an entered try
and nothing else, which matters because the guarded set includes
PlayerMoveEvent and PlayerPacketEvent.
sendDefaultPii stays false: the SDK's PII defaults are request headers and
IP addresses, which say nothing about a Minestom crash. The player identity
that does is attached deliberately.
Verified against the real fat jar. Without a DSN:
INFO n.o.t.c.o.TitanObservability - Sentry reporting disabled - ...
INFO luckperms - Loading configuration...
ERROR n.o.t.c.o.TitanObservability - Unhandled exception
java.nio.file.NoSuchFileException: worlds
With TITAN_SENTRY_DSN and TITAN_SENTRY_ENVIRONMENT set:
INFO n.o.t.c.o.TitanObservability - Sentry reporting enabled -
release 1.14.0, environment smoketest
Before this change the same run printed "No SLF4J providers were found"
and a bare stack trace.
The appender is declared with an empty DSN on purpose. It is constructed
when the first logger is created, necessarily before bootstrap() can call
Sentry.init, and an appender that finds no DSN then logs "DSN is required"
at WARN - which makes Logback dump its entire configuration status on every
start.
7 new tests in :common (63 total, 0 failures).
Claude-Session: https://claude.ai/code/session_01QtbnMbAj6m2q3vrDYFizDr
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
TheMeinerLP
added a commit
that referenced
this pull request
Aug 28, 2026
* build: switch to the OneLiteFeather minestom-extensions fork dev.hollowcube:minestom-ce-extensions is archived and resolves extension dependencies through a Kotlin class (net.minestom.dependencies.maven. MavenRepository). That forced two workarounds Titan carried in comments: bundling kotlin-stdlib-jdk8 into the fat jar, and routing resolution through the reposilite JitPack proxy for com.github.Minestom: DependencyGetter. net.onelitefeather:minestom-extensions 2.2.0 keeps the same packages (net.hollowcube.minestom.extensions, net.minestom.server.extensions) but resolves through Maven Resolver, so both workarounds are gone: - kotlin-stdlib-jdk8 dropped from :app and from the version catalog - the JitPack proxy repository replaced by the credential-free OneLiteFeatherReleases repository the fork is published to. Verified with a cold Gradle user home: all 65 modules resolve without it, com.github.CloudNetService.cloud-command-framework included (it comes from repository.derklaro.dev). The fork also offers ExtensionBootstrap.init(Auth), added upstream for exactly this case. Titan's Velocity path no longer has to reach for getDeclaredConstructor(MinecraftServer.class) + setAccessible(true) - the reflection hack listed as open point 1 in the OLF Minestom project standard is gone. :bridge now generates extension.json from @ExtensionInfo instead of keeping a hand-written file plus a ReplaceTokens filter for @Version@. The generated descriptor carries the same name, entrypoint, version, authors and dependencies; only key order and whitespace differ. The annotation has source retention, so the bridge jar holds exactly the same entries as before. Verified: 56 tests, 0 failures (unchanged). The Velocity branch was smoke tested against the real fat jar with -Dminestom.velocity.secret set; it gets past ExtensionBootstrap and LuckPerms and stops at the same missing worlds/ directory as the pre-change jar does in the same run. Fat jar shrinks from 43.7 MB to 40.8 MB (25650 to 23719 entries): the old DependencyGetter dragged in shrinkwrap-resolver, arquillian-spacelift, jsoup and a large commons/plexus tree; Maven Resolver replaces them with aether plus httpclient. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QtbnMbAj6m2q3vrDYFizDr * feat: log through SLF4J and report errors to Sentry (#208) Titan shipped without an SLF4J binding. slf4j-api arrived transitively through Minestom, no provider was ever declared, and the fat jars answered every log call with "No SLF4J providers were found" and dropped it. On top of that, Minestom's default ExceptionManager handler is Throwable::printStackTrace, so a listener that threw left a bare stack trace on stdout and nothing else. A production lobby was effectively running blind. Logging - logback-classic as runtimeOnly in :app and :setup, slf4j-api declared explicitly where code compiles against it - one logback.xml in :common, so both fat jars get the same configuration without copying it - TitanObservability.installExceptionHandler() replaces Minestom's printStackTrace default, so exceptions reach the log like everything else Sentry - optional, exactly as Sturnus does it: no TITAN_SENTRY_DSN means Sentry.init is never called and the process behaves as it does today. One jar serves operators with and without a Sentry instance. - release comes from the jar's Implementation-Version manifest attribute (added in titan.java-conventions), so an issue points at a deploy - environment from TITAN_SENTRY_ENVIRONMENT - reporting has exactly one entry point, the logback appender at ERROR. No second path that could double-report or drift out of sync. Player attribution EventNodeImpl catches what a listener throws and hands it to the exception manager one frame up, on the same thread. TitanObservability.guard() sits inside that frame and records the failing event's player, which the handler puts into the MDC as player.uuid / player.name. Recording happens in a catch block only - a listener that returns normally pays for an entered try and nothing else, which matters because the guarded set includes PlayerMoveEvent and PlayerPacketEvent. sendDefaultPii stays false: the SDK's PII defaults are request headers and IP addresses, which say nothing about a Minestom crash. The player identity that does is attached deliberately. Verified against the real fat jar. Without a DSN: INFO n.o.t.c.o.TitanObservability - Sentry reporting disabled - ... INFO luckperms - Loading configuration... ERROR n.o.t.c.o.TitanObservability - Unhandled exception java.nio.file.NoSuchFileException: worlds With TITAN_SENTRY_DSN and TITAN_SENTRY_ENVIRONMENT set: INFO n.o.t.c.o.TitanObservability - Sentry reporting enabled - release 1.14.0, environment smoketest Before this change the same run printed "No SLF4J providers were found" and a bare stack trace. The appender is declared with an empty DSN on purpose. It is constructed when the first logger is created, necessarily before bootstrap() can call Sentry.init, and an appender that finds no DSN then logs "DSN is required" at WARN - which makes Logback dump its entire configuration status on every start. 7 new tests in :common (63 total, 0 failures). Claude-Session: https://claude.ai/code/session_01QtbnMbAj6m2q3vrDYFizDr Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warum
Titan lief in Produktion faktisch blind:
slf4j-apikam nur transitiv über Minestom, ein Provider war nie deklariert. Der Fat Jar antwortete auf jeden Log-Aufruf mitNo SLF4J providers were foundund verwarf ihn.Throwable::printStackTrace. Eine Exception in einem Listener landete roh auf stdout, an SLF4J vorbei.Zusammen hieß das: ein abstürzender Listener hinterließ einen nackten Stacktrace und sonst nichts.
Logging
logback-classicalsruntimeOnlyin:appund:setup,slf4j-apiexplizit deklariertlogback.xmlin:common— beide Fat Jars bündeln:common, also eine Konfiguration ohne KopieTitanObservability.installExceptionHandler()ersetzt MinestomsprintStackTrace-DefaultSentry
Optional, nach dem Muster von Sturnus: ohne
TITAN_SENTRY_DSNwirdSentry.initnie aufgerufen und der Prozess verhält sich exakt wie heute. Ein Jar bedient Betreiber mit und ohne Sentry-Instanz.TITAN_SENTRY_DSNTITAN_SENTRY_ENVIRONMENTproduction,beta, … — DefaultunknownDie Release-Angabe kommt aus dem
Implementation-Version-Manifest-Attribut des Fat Jars (neu intitan.java-conventions), ein Sentry-Issue zeigt also auf einen konkreten Deploy.Reporting hat genau einen Eingang: den Logback-Appender ab
ERROR. Kein zweiter, paralleler Pfad, der doppelt melden oder auseinanderlaufen könnte.Spieler-Zuordnung
Wie besprochen mit UUID und Name.
EventNodeImplfängt ab, was ein Listener wirft, und reicht es einen Frame höher an den ExceptionManager — auf demselben Thread.TitanObservability.guard()sitzt in diesem Frame, merkt sich den Spieler des fehlgeschlagenen Events und wirft weiter; der Handler legt ihn alsplayer.uuid/player.namein den MDC, von wo der Sentry-Appender ihn übernimmt.Kein Kostenpunkt auf dem gesunden Pfad. Aufgezeichnet wird ausschließlich im
catch-Block. Ein Listener, der normal zurückkehrt, zahlt ein betretenestryund sonst nichts — relevant, weil zu den 18 gewrappten ListenernPlayerMoveEventundPlayerPacketEventgehören.sendDefaultPiibleibtfalse: die PII-Defaults des SDK sind Request-Header und IP-Adressen, die über einen Minestom-Absturz nichts aussagen. Die Spieleridentität, die etwas aussagt, wird bewusst gesetzt.Verifikation am echten Fat Jar
Ohne DSN:
Mit DSN:
Vor dieser Änderung gab derselbe Lauf
SLF4J(W): No SLF4J providers were foundaus und danach einen nackten Stacktrace. Die Release-Angabe1.14.0belegt, dass das Manifest-Attribut greift.Tests: 63 gesamt, 0 Fehler — davon 7 neue in
:common:assertSame)Sentry.isEnabled()falsedevzurück statt aufnullEin Detail, das Erklärung braucht
Der Appender wird mit leerem DSN deklariert:
Er wird konstruiert, sobald der erste Logger entsteht — zwangsläufig bevor
bootstrap()Sentry.initaufrufen kann. Ein Appender, der zu diesem Zeitpunkt keinen DSN findet, loggtDSN is requiredauf WARN, und das lässt Logback bei jedem Start seinen kompletten Konfigurations-Status ausgeben (~25 Zeilen). Mit leerem DSN ist der Start sauber;bootstrap()entscheidet ein paar Anweisungen später über die globale SDK-Instanz, ob tatsächlich gesendet wird.Anmerkung
Commit-Typ ist
feat:— anders als #206/#207 erscheint das hier also im Changelog. Das ist beabsichtigt: Betreiber müssen wissen, ab welcher VersionTITAN_SENTRY_DSNgesetzt werden kann.🤖 Generated with Claude Code
https://claude.ai/code/session_01QtbnMbAj6m2q3vrDYFizDr