Unverified Commit b65c2c00 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(plugins): 插件侧计数器与周期健康日志,Limbo/刷新失败按故障周期升级日志

parent fcf5c305
Loading
Loading
Loading
Loading
+30 −0
Changes for docs/troubleshooting.md: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+10 −0
Changes for plugins/README.md: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+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
@@ -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;
@@ -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);
@@ -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() {
@@ -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
@@ -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));
        }
    }

+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";
        }
    }
}
+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
@@ -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");
@@ -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));
@@ -297,6 +303,7 @@ public final class FelisApiClient {
        } catch (InterruptedException e) {
            throw interrupted(e);
        }
        stats.retried();
        return expectObject(send(req), expect);
    }

@@ -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