Astral Realms Documentation Help

Architecture

AstralAnalytics is fan-in: many event producers, one event sink. The shape is deliberately small — a single packet type, a single exchange, a single table.

Modules

Module

Build artefact

Role

common

analytics-common.jar

Shared packet + service implementation. Imported by paper, velocity, and master.

paper

AstralAnalytics-<version>.jar

Bukkit plugin. Emits events.

velocity

AstralAnalytics-<version>.jar

Velocity proxy plugin. Emits events.

master

MageHic plugin

Single subscriber. Persists to Postgres.

The packet

There is one packet in the entire network:

public class LogEventPacket implements Packet { private UUID playerId; private String type; private String payload; // raw JSON string private String server; private String group; }

Registered at opcode 0x00 by AnalyticsPacketRegistry. Opcodes are scoped to the registry, so AstralAnalytics doesn't collide with AstralCore's own packet space.

The exchange

public class AnalyticsExchanges { public static final String EVENTS = "analytics.events"; }

Every producer registers the same exchange and publishes LogEventPacket instances. The producer-side registration uses an empty packet listener — producers don't consume their own events. Only the master attaches a non-trivial handler.

Producer flow

caller ──► AnalyticsService.log(uuid, "type", payload) │ ▼ AnalyticsServiceImpl │ ├── String payload ──── (already a JSON-encoded string) ├── JsonElement ──► Gson.toJson() └── Map<String,?> ──► Gson.toJson() │ ▼ new LogEventPacket( playerId, type, payload, serverName, // injected at construction serverGroup // injected at construction ) │ ▼ messaging.send("analytics.events", packet)

The constructor of AnalyticsServiceImpl takes the server name and group, so the resulting events inherit the right tags without the caller having to think about it. On Paper they come from AstralPaperAPI.serverInformation(). On Velocity they're hard-coded to "proxy".

If the publish fails, the exception is logged at WARN and swallowed — analytics never block gameplay.

Master consumer flow

RabbitMQ ──► "analytics.events" │ ▼ EventService listener │ ▼ JsonParser.parse(packet.payload) │ ▼ if payload has "ip" field: GeoIP.getCountryCode(ip) ──► ip-api.com/json/<ip>?fields=countryCode │ ▼ payload.country = "FR" | "UNKNOWN" │ ▼ EventRepository.insert(type, playerId, server, group, payload) │ ▼ INSERT INTO events (type, player_uuid, server_name, server_group, payload) VALUES (?, ?, ?, ?, ?::jsonb)

GeoIP enrichment is best-effort — it has a 5-second connect timeout, a 5-second read timeout, and returns "UNKNOWN" on any error. The lookup is asynchronous; the eventual insert chains off the GeoIP future via thenCompose.

The repository uses a PGobject of type jsonb so the payload is stored natively (and indexable through the GIN index).

MageHic-local shortcut

When the producer is the master (or another MageHic plugin running in the same JVM), there is no point publishing to RabbitMQ just to read it back. The master exposes MHAnalyticsService implements AnalyticsService that calls EventService.logEvent directly:

MageHic plugin │ ▼ AnalyticsService.log(...) │ ▼ MHAnalyticsService │ ▼ EventService.logEvent("magehic", "magehic", ...) ← skips RabbitMQ │ ▼ EventRepository.insert

The server_name and server_group columns end up as the literal string "magehic" so MageHic-side events are easy to filter or exclude in Metabase queries.

What is not in this design

  • No retries. If RabbitMQ is down at publish time, the event is dropped (and logged).

  • No back-pressure. Producers fire-and-forget.

  • No client-side batching. Each event is its own packet.

  • No schema for payload — it is a JSONB blob keyed only by type. Conventions live in Events, not in code.

These trade-offs are explicit: telemetry that interferes with gameplay is worse than telemetry that occasionally drops a sample.

Last modified: 25 July 2026