Loading docs/troubleshooting.md +30 −0 Changes for docs/troubleshooting.md: 30 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1439,6 +1439,36 @@ kubectl -n felis logs deploy/felis-api | grep 'msg=request' | grep 'status=5' kubectl -n felis logs deploy/felis-api | grep 'request_id=<id from the error>' ``` The plugins' calls are the `face="internal"` series, one route per call: a failing join-event, wake or link-status poll shows up as its own route. ```promql sum by (route, code) (rate(felis_http_requests_total{face="internal", route="/api/v1/internal/servers/{name}/join-event"}[5m])) sum by (route, code) (rate(felis_http_requests_total{face="internal", route="/api/v1/internal/servers/{name}/wake"}[5m])) histogram_quantile(0.95, sum by (le, route) (rate(felis_http_request_duration_seconds_bucket{face="internal"}[5m]))) ``` A call that never reached felis-api (refused, reset, timed out) is missing from those series; the caller counts it. The proxy logs one line per active 10 minutes with felis-api as it saw it plus its own failures, and `/felis` at the proxy console prints the totals since start: ```bash journalctl -u felis-velocity | grep 'Felis: last 10 min' # Felis: last 10 min: felis-api calls=412 (no answer=0, 4xx=3, 5xx=0, retried=0), avg=18 ms, max=240 ms, # busy refusals=0, join-events failed=0, join-events dropped=0, transfers failed=0, # server-list refreshes failed=0, waiting now=0 journalctl -u felis-velocity | grep 'server list refresh' kubectl -n minecraft logs login-0 | grep 'link status poll' ``` `no answer` rising with a flat `felis_http_requests_total` means the path to the internal face is broken (NetworkPolicy, Service, the api Pod down); `busy refusals` or `join-events dropped` above zero means felis-api is slower than the proxy's pool of 8 threads can absorb. A refresh or link-status outage warns when it starts, every 5 minutes while it lasts with the failure count, and at info when it recovers. ### Scraping The series come from two processes. Both Services carry the Loading plugins/README.md +10 −0 Changes for plugins/README.md: 10 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -192,6 +192,16 @@ one is still going. Acting commands (`/link`, `/felis claim`, migrate, op approve) share a per-player budget of 5 then one per 5 s; felis:control frames from the lobby are metered per player and menu status answers are cached. Health on the proxy: every 10 minutes that saw any activity the proxy logs one info line, `Felis: last 10 min: felis-api calls=… (no answer=…, 4xx=…, 5xx=…, retried=…), avg=… ms, max=… ms, busy refusals=…, join-events failed=…, join-events dropped=…, transfers failed=…, server-list refreshes failed=…, waiting now=…`, with only that window's counts. `/felis` run from the console adds the same felis-api counts since start, the waiting count and the failure totals. A server-list refresh that keeps failing warns once when it starts, then every 5 minutes with the running count, and logs at info when it recovers; the login gate treats its link-status polls the same way. ## Lobby menu (§12) The `paper/` module is the lobby's player-facing face for §27 scenario 10 Loading plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java +30 −5 Changes for plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java: 30 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfigLoader; import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.OutageTracker; import com.loohp.limbo.events.EventHandler; import com.loohp.limbo.events.Listener; Loading Loading @@ -121,6 +122,8 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { static final long START_RETRY_WINDOW_MILLIS = 60_000L; // How long the release is re-sent after sign-in before giving up with a message. private static final long RELEASE_WINDOW_MILLIS = 120_000L; // How often a link-status outage is summarized while it lasts. private static final long POLL_OUTAGE_REPEAT_MILLIS = 5 * 60_000L; // ---- readiness state ---- private final AtomicBoolean ready = new AtomicBoolean(false); Loading @@ -141,6 +144,11 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { // Players whose release loop is running; the async link poll can observe "linked" // twice before its cancellation lands, and the release must start once. private final Set<UUID> releasing = ConcurrentHashMap.newKeySet(); // One tracker for every player's link poll: the polls share felis-api, so an outage // is one event, reported when it starts, every few minutes while it lasts, and when // it ends, rather than once a second per waiting player (or never, at FINE). private final OutageTracker pollOutage = new OutageTracker(POLL_OUTAGE_REPEAT_MILLIS, System::currentTimeMillis); @Override public void onEnable() { Loading Loading @@ -324,7 +332,8 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { } catch (RuntimeException e) { // A client that refuses the book (rare) still gets the chat instructions // below, so a book failure is not fatal to the flow. LOG.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage()); LOG.warning("FelisLimbo: openBook failed for " + id + " — " + e.getMessage() + "; the code is still sent in chat"); } player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code()); player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + url Loading @@ -348,13 +357,29 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { disconnectOnMain(id, "登录超时,请重连 / Login timed out. Please reconnect."); return; } boolean linked; try { if (apiClient.linkStatus(id)) { getServer().getScheduler().runTask(this, () -> startRelease(id)); } linked = apiClient.linkStatus(id); } catch (LinkException e) { // A transient poll failure is not fatal — keep trying until the deadline. LOG.fine("FelisLimbo: link status poll failed for " + id + " — " + e.getMessage()); OutageTracker.Report report = pollOutage.failure(); if (report == OutageTracker.Report.DOWN) { LOG.warning("FelisLimbo: link status poll failed for " + id + " — " + e.getMessage() + "; players keep waiting, and repeats are summarized every " + POLL_OUTAGE_REPEAT_MILLIS / 60_000L + " min until it recovers"); } else if (report == OutageTracker.Report.STILL_DOWN) { LOG.warning("FelisLimbo: link status polls still failing: " + pollOutage.failures() + " failed polls over " + pollOutage.downForMillis() / 1000 + " s, last — " + e.getMessage()); } return; } if (pollOutage.success() == OutageTracker.Report.RECOVERED) { LOG.info("FelisLimbo: link status polls recovered after " + pollOutage.lastOutageFailures() + " failed polls over " + pollOutage.lastOutageMillis() / 1000 + " s"); } if (linked) { getServer().getScheduler().runTask(this, () -> startRelease(id)); } } Loading plugins/shared/src/main/java/best/lolicon/felis/link/ApiStats.java 0 → 100644 +103 −0 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/ApiStats.java: 103 added lines, 0 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.util.concurrent.atomic.AtomicLong; /** * ApiStats counts what a {@link FelisApiClient} sent and how it came back, so the * plugin can report felis-api's health as its callers see it: calls, the ones that * never got an answer (refused, reset, timed out), 4xx and 5xx answers, retries, and * latency. Every attempt is one call, a retried GET counting twice. Counters only * grow; {@link #window()} hands out what changed since the previous window, which is * what a periodic log line wants. */ public final class ApiStats { private final AtomicLong calls = new AtomicLong(); private final AtomicLong transport = new AtomicLong(); private final AtomicLong clientErrors = new AtomicLong(); private final AtomicLong serverErrors = new AtomicLong(); private final AtomicLong retries = new AtomicLong(); private final AtomicLong totalMillis = new AtomicLong(); private final AtomicLong windowMaxMillis = new AtomicLong(); private final AtomicLong maxMillis = new AtomicLong(); private Snapshot lastWindow = new Snapshot(0, 0, 0, 0, 0, 0, 0); /** record notes one attempt: its HTTP status (0 when no answer came) and how long it took. */ void record(int status, long millis) { calls.incrementAndGet(); if (status == 0) { transport.incrementAndGet(); } else if (status >= 500) { serverErrors.incrementAndGet(); } else if (status >= 400) { clientErrors.incrementAndGet(); } totalMillis.addAndGet(millis); windowMaxMillis.accumulateAndGet(millis, Math::max); maxMillis.accumulateAndGet(millis, Math::max); } void retried() { retries.incrementAndGet(); } /** total is every count since the client was made, with the slowest call ever. */ public Snapshot total() { return new Snapshot(calls.get(), transport.get(), clientErrors.get(), serverErrors.get(), retries.get(), totalMillis.get(), maxMillis.get()); } /** * window returns the counts since the previous call (the first call: since the * client was made), with the slowest call in that span, and starts a new window, so * consecutive windows never overlap. */ public synchronized Snapshot window() { long max = windowMaxMillis.getAndSet(0); Snapshot now = new Snapshot(calls.get(), transport.get(), clientErrors.get(), serverErrors.get(), retries.get(), totalMillis.get(), max); Snapshot delta = new Snapshot( now.calls - lastWindow.calls, now.transport - lastWindow.transport, now.clientErrors - lastWindow.clientErrors, now.serverErrors - lastWindow.serverErrors, now.retries - lastWindow.retries, now.totalMillis - lastWindow.totalMillis, max); lastWindow = now; return delta; } /** Snapshot is one immutable reading of the counters. */ public static final class Snapshot { public final long calls; public final long transport; public final long clientErrors; public final long serverErrors; public final long retries; public final long totalMillis; public final long maxMillis; Snapshot(long calls, long transport, long clientErrors, long serverErrors, long retries, long totalMillis, long maxMillis) { this.calls = calls; this.transport = transport; this.clientErrors = clientErrors; this.serverErrors = serverErrors; this.retries = retries; this.totalMillis = totalMillis; this.maxMillis = maxMillis; } /** avgMillis is the mean call time, 0 with no calls. */ public long avgMillis() { return calls == 0 ? 0 : totalMillis / calls; } /** summary is the one-line form the plugins log and print. */ public String summary() { return "calls=" + calls + " (no answer=" + transport + ", 4xx=" + clientErrors + ", 5xx=" + serverErrors + ", retried=" + retries + "), avg=" + avgMillis() + " ms, max=" + maxMillis + " ms"; } } } plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +17 −1 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 17 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -54,6 +54,7 @@ public final class FelisApiClient { private final LinkConfig config; private final HttpClient http; private final ApiStats stats = new ApiStats(); public FelisApiClient(LinkConfig config) { this.config = Objects.requireNonNull(config, "config"); Loading @@ -62,6 +63,11 @@ public final class FelisApiClient { .build(); } /** stats counts this client's calls and their outcomes, for the plugin's health line. */ public ApiStats stats() { return stats; } /** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */ public List<ServerView> listServers() throws LinkException { return ServerView.listFrom(getObject("/api/v1/servers", 200)); Loading Loading @@ -297,6 +303,7 @@ public final class FelisApiClient { } catch (InterruptedException e) { throw interrupted(e); } stats.retried(); return expectObject(send(req), expect); } Loading Loading @@ -338,8 +345,17 @@ public final class FelisApiClient { } } // exchange is the one place a request goes out, so every attempt is counted once. private HttpResponse<String> exchange(HttpRequest req) throws IOException, InterruptedException { return http.send(req, HttpResponse.BodyHandlers.ofString()); long start = System.nanoTime(); int status = 0; try { HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString()); status = res.statusCode(); return res; } finally { stats.record(status, (System.nanoTime() - start) / 1_000_000); } } // A refused connection arrives as a ConnectException with no message; the class Loading Loading
docs/troubleshooting.md +30 −0 Changes for docs/troubleshooting.md: 30 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1439,6 +1439,36 @@ kubectl -n felis logs deploy/felis-api | grep 'msg=request' | grep 'status=5' kubectl -n felis logs deploy/felis-api | grep 'request_id=<id from the error>' ``` The plugins' calls are the `face="internal"` series, one route per call: a failing join-event, wake or link-status poll shows up as its own route. ```promql sum by (route, code) (rate(felis_http_requests_total{face="internal", route="/api/v1/internal/servers/{name}/join-event"}[5m])) sum by (route, code) (rate(felis_http_requests_total{face="internal", route="/api/v1/internal/servers/{name}/wake"}[5m])) histogram_quantile(0.95, sum by (le, route) (rate(felis_http_request_duration_seconds_bucket{face="internal"}[5m]))) ``` A call that never reached felis-api (refused, reset, timed out) is missing from those series; the caller counts it. The proxy logs one line per active 10 minutes with felis-api as it saw it plus its own failures, and `/felis` at the proxy console prints the totals since start: ```bash journalctl -u felis-velocity | grep 'Felis: last 10 min' # Felis: last 10 min: felis-api calls=412 (no answer=0, 4xx=3, 5xx=0, retried=0), avg=18 ms, max=240 ms, # busy refusals=0, join-events failed=0, join-events dropped=0, transfers failed=0, # server-list refreshes failed=0, waiting now=0 journalctl -u felis-velocity | grep 'server list refresh' kubectl -n minecraft logs login-0 | grep 'link status poll' ``` `no answer` rising with a flat `felis_http_requests_total` means the path to the internal face is broken (NetworkPolicy, Service, the api Pod down); `busy refusals` or `join-events dropped` above zero means felis-api is slower than the proxy's pool of 8 threads can absorb. A refresh or link-status outage warns when it starts, every 5 minutes while it lasts with the failure count, and at info when it recovers. ### Scraping The series come from two processes. Both Services carry the Loading
plugins/README.md +10 −0 Changes for plugins/README.md: 10 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -192,6 +192,16 @@ one is still going. Acting commands (`/link`, `/felis claim`, migrate, op approve) share a per-player budget of 5 then one per 5 s; felis:control frames from the lobby are metered per player and menu status answers are cached. Health on the proxy: every 10 minutes that saw any activity the proxy logs one info line, `Felis: last 10 min: felis-api calls=… (no answer=…, 4xx=…, 5xx=…, retried=…), avg=… ms, max=… ms, busy refusals=…, join-events failed=…, join-events dropped=…, transfers failed=…, server-list refreshes failed=…, waiting now=…`, with only that window's counts. `/felis` run from the console adds the same felis-api counts since start, the waiting count and the failure totals. A server-list refresh that keeps failing warns once when it starts, then every 5 minutes with the running count, and logs at info when it recovers; the login gate treats its link-status polls the same way. ## Lobby menu (§12) The `paper/` module is the lobby's player-facing face for §27 scenario 10 Loading
plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java +30 −5 Changes for plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java: 30 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfigLoader; import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.OutageTracker; import com.loohp.limbo.events.EventHandler; import com.loohp.limbo.events.Listener; Loading Loading @@ -121,6 +122,8 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { static final long START_RETRY_WINDOW_MILLIS = 60_000L; // How long the release is re-sent after sign-in before giving up with a message. private static final long RELEASE_WINDOW_MILLIS = 120_000L; // How often a link-status outage is summarized while it lasts. private static final long POLL_OUTAGE_REPEAT_MILLIS = 5 * 60_000L; // ---- readiness state ---- private final AtomicBoolean ready = new AtomicBoolean(false); Loading @@ -141,6 +144,11 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { // Players whose release loop is running; the async link poll can observe "linked" // twice before its cancellation lands, and the release must start once. private final Set<UUID> releasing = ConcurrentHashMap.newKeySet(); // One tracker for every player's link poll: the polls share felis-api, so an outage // is one event, reported when it starts, every few minutes while it lasts, and when // it ends, rather than once a second per waiting player (or never, at FINE). private final OutageTracker pollOutage = new OutageTracker(POLL_OUTAGE_REPEAT_MILLIS, System::currentTimeMillis); @Override public void onEnable() { Loading Loading @@ -324,7 +332,8 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { } catch (RuntimeException e) { // A client that refuses the book (rare) still gets the chat instructions // below, so a book failure is not fatal to the flow. LOG.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage()); LOG.warning("FelisLimbo: openBook failed for " + id + " — " + e.getMessage() + "; the code is still sent in chat"); } player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code()); player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + url Loading @@ -348,13 +357,29 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { disconnectOnMain(id, "登录超时,请重连 / Login timed out. Please reconnect."); return; } boolean linked; try { if (apiClient.linkStatus(id)) { getServer().getScheduler().runTask(this, () -> startRelease(id)); } linked = apiClient.linkStatus(id); } catch (LinkException e) { // A transient poll failure is not fatal — keep trying until the deadline. LOG.fine("FelisLimbo: link status poll failed for " + id + " — " + e.getMessage()); OutageTracker.Report report = pollOutage.failure(); if (report == OutageTracker.Report.DOWN) { LOG.warning("FelisLimbo: link status poll failed for " + id + " — " + e.getMessage() + "; players keep waiting, and repeats are summarized every " + POLL_OUTAGE_REPEAT_MILLIS / 60_000L + " min until it recovers"); } else if (report == OutageTracker.Report.STILL_DOWN) { LOG.warning("FelisLimbo: link status polls still failing: " + pollOutage.failures() + " failed polls over " + pollOutage.downForMillis() / 1000 + " s, last — " + e.getMessage()); } return; } if (pollOutage.success() == OutageTracker.Report.RECOVERED) { LOG.info("FelisLimbo: link status polls recovered after " + pollOutage.lastOutageFailures() + " failed polls over " + pollOutage.lastOutageMillis() / 1000 + " s"); } if (linked) { getServer().getScheduler().runTask(this, () -> startRelease(id)); } } Loading
plugins/shared/src/main/java/best/lolicon/felis/link/ApiStats.java 0 → 100644 +103 −0 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/ApiStats.java: 103 added lines, 0 removed lines. Original line number Diff line number Diff line package best.lolicon.felis.link; import java.util.concurrent.atomic.AtomicLong; /** * ApiStats counts what a {@link FelisApiClient} sent and how it came back, so the * plugin can report felis-api's health as its callers see it: calls, the ones that * never got an answer (refused, reset, timed out), 4xx and 5xx answers, retries, and * latency. Every attempt is one call, a retried GET counting twice. Counters only * grow; {@link #window()} hands out what changed since the previous window, which is * what a periodic log line wants. */ public final class ApiStats { private final AtomicLong calls = new AtomicLong(); private final AtomicLong transport = new AtomicLong(); private final AtomicLong clientErrors = new AtomicLong(); private final AtomicLong serverErrors = new AtomicLong(); private final AtomicLong retries = new AtomicLong(); private final AtomicLong totalMillis = new AtomicLong(); private final AtomicLong windowMaxMillis = new AtomicLong(); private final AtomicLong maxMillis = new AtomicLong(); private Snapshot lastWindow = new Snapshot(0, 0, 0, 0, 0, 0, 0); /** record notes one attempt: its HTTP status (0 when no answer came) and how long it took. */ void record(int status, long millis) { calls.incrementAndGet(); if (status == 0) { transport.incrementAndGet(); } else if (status >= 500) { serverErrors.incrementAndGet(); } else if (status >= 400) { clientErrors.incrementAndGet(); } totalMillis.addAndGet(millis); windowMaxMillis.accumulateAndGet(millis, Math::max); maxMillis.accumulateAndGet(millis, Math::max); } void retried() { retries.incrementAndGet(); } /** total is every count since the client was made, with the slowest call ever. */ public Snapshot total() { return new Snapshot(calls.get(), transport.get(), clientErrors.get(), serverErrors.get(), retries.get(), totalMillis.get(), maxMillis.get()); } /** * window returns the counts since the previous call (the first call: since the * client was made), with the slowest call in that span, and starts a new window, so * consecutive windows never overlap. */ public synchronized Snapshot window() { long max = windowMaxMillis.getAndSet(0); Snapshot now = new Snapshot(calls.get(), transport.get(), clientErrors.get(), serverErrors.get(), retries.get(), totalMillis.get(), max); Snapshot delta = new Snapshot( now.calls - lastWindow.calls, now.transport - lastWindow.transport, now.clientErrors - lastWindow.clientErrors, now.serverErrors - lastWindow.serverErrors, now.retries - lastWindow.retries, now.totalMillis - lastWindow.totalMillis, max); lastWindow = now; return delta; } /** Snapshot is one immutable reading of the counters. */ public static final class Snapshot { public final long calls; public final long transport; public final long clientErrors; public final long serverErrors; public final long retries; public final long totalMillis; public final long maxMillis; Snapshot(long calls, long transport, long clientErrors, long serverErrors, long retries, long totalMillis, long maxMillis) { this.calls = calls; this.transport = transport; this.clientErrors = clientErrors; this.serverErrors = serverErrors; this.retries = retries; this.totalMillis = totalMillis; this.maxMillis = maxMillis; } /** avgMillis is the mean call time, 0 with no calls. */ public long avgMillis() { return calls == 0 ? 0 : totalMillis / calls; } /** summary is the one-line form the plugins log and print. */ public String summary() { return "calls=" + calls + " (no answer=" + transport + ", 4xx=" + clientErrors + ", 5xx=" + serverErrors + ", retried=" + retries + "), avg=" + avgMillis() + " ms, max=" + maxMillis + " ms"; } } }
plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +17 −1 Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 17 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -54,6 +54,7 @@ public final class FelisApiClient { private final LinkConfig config; private final HttpClient http; private final ApiStats stats = new ApiStats(); public FelisApiClient(LinkConfig config) { this.config = Objects.requireNonNull(config, "config"); Loading @@ -62,6 +63,11 @@ public final class FelisApiClient { .build(); } /** stats counts this client's calls and their outcomes, for the plugin's health line. */ public ApiStats stats() { return stats; } /** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */ public List<ServerView> listServers() throws LinkException { return ServerView.listFrom(getObject("/api/v1/servers", 200)); Loading Loading @@ -297,6 +303,7 @@ public final class FelisApiClient { } catch (InterruptedException e) { throw interrupted(e); } stats.retried(); return expectObject(send(req), expect); } Loading Loading @@ -338,8 +345,17 @@ public final class FelisApiClient { } } // exchange is the one place a request goes out, so every attempt is counted once. private HttpResponse<String> exchange(HttpRequest req) throws IOException, InterruptedException { return http.send(req, HttpResponse.BodyHandlers.ofString()); long start = System.nanoTime(); int status = 0; try { HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString()); status = res.statusCode(); return res; } finally { stats.record(status, (System.nanoTime() - start) / 1_000_000); } } // A refused connection arrives as a ConnectException with no message; the class Loading