A Paper plugin that watches tick health, world load, memory and CPU, works out which plugin is actually burning the tick, and pushes alerts to Discord or any webhook when things go bad.
Built for Minecraft 26.2, Java 25.
- Tick monitoring: TPS across 5s/1m/5m/15m windows, MSPT average, p95 and peak, plus a distribution histogram and a log of individual stalls with timestamps.
- Per-world stats: entities, living entities, loaded chunks, players and scan cost, per world.
- Entity and chunk hotspots: counts by type, and the chunks with the worst entity pile-ups (click a hotspot to get a teleport command).
- Plugin attribution: a background stack sampler tells you what share of the main thread each plugin owns, down to individual methods. Optional event-handler timing on top of that.
- Memory and CPU: heap, committed, native, per-pool usage, GC load as a percentage of wall clock, collection rate, process/system CPU, threads and JVM flags.
- Automatic lag detection: a small rule engine with per-rule streak counts and cooldowns, so a single bad tick does not page you, and a long outage does not spam you.
- Alerts: console, in-game, Discord embeds and generic JSON webhooks, with retry and a per-minute budget. Recovery notices when a rule clears.
- Reports: a full text snapshot written to disk, automatically on any critical alert.
- Paper
26.2or newer - Java 25
mvn clean packageThe jar lands in target/SPM-26.2.jar. Drop it in plugins/ and restart.
Root command is /spm, aliased to /perf and /lagcheck. Running it bare gives the overview.
| Command | What it shows |
|---|---|
/spm |
everything on one screen |
/spm tps |
tick rate windows and recent stalls |
/spm mspt |
tick time breakdown and distribution |
/spm world [name] |
per world load, top types, busiest chunks |
/spm entities [world] |
entity counts by type |
/spm chunks [world] |
loaded chunks, block entities, hotspots |
/spm mem |
heap, pools, garbage collection |
/spm cpu |
cpu, threads, jvm flags |
/spm plugins |
main thread share per plugin |
/spm timings <plugin> |
hot frames and event costs for one plugin |
/spm profiler <on|off|reset|status> |
control the sampler |
/spm profiler events <on|off> |
control event handler timing |
/spm alerts [mute|unmute|test] |
alert history and routing |
/spm report [print] |
write or print a full snapshot |
/spm reload |
reload config and restart the timers |
Four roles, each granted separately. spm.admin inherits the other three.
| Node | Grants |
|---|---|
spm.view |
every read-only subcommand |
spm.profile |
starting/stopping the profiler |
spm.alerts |
receiving lag alerts in chat |
spm.admin |
reload, mute, reports, and everything above |
There are two independent mechanisms, because they answer different questions.
Stack sampler. A low-priority daemon thread wakes every 25ms and takes a stack trace of the
server thread, but only while it is actually RUNNABLE so idle time is not counted. It walks the
trace from the top down and credits the first frame it can map to a plugin. The map is built from
each plugin's main class package, plus a three-segment root so sibling packages
(me.you.plugin.listeners) still resolve. It is statistical, costs almost nothing, and is on by
default. This is what answers "who is eating my tick".
Event timing. Opt-in, and more invasive. It walks every HandlerList, finds each
RegisteredListener, and swaps its EventExecutor for a wrapper that records call count, total
time and worst case per plugin/event pair. The original executors are kept and restored on
/spm profiler events off and on disable. Newly registered handlers are picked up by a periodic
rescan. This costs one System.nanoTime() pair per handler call, so leave it off unless you are
chasing something specific. This is what answers "which listener is slow".
If the server build does not expose an executor field the plugin can reach, event timing reports itself as unsupported and the sampler carries on alone.
Each rule produces a severity (WARN, CRITICAL) or nothing. A rule has to come back dirty on
thresholds.streak consecutive passes before it fires, which filters out single bad ticks like a
world save. Once firing, it stays quiet for alerts.cooldown-seconds unless the severity gets
worse, in which case it re-fires immediately. When it goes clean it emits a recovery notice.
Rules cover tick rate, tick time, heap, CPU, GC load, per-world entity counts and per-chunk entity pile-ups.
plugins/SPM/config.yml. Every value is clamped to a sane range on load, so a typo will not take
the server down.
intervals:
scan-ticks: 100 # world/entity scan, main thread
resource-ticks: 40 # memory + cpu poll, async
watch-ticks: 60 # rule evaluation, async
history-seconds: 300 # ring buffer depth
thresholds:
tps-warn: 18.5
tps-critical: 15.0
mspt-warn: 40.0
mspt-critical: 50.0
heap-warn: 80.0
heap-critical: 92.0
entities-per-chunk: 150
streak: 3 # passes before a rule fires
alerts:
cooldown-seconds: 180
max-per-minute: 6
discord:
enabled: false
url: ""
mention: "" # e.g. "<@&ROLE_ID>"
webhook:
enabled: false
url: ""
headers: {} # e.g. Authorization: "Bearer ..."The generic webhook posts a flat JSON document with a metrics object, so it drops straight into
Grafana, a Discord relay, or your own endpoint.
- The world scan runs on the main thread because
World#getEntities()has to. It is one pass with a reusedLocation, and it reports its own cost in/spm worldso you can see what it costs you. Raiseintervals.scan-tickson very large servers. - Block entity counts are only computed when you ask for them (
/spm chunks <world>), since that walk is expensive. It is also capped at 20ms, so on a very large world the count comes back marked+and partial rather than holding the tick. - MSPT comes from the server's own tick timings where available, and falls back to a measured tick interval otherwise.
- Sampler attribution is by package. A plugin that shades another library under its own package will absorb that library's time, which is usually what you want anyway.
MIT. See LICENSE.
Written by Am4er.