From 5f84fd55105bb89d374c797822607b245ee3bec4 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:50:07 +0200 Subject: [PATCH 1/4] feat(microbot): report script errors to microbot.cloud A root logback appender groups ERROR events by exception type and top stack frames, attributes them to the owning Hub plugin and version, sanitises messages and posts a batch to /plugintelemetry/errors every 5 minutes. Honours --disable-telemetry, -Dmicrobot.disableTelemetry and the Disable telemetry toggle. -Dmicrobot.apiUrl overrides the API base URL for local testing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PKz6YjDRJdva762xPzTaia --- docs/ARCHITECTURE.md | 1 + .../client/plugins/microbot/MicrobotApi.java | 28 ++- .../plugins/microbot/MicrobotConfig.java | 2 +- .../plugins/microbot/MicrobotPlugin.java | 10 + .../diagnostics/ScriptErrorReporter.java | 237 ++++++++++++++++++ .../diagnostics/ScriptErrorReporterTest.java | 122 +++++++++ 6 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68aa526c753..373fa649ff3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,6 +14,7 @@ - Script loop (`Script.run()` implementations) executes on scheduled executors → queries caches via Queryable API → performs interactions through utilities (`Rs2Inventory`, `Rs2Walker`, etc.) → waits with `sleepUntil` helpers. - Blocking events (`BlockingEventManager`) continuously validate (e.g., welcome screen, bank popups) → if triggered, they run on a dedicated executor and block script progression until resolved. - Telemetry flow: session/version/fact/plugin telemetry is skipped when telemetry is disabled; failures are logged at debug level. +- Error telemetry: `diagnostics/ScriptErrorReporter` is a root logback appender that groups ERROR events by exception type and top stack frames, attributes them to the owning Hub plugin and version, sanitises messages with `DiagnosticReport.clean`, and posts a batch to `/plugintelemetry/errors` every 5 minutes. It is skipped by `--disable-telemetry`, `-Dmicrobot.disableTelemetry=true` or the config toggle. `-Dmicrobot.apiUrl` points all microbot.cloud calls at another base URL for local testing. ## Runtime Boundaries - **Threads**: Client thread (never block/sleep); script/executor threads (automation logic, sleeps allowed); blocking-event executor (resolves UI blockers). Use `ClientThread.runOnClientThreadOptional` for safe client access. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java index 041dba8c8b2..65dfb5d17ce 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java @@ -7,6 +7,8 @@ import lombok.extern.slf4j.Slf4j; import net.runelite.client.RuneLiteProperties; import net.runelite.http.api.RuneLiteAPI; +import okhttp3.Call; +import okhttp3.Callback; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; @@ -30,7 +32,7 @@ public class MicrobotApi { private final Gson gson; private final String pluginTelemetryToken; - private final String microbotApiUrl = "https://microbot.cloud/api"; + private final String microbotApiUrl = System.getProperty("microbot.apiUrl", "https://microbot.cloud/api"); @Inject MicrobotApi(OkHttpClient client, Gson gson) { this.client = client; @@ -105,6 +107,30 @@ public void increasePluginInstall(String internalName, String displayName, Strin } } + public void submitErrors(JsonObject payload) + { + Request request = new Request.Builder() + .url(microbotApiUrl + "/plugintelemetry/errors") + .header("X-Plugin-Telemetry-Token", pluginTelemetryToken) + .post(RequestBody.create(RuneLiteAPI.JSON, gson.toJson(payload))) + .build(); + + client.newCall(request).enqueue(new Callback() + { + @Override + public void onFailure(Call call, IOException e) + { + log.debug("Error telemetry upload failed", e); + } + + @Override + public void onResponse(Call call, Response response) + { + response.close(); + } + }); + } + /** * Sends a ping request to the microbot API to update the session status. * diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java index ec8490eed96..dbf20321206 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java @@ -222,7 +222,7 @@ default boolean showCacheInfo() { @ConfigItem( keyName = keyDisableTelemetry, name = "Disable telemetry", - description = "Stop outbound calls to microbot.cloud (update check, random-fact splash, session ping). " + + description = "Stop outbound calls to microbot.cloud (update check, random-fact splash, session ping, script error reports). " + "Equivalent to launching with -Dmicrobot.disableTelemetry=true.", position = 6, section = generalSection diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 25962198f71..86ef3594fa3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -16,6 +16,7 @@ import net.runelite.client.events.RuneScapeProfileChanged; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.diagnostics.ScriptErrorReporter; import net.runelite.client.plugins.microbot.pouch.PouchOverlay; import net.runelite.client.plugins.microbot.ui.MicrobotPluginConfigurationDescriptor; import net.runelite.client.plugins.microbot.ui.MicrobotPluginListPanel; @@ -125,6 +126,9 @@ MicrobotConfig provideConfig(ConfigManager configManager) @Inject private MicrobotVersionChecker microbotVersionChecker; + + @Inject + private ScriptErrorReporter scriptErrorReporter; // Widget change tracking for overlay cache invalidation private volatile boolean widgetLayoutChanged = false; @@ -153,6 +157,10 @@ protected void startUp() throws AWTException gameChatAppender.setContext(context); context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(gameChatAppender); + scriptErrorReporter.setContext(context); + scriptErrorReporter.start(); + context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(scriptErrorReporter); + // Start appender if logging is enabled if (microbotConfig.enableGameChatLogging()) { gameChatAppender.start(); @@ -218,6 +226,8 @@ protected void shutDown() overlayManager.remove(pouchOverlay); clientToolbar.removeNavigation(navButton); if (gameChatAppender.isStarted()) gameChatAppender.stop(); + ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).detachAppender(scriptErrorReporter); + scriptErrorReporter.stop(); microbotVersionChecker.shutdown(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java new file mode 100644 index 00000000000..6a22903f633 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -0,0 +1,237 @@ +package net.runelite.client.plugins.microbot.diagnostics; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ch.qos.logback.core.UnsynchronizedAppenderBase; +import com.google.gson.JsonElement; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import net.runelite.api.Player; +import net.runelite.client.RuneLiteProperties; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.MicrobotApi; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; + +@Singleton +public class ScriptErrorReporter extends UnsynchronizedAppenderBase +{ + static final int MAX_FINGERPRINTS = 100; + private static final int MAX_FRAMES = 20; + private static final int FINGERPRINT_FRAMES = 3; + private static final long FLUSH_MINUTES = 5; + + private final MicrobotApi microbotApi; + private final MicrobotPluginManager microbotPluginManager; + private final ScheduledExecutorService executor; + private final boolean disableTelemetry; + private final String sessionId = UUID.randomUUID().toString(); + private final Map pending = new LinkedHashMap<>(); + private ScheduledFuture flushTask; + + @Inject + ScriptErrorReporter(MicrobotApi microbotApi, MicrobotPluginManager microbotPluginManager, ScheduledExecutorService executor, + @Named("disableTelemetry") boolean disableTelemetry) + { + this.microbotApi = microbotApi; + this.microbotPluginManager = microbotPluginManager; + this.executor = executor; + this.disableTelemetry = disableTelemetry; + setName("SCRIPT_ERROR_REPORTER"); + } + + @Override + public void start() + { + super.start(); + flushTask = executor.scheduleWithFixedDelay(this::flush, FLUSH_MINUTES, FLUSH_MINUTES, TimeUnit.MINUTES); + } + + @Override + public void stop() + { + if (flushTask != null) + { + flushTask.cancel(false); + } + super.stop(); + flush(); + } + + @Override + protected void append(ILoggingEvent event) + { + if (!event.getLevel().isGreaterOrEqual(Level.ERROR) + || event.getLoggerName().startsWith(ScriptErrorReporter.class.getPackageName()) + || event.getLoggerName().equals(MicrobotApi.class.getName()) + || isDisabled()) + { + return; + } + + IThrowableProxy root = rootCause(event.getThrowableProxy()); + List frames = frames(root); + String fingerprint = root == null + ? event.getLoggerName() + "|" + event.getMessage() + : root.getClassName() + "|" + String.join("|", frames.subList(0, Math.min(FINGERPRINT_FRAMES, frames.size()))); + + synchronized (pending) + { + JsonObject error = pending.get(fingerprint); + if (error != null) + { + error.addProperty("count", error.get("count").getAsInt() + 1); + return; + } + if (pending.size() >= MAX_FINGERPRINTS) + { + return; + } + pending.put(fingerprint, describe(event, root, frames, fingerprint)); + } + } + + public void flush() + { + JsonArray errors = new JsonArray(); + synchronized (pending) + { + pending.values().forEach(errors::add); + pending.clear(); + } + if (errors.size() == 0 || isDisabled()) + { + return; + } + errors.forEach(error -> attribute(error.getAsJsonObject())); + + JsonObject payload = new JsonObject(); + payload.addProperty("sessionId", sessionId); + payload.addProperty("microbotVersion", RuneLiteProperties.getMicrobotVersion()); + payload.addProperty("microbotCommit", RuneLiteProperties.getMicrobotCommit()); + payload.addProperty("buildChannel", RuneLiteProperties.getMicrobotBuildChannel()); + payload.addProperty("javaVersion", System.getProperty("java.version")); + payload.addProperty("osName", System.getProperty("os.name")); + payload.addProperty("osArch", System.getProperty("os.arch")); + payload.add("errors", errors); + microbotApi.submitErrors(payload); + } + + int pendingCount() + { + synchronized (pending) + { + return pending.size(); + } + } + + private static JsonObject describe(ILoggingEvent event, IThrowableProxy root, List frames, String fingerprint) + { + JsonObject error = new JsonObject(); + error.addProperty("fingerprint", fingerprint); + error.addProperty("count", 1); + error.addProperty("firstSeen", event.getTimeStamp()); + error.addProperty("logger", event.getLoggerName()); + error.addProperty("thread", scrub(event.getThreadName())); + error.addProperty("message", scrub(event.getMessage())); + if (root != null) + { + error.addProperty("exception", root.getClassName()); + error.addProperty("exceptionMessage", scrub(root.getMessage())); + } + JsonArray stack = new JsonArray(); + frames.forEach(stack::add); + error.add("stack", stack); + return error; + } + + private boolean isDisabled() + { + return disableTelemetry || Microbot.isTelemetryDisabled(); + } + + private void attribute(JsonObject error) + { + List plugins = microbotPluginManager.getInstalledPlugins(); + for (JsonElement frame : error.getAsJsonArray("stack")) + { + for (Plugin plugin : plugins) + { + String pkg = plugin.getClass().getPackageName(); + if (!pkg.equals(Microbot.class.getPackageName()) && frame.getAsString().startsWith(pkg + ".")) + { + String internalName = plugin.getClass().getSimpleName(); + error.addProperty("plugin", internalName); + error.addProperty("pluginVersion", microbotPluginManager.getInstalledPluginVersion(internalName) + .orElse(plugin.getClass().getAnnotation(PluginDescriptor.class).version())); + return; + } + } + } + } + + private static IThrowableProxy rootCause(IThrowableProxy ex) + { + while (ex != null && ex.getCause() != null && ex.getCause() != ex) + { + ex = ex.getCause(); + } + return ex; + } + + private static List frames(IThrowableProxy ex) + { + List frames = new ArrayList<>(); + if (ex == null) + { + return frames; + } + for (StackTraceElementProxy proxy : ex.getStackTraceElementProxyArray()) + { + if (frames.size() >= MAX_FRAMES) + { + break; + } + StackTraceElement element = proxy.getStackTraceElement(); + frames.add(element.getClassName() + "." + element.getMethodName() + ":" + element.getLineNumber()); + } + return frames; + } + + private static String scrub(String raw) + { + String name = localPlayerName(); + if (raw != null && name != null && !name.isEmpty()) + { + raw = raw.replace(name, "[player]").replace(name.replace(' ', ' '), "[player]"); + } + return DiagnosticReport.clean(raw); + } + + private static String localPlayerName() + { + try + { + Player player = Microbot.getClient().getLocalPlayer(); + return player == null ? null : player.getName(); + } + catch (RuntimeException e) + { + return null; + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java new file mode 100644 index 00000000000..a43b35e10e1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -0,0 +1,122 @@ +package net.runelite.client.plugins.microbot.diagnostics; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.LoggingEvent; +import com.google.gson.JsonObject; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.MicrobotApi; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import static org.junit.Assert.assertEquals; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ScriptErrorReporterTest +{ + @PluginDescriptor(name = "Fake") + static class FakePlugin extends Plugin + { + } + + private final LoggerContext context = new LoggerContext(); + private MicrobotApi api; + private ScriptErrorReporter reporter; + + @Before + public void setUp() + { + api = mock(MicrobotApi.class); + MicrobotPluginManager pluginManager = mock(MicrobotPluginManager.class); + when(pluginManager.getInstalledPlugins()).thenReturn(List.of(new FakePlugin())); + when(pluginManager.getInstalledPluginVersion("FakePlugin")).thenReturn(Optional.of("1.2.3")); + reporter = reporter(pluginManager, false); + } + + private ScriptErrorReporter reporter(MicrobotPluginManager pluginManager, boolean disableTelemetry) + { + ScriptErrorReporter reporter = new ScriptErrorReporter(api, pluginManager, mock(ScheduledExecutorService.class), disableTelemetry); + reporter.setContext(context); + reporter.start(); + return reporter; + } + + @After + public void tearDown() + { + System.clearProperty("microbot.disableTelemetry"); + } + + private void log(Level level, String message, Throwable ex) + { + reporter.doAppend(new LoggingEvent("x", context.getLogger("some.Script"), level, message, ex, null)); + } + + private static RuntimeException boom() + { + return new IllegalStateException("bank closed at /home/alice/.runelite"); + } + + @Test + public void groupsRepeatedErrorsAndAttributesPlugin() + { + for (int i = 0; i < 3; i++) + { + log(Level.ERROR, "loop failed", boom()); + } + log(Level.ERROR, "loop failed", new NullPointerException()); + log(Level.WARN, "just a warning", boom()); + assertEquals(2, reporter.pendingCount()); + + reporter.flush(); + ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); + verify(api).submitErrors(captor.capture()); + JsonObject first = captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject(); + assertEquals(3, first.get("count").getAsInt()); + assertEquals("java.lang.IllegalStateException", first.get("exception").getAsString()); + assertEquals("bank closed at [path]", first.get("exceptionMessage").getAsString()); + assertEquals("FakePlugin", first.get("plugin").getAsString()); + assertEquals("1.2.3", first.get("pluginVersion").getAsString()); + assertEquals(0, reporter.pendingCount()); + } + + @Test + public void capsDistinctErrors() + { + for (int i = 0; i < ScriptErrorReporter.MAX_FINGERPRINTS + 10; i++) + { + log(Level.ERROR, "failed " + i, null); + } + assertEquals(ScriptErrorReporter.MAX_FINGERPRINTS, reporter.pendingCount()); + } + + @Test + public void sendsNothingWhenTelemetryDisabled() + { + System.setProperty("microbot.disableTelemetry", "true"); + log(Level.ERROR, "loop failed", boom()); + reporter.flush(); + assertEquals(0, reporter.pendingCount()); + verify(api, never()).submitErrors(any()); + } + + @Test + public void sendsNothingWithDisableTelemetryFlag() + { + reporter = reporter(mock(MicrobotPluginManager.class), true); + log(Level.ERROR, "loop failed", boom()); + reporter.flush(); + assertEquals(0, reporter.pendingCount()); + verify(api, never()).submitErrors(any()); + } +} From a145ed5c79f0e7396c217440578caf3009b8e492 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:58:34 +0200 Subject: [PATCH 2/4] fix(microbot): sanitise error fingerprints and harden flush Review of #1900: the no-exception fingerprint carried the raw log message, so paths and emails reached the payload unsanitised. Scrub it like the message field. Build exception fingerprints from the first non-JDK frames so unrelated errors thrown inside the JDK do not merge, and catch runtime failures in flush so one bad batch cannot cancel the scheduled task for the rest of the session. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PKz6YjDRJdva762xPzTaia --- docs/ARCHITECTURE.md | 2 +- .../diagnostics/ScriptErrorReporter.java | 24 ++++++++-- .../diagnostics/ScriptErrorReporterTest.java | 48 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 373fa649ff3..90a58f51440 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ - Script loop (`Script.run()` implementations) executes on scheduled executors → queries caches via Queryable API → performs interactions through utilities (`Rs2Inventory`, `Rs2Walker`, etc.) → waits with `sleepUntil` helpers. - Blocking events (`BlockingEventManager`) continuously validate (e.g., welcome screen, bank popups) → if triggered, they run on a dedicated executor and block script progression until resolved. - Telemetry flow: session/version/fact/plugin telemetry is skipped when telemetry is disabled; failures are logged at debug level. -- Error telemetry: `diagnostics/ScriptErrorReporter` is a root logback appender that groups ERROR events by exception type and top stack frames, attributes them to the owning Hub plugin and version, sanitises messages with `DiagnosticReport.clean`, and posts a batch to `/plugintelemetry/errors` every 5 minutes. It is skipped by `--disable-telemetry`, `-Dmicrobot.disableTelemetry=true` or the config toggle. `-Dmicrobot.apiUrl` points all microbot.cloud calls at another base URL for local testing. +- Error telemetry: `diagnostics/ScriptErrorReporter` is a root logback appender that groups ERROR events by exception type and top stack frames, attributes them to the owning Hub plugin and version, sanitises messages with `DiagnosticReport.clean`, and posts a batch to `/plugintelemetry/errors` every 5 minutes. It is skipped by `--disable-telemetry`, `-Dmicrobot.disableTelemetry=true` or the config toggle. `-Dmicrobot.apiUrl` points `MicrobotApi` calls (session, plugin and error telemetry) at another base URL for local testing. ## Runtime Boundaries - **Threads**: Client thread (never block/sleep); script/executor threads (automation logic, sleeps allowed); blocking-event executor (resolves UI blockers). Use `ClientThread.runOnClientThreadOptional` for safe client access. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java index 6a22903f633..560c4daca99 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -16,9 +16,11 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; import net.runelite.api.Player; import net.runelite.client.RuneLiteProperties; import net.runelite.client.plugins.Plugin; @@ -27,6 +29,7 @@ import net.runelite.client.plugins.microbot.MicrobotApi; import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +@Slf4j @Singleton public class ScriptErrorReporter extends UnsynchronizedAppenderBase { @@ -86,8 +89,11 @@ protected void append(ILoggingEvent event) IThrowableProxy root = rootCause(event.getThrowableProxy()); List frames = frames(root); String fingerprint = root == null - ? event.getLoggerName() + "|" + event.getMessage() - : root.getClassName() + "|" + String.join("|", frames.subList(0, Math.min(FINGERPRINT_FRAMES, frames.size()))); + ? event.getLoggerName() + "|" + scrub(event.getMessage()) + : root.getClassName() + "|" + frames.stream() + .filter(frame -> !frame.startsWith("java.") && !frame.startsWith("javax.") && !frame.startsWith("jdk.") && !frame.startsWith("sun.")) + .limit(FINGERPRINT_FRAMES) + .collect(Collectors.joining("|")); synchronized (pending) { @@ -106,6 +112,18 @@ protected void append(ILoggingEvent event) } public void flush() + { + try + { + send(); + } + catch (RuntimeException e) + { + log.debug("Error telemetry flush failed", e); + } + } + + private void send() { JsonArray errors = new JsonArray(); synchronized (pending) @@ -217,7 +235,7 @@ private static String scrub(String raw) String name = localPlayerName(); if (raw != null && name != null && !name.isEmpty()) { - raw = raw.replace(name, "[player]").replace(name.replace(' ', ' '), "[player]"); + raw = raw.replace(name, "[player]").replace(name.replace('\u00A0', ' '), "[player]"); } return DiagnosticReport.clean(raw); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java index a43b35e10e1..9a2f63cf0b0 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -12,6 +12,7 @@ import net.runelite.client.plugins.microbot.MicrobotApi; import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -90,6 +91,53 @@ public void groupsRepeatedErrorsAndAttributesPlugin() assertEquals(0, reporter.pendingCount()); } + @Test + public void sanitisesErrorsWithoutException() + { + log(Level.ERROR, "Failed to load /home/alice/.runelite/x.json for alice@example.com", null); + reporter.flush(); + ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); + verify(api).submitErrors(captor.capture()); + String payload = captor.getValue().toString(); + assertFalse(payload, payload.contains("alice")); + } + + @Test + public void groupsJdkThrownErrorsByCallerFrames() + { + for (int i = 0; i < 2; i++) + { + log(Level.ERROR, "loop", i == 0 ? indexError() : indexErrorElsewhere()); + } + assertEquals(2, reporter.pendingCount()); + } + + private static RuntimeException indexError() + { + try + { + new java.util.ArrayList<>().get(1); + return null; + } + catch (IndexOutOfBoundsException e) + { + return e; + } + } + + private static RuntimeException indexErrorElsewhere() + { + try + { + new java.util.ArrayList<>().get(2); + return null; + } + catch (IndexOutOfBoundsException e) + { + return e; + } + } + @Test public void capsDistinctErrors() { From bc01416046acb35bae6c0dd9d428ba6443924afd Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:42:48 +0200 Subject: [PATCH 3/4] feat(microbot): send the server session id with error reports The API now accepts error reports only from a live microbot.cloud session. Send ClientSessionManager's server-issued session id instead of a random one, and keep errors queued until a session exists. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PKz6YjDRJdva762xPzTaia --- .../runelite/client/ClientSessionManager.java | 372 +++++++++--------- .../diagnostics/ScriptErrorReporter.java | 13 +- .../diagnostics/ScriptErrorReporterTest.java | 24 +- 3 files changed, 220 insertions(+), 189 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java b/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java index 9555835d816..456ae7c0cc8 100644 --- a/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java +++ b/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java @@ -1,185 +1,187 @@ -/* - * Copyright (c) 2018, Adam - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package net.runelite.client; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.api.Client; -import net.runelite.api.GameState; -import net.runelite.client.eventbus.Subscribe; -import net.runelite.client.events.ClientShutdown; -import net.runelite.client.plugins.microbot.MicrobotApi; -import net.runelite.client.util.RunnableExceptionLogger; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; -import java.io.IOException; -import java.util.UUID; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -@Singleton -@Slf4j -public class ClientSessionManager -{ - private final ScheduledExecutorService executorService; - private final Client client; - private final SessionClient sessionClient; - private final boolean disableTelemetry; - - private ScheduledFuture scheduledFuture; - private ScheduledFuture scheduledFutureMicroBot; - - private UUID sessionId = UUID.randomUUID(); - private UUID microbotSessionId; - private MicrobotApi microbotApi; - - @Inject - ClientSessionManager(ScheduledExecutorService executorService, - Client client, - SessionClient sessionClient, MicrobotApi microbotApi, - @Named("disableTelemetry") boolean disableTelemetry) - { - this.executorService = executorService; - this.client = client; - this.sessionClient = sessionClient; - this.microbotApi = microbotApi; - this.disableTelemetry = disableTelemetry; - } - - public void start() - { - if (disableTelemetry) { - log.info("Telemetry is disabled. ClientSessionManager will not start."); - return; - } - executorService.execute(() -> - { - try - { - sessionId = sessionClient.open(); - microbotSessionId = microbotApi.microbotOpen(); - log.debug("Opened session {}", sessionId); - } - catch (IOException ex) - { - log.warn("error opening session", ex); - } - }); - scheduledFuture = executorService.scheduleWithFixedDelay(RunnableExceptionLogger.wrap(this::ping), (int) (5 * 60 * Math.random()), 10 * 60, TimeUnit.SECONDS); - scheduledFutureMicroBot = executorService.scheduleWithFixedDelay( - RunnableExceptionLogger.wrap(this::microbotPing), 1, 10, TimeUnit.MINUTES); - } - - @Subscribe - private void onClientShutdown(ClientShutdown e) - { - if (disableTelemetry) return; - scheduledFuture.cancel(true); - scheduledFutureMicroBot.cancel(true); - e.waitFor(executorService.submit(() -> - { - try - { - UUID localUuid = sessionId; - if (localUuid != null) - { - sessionClient.delete(localUuid); - } - UUID localMicrobotUuid = microbotSessionId; - if (localMicrobotUuid != null) - { - microbotApi.microbotDelete(localMicrobotUuid); - } - } - catch (IOException ex) - { - log.warn(null, ex); - } - sessionId = null; - })); - } - - private void ping() - { - if (!isWorldHostValid()) - { - return; - } - - GameState gameState = client.getGameState(); - boolean loggedIn = gameState.getState() >= GameState.LOADING.getState(); - - try - { - sessionClient.ping(sessionId, loggedIn); - } - catch (IOException ex) - { - log.warn("Unable to ping session service", ex); - } - } - - private void microbotPing() - { - try - { - if (microbotSessionId == null) { - microbotSessionId = microbotApi.microbotOpen(); - return; - } - } - catch (IOException ex) - { - log.warn("unable to open session", ex); - return; - } - - boolean loggedIn = false; - if (client != null) - { - GameState gameState = client.getGameState(); - loggedIn = gameState.getState() >= GameState.LOADING.getState(); - } - - try - { - microbotApi.microbotPing(microbotSessionId, loggedIn); - } - catch (IOException ex) - { - log.warn("Resetting session", ex); - sessionId = null; - } - - } - - private boolean isWorldHostValid() - { - String host = client.getWorldHost(); - return host != null && host.endsWith(".runescape.com"); - } -} +/* + * Copyright (c) 2018, Adam + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ClientShutdown; +import net.runelite.client.plugins.microbot.MicrobotApi; +import net.runelite.client.util.RunnableExceptionLogger; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Singleton +@Slf4j +public class ClientSessionManager +{ + private final ScheduledExecutorService executorService; + private final Client client; + private final SessionClient sessionClient; + private final boolean disableTelemetry; + + private ScheduledFuture scheduledFuture; + private ScheduledFuture scheduledFutureMicroBot; + + private UUID sessionId = UUID.randomUUID(); + @Getter + private volatile UUID microbotSessionId; + private MicrobotApi microbotApi; + + @Inject + ClientSessionManager(ScheduledExecutorService executorService, + Client client, + SessionClient sessionClient, MicrobotApi microbotApi, + @Named("disableTelemetry") boolean disableTelemetry) + { + this.executorService = executorService; + this.client = client; + this.sessionClient = sessionClient; + this.microbotApi = microbotApi; + this.disableTelemetry = disableTelemetry; + } + + public void start() + { + if (disableTelemetry) { + log.info("Telemetry is disabled. ClientSessionManager will not start."); + return; + } + executorService.execute(() -> + { + try + { + sessionId = sessionClient.open(); + microbotSessionId = microbotApi.microbotOpen(); + log.debug("Opened session {}", sessionId); + } + catch (IOException ex) + { + log.warn("error opening session", ex); + } + }); + scheduledFuture = executorService.scheduleWithFixedDelay(RunnableExceptionLogger.wrap(this::ping), (int) (5 * 60 * Math.random()), 10 * 60, TimeUnit.SECONDS); + scheduledFutureMicroBot = executorService.scheduleWithFixedDelay( + RunnableExceptionLogger.wrap(this::microbotPing), 1, 10, TimeUnit.MINUTES); + } + + @Subscribe + private void onClientShutdown(ClientShutdown e) + { + if (disableTelemetry) return; + scheduledFuture.cancel(true); + scheduledFutureMicroBot.cancel(true); + e.waitFor(executorService.submit(() -> + { + try + { + UUID localUuid = sessionId; + if (localUuid != null) + { + sessionClient.delete(localUuid); + } + UUID localMicrobotUuid = microbotSessionId; + if (localMicrobotUuid != null) + { + microbotApi.microbotDelete(localMicrobotUuid); + } + } + catch (IOException ex) + { + log.warn(null, ex); + } + sessionId = null; + })); + } + + private void ping() + { + if (!isWorldHostValid()) + { + return; + } + + GameState gameState = client.getGameState(); + boolean loggedIn = gameState.getState() >= GameState.LOADING.getState(); + + try + { + sessionClient.ping(sessionId, loggedIn); + } + catch (IOException ex) + { + log.warn("Unable to ping session service", ex); + } + } + + private void microbotPing() + { + try + { + if (microbotSessionId == null) { + microbotSessionId = microbotApi.microbotOpen(); + return; + } + } + catch (IOException ex) + { + log.warn("unable to open session", ex); + return; + } + + boolean loggedIn = false; + if (client != null) + { + GameState gameState = client.getGameState(); + loggedIn = gameState.getState() >= GameState.LOADING.getState(); + } + + try + { + microbotApi.microbotPing(microbotSessionId, loggedIn); + } + catch (IOException ex) + { + log.warn("Resetting session", ex); + sessionId = null; + } + + } + + private boolean isWorldHostValid() + { + String host = client.getWorldHost(); + return host != null && host.endsWith(".runescape.com"); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java index 560c4daca99..924895132a8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -22,6 +22,7 @@ import javax.inject.Singleton; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Player; +import net.runelite.client.ClientSessionManager; import net.runelite.client.RuneLiteProperties; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; @@ -42,18 +43,19 @@ public class ScriptErrorReporter extends UnsynchronizedAppenderBase pending = new LinkedHashMap<>(); private ScheduledFuture flushTask; @Inject ScriptErrorReporter(MicrobotApi microbotApi, MicrobotPluginManager microbotPluginManager, ScheduledExecutorService executor, - @Named("disableTelemetry") boolean disableTelemetry) + ClientSessionManager clientSessionManager, @Named("disableTelemetry") boolean disableTelemetry) { this.microbotApi = microbotApi; this.microbotPluginManager = microbotPluginManager; this.executor = executor; this.disableTelemetry = disableTelemetry; + this.clientSessionManager = clientSessionManager; setName("SCRIPT_ERROR_REPORTER"); } @@ -125,6 +127,11 @@ public void flush() private void send() { + UUID sessionId = clientSessionManager.getMicrobotSessionId(); + if (sessionId == null) + { + return; + } JsonArray errors = new JsonArray(); synchronized (pending) { @@ -138,7 +145,7 @@ private void send() errors.forEach(error -> attribute(error.getAsJsonObject())); JsonObject payload = new JsonObject(); - payload.addProperty("sessionId", sessionId); + payload.addProperty("sessionId", sessionId.toString()); payload.addProperty("microbotVersion", RuneLiteProperties.getMicrobotVersion()); payload.addProperty("microbotCommit", RuneLiteProperties.getMicrobotCommit()); payload.addProperty("buildChannel", RuneLiteProperties.getMicrobotBuildChannel()); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java index 9a2f63cf0b0..de393c10ce5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -6,7 +6,9 @@ import com.google.gson.JsonObject; import java.util.List; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.ScheduledExecutorService; +import net.runelite.client.ClientSessionManager; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.MicrobotApi; @@ -32,12 +34,15 @@ static class FakePlugin extends Plugin private final LoggerContext context = new LoggerContext(); private MicrobotApi api; + private ClientSessionManager sessions; private ScriptErrorReporter reporter; @Before public void setUp() { api = mock(MicrobotApi.class); + sessions = mock(ClientSessionManager.class); + when(sessions.getMicrobotSessionId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); MicrobotPluginManager pluginManager = mock(MicrobotPluginManager.class); when(pluginManager.getInstalledPlugins()).thenReturn(List.of(new FakePlugin())); when(pluginManager.getInstalledPluginVersion("FakePlugin")).thenReturn(Optional.of("1.2.3")); @@ -46,7 +51,7 @@ public void setUp() private ScriptErrorReporter reporter(MicrobotPluginManager pluginManager, boolean disableTelemetry) { - ScriptErrorReporter reporter = new ScriptErrorReporter(api, pluginManager, mock(ScheduledExecutorService.class), disableTelemetry); + ScriptErrorReporter reporter = new ScriptErrorReporter(api, pluginManager, mock(ScheduledExecutorService.class), sessions, disableTelemetry); reporter.setContext(context); reporter.start(); return reporter; @@ -138,6 +143,23 @@ private static RuntimeException indexErrorElsewhere() } } + @Test + public void keepsErrorsUntilServerSessionExists() + { + when(sessions.getMicrobotSessionId()).thenReturn(null); + log(Level.ERROR, "loop failed", boom()); + reporter.flush(); + verify(api, never()).submitErrors(any()); + assertEquals(1, reporter.pendingCount()); + + when(sessions.getMicrobotSessionId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000002")); + reporter.flush(); + ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); + verify(api).submitErrors(captor.capture()); + assertEquals("00000000-0000-0000-0000-000000000002", captor.getValue().get("sessionId").getAsString()); + assertEquals(0, reporter.pendingCount()); + } + @Test public void capsDistinctErrors() { From d7514108fd0113254a71d593128e474d3f31f2b1 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:09:14 +0200 Subject: [PATCH 4/4] fix(microbot): recover error reporting after session loss Final review of #1900: - microbotPing reset the RuneLite session id instead of the Microbot one, so a dead Microbot session was never reopened and every error upload would 403 until restart. Reset microbotSessionId. - Requeue a batch the server rejects or that fails to send, and log the HTTP status at debug. - Remember the local player name from GameTick on the client thread and keep it after logout for scrubbing, instead of reading game state from the logging thread. - Normalise digits in message fingerprints so "failed 1..N" does not fill the 100-entry cap. - Restore ClientSessionManager's CRLF line endings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PKz6YjDRJdva762xPzTaia --- .../runelite/client/ClientSessionManager.java | 374 +++++++++--------- .../client/plugins/microbot/MicrobotApi.java | 8 +- .../plugins/microbot/MicrobotPlugin.java | 6 + .../diagnostics/ScriptErrorReporter.java | 54 ++- .../diagnostics/ScriptErrorReporterTest.java | 57 ++- 5 files changed, 285 insertions(+), 214 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java b/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java index 456ae7c0cc8..cba51592f1e 100644 --- a/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java +++ b/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java @@ -1,187 +1,187 @@ -/* - * Copyright (c) 2018, Adam - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package net.runelite.client; - -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import net.runelite.api.Client; -import net.runelite.api.GameState; -import net.runelite.client.eventbus.Subscribe; -import net.runelite.client.events.ClientShutdown; -import net.runelite.client.plugins.microbot.MicrobotApi; -import net.runelite.client.util.RunnableExceptionLogger; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; -import java.io.IOException; -import java.util.UUID; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -@Singleton -@Slf4j -public class ClientSessionManager -{ - private final ScheduledExecutorService executorService; - private final Client client; - private final SessionClient sessionClient; - private final boolean disableTelemetry; - - private ScheduledFuture scheduledFuture; - private ScheduledFuture scheduledFutureMicroBot; - - private UUID sessionId = UUID.randomUUID(); - @Getter - private volatile UUID microbotSessionId; - private MicrobotApi microbotApi; - - @Inject - ClientSessionManager(ScheduledExecutorService executorService, - Client client, - SessionClient sessionClient, MicrobotApi microbotApi, - @Named("disableTelemetry") boolean disableTelemetry) - { - this.executorService = executorService; - this.client = client; - this.sessionClient = sessionClient; - this.microbotApi = microbotApi; - this.disableTelemetry = disableTelemetry; - } - - public void start() - { - if (disableTelemetry) { - log.info("Telemetry is disabled. ClientSessionManager will not start."); - return; - } - executorService.execute(() -> - { - try - { - sessionId = sessionClient.open(); - microbotSessionId = microbotApi.microbotOpen(); - log.debug("Opened session {}", sessionId); - } - catch (IOException ex) - { - log.warn("error opening session", ex); - } - }); - scheduledFuture = executorService.scheduleWithFixedDelay(RunnableExceptionLogger.wrap(this::ping), (int) (5 * 60 * Math.random()), 10 * 60, TimeUnit.SECONDS); - scheduledFutureMicroBot = executorService.scheduleWithFixedDelay( - RunnableExceptionLogger.wrap(this::microbotPing), 1, 10, TimeUnit.MINUTES); - } - - @Subscribe - private void onClientShutdown(ClientShutdown e) - { - if (disableTelemetry) return; - scheduledFuture.cancel(true); - scheduledFutureMicroBot.cancel(true); - e.waitFor(executorService.submit(() -> - { - try - { - UUID localUuid = sessionId; - if (localUuid != null) - { - sessionClient.delete(localUuid); - } - UUID localMicrobotUuid = microbotSessionId; - if (localMicrobotUuid != null) - { - microbotApi.microbotDelete(localMicrobotUuid); - } - } - catch (IOException ex) - { - log.warn(null, ex); - } - sessionId = null; - })); - } - - private void ping() - { - if (!isWorldHostValid()) - { - return; - } - - GameState gameState = client.getGameState(); - boolean loggedIn = gameState.getState() >= GameState.LOADING.getState(); - - try - { - sessionClient.ping(sessionId, loggedIn); - } - catch (IOException ex) - { - log.warn("Unable to ping session service", ex); - } - } - - private void microbotPing() - { - try - { - if (microbotSessionId == null) { - microbotSessionId = microbotApi.microbotOpen(); - return; - } - } - catch (IOException ex) - { - log.warn("unable to open session", ex); - return; - } - - boolean loggedIn = false; - if (client != null) - { - GameState gameState = client.getGameState(); - loggedIn = gameState.getState() >= GameState.LOADING.getState(); - } - - try - { - microbotApi.microbotPing(microbotSessionId, loggedIn); - } - catch (IOException ex) - { - log.warn("Resetting session", ex); - sessionId = null; - } - - } - - private boolean isWorldHostValid() - { - String host = client.getWorldHost(); - return host != null && host.endsWith(".runescape.com"); - } -} +/* + * Copyright (c) 2018, Adam + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ClientShutdown; +import net.runelite.client.plugins.microbot.MicrobotApi; +import net.runelite.client.util.RunnableExceptionLogger; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Singleton +@Slf4j +public class ClientSessionManager +{ + private final ScheduledExecutorService executorService; + private final Client client; + private final SessionClient sessionClient; + private final boolean disableTelemetry; + + private ScheduledFuture scheduledFuture; + private ScheduledFuture scheduledFutureMicroBot; + + private UUID sessionId = UUID.randomUUID(); + @Getter + private volatile UUID microbotSessionId; + private MicrobotApi microbotApi; + + @Inject + ClientSessionManager(ScheduledExecutorService executorService, + Client client, + SessionClient sessionClient, MicrobotApi microbotApi, + @Named("disableTelemetry") boolean disableTelemetry) + { + this.executorService = executorService; + this.client = client; + this.sessionClient = sessionClient; + this.microbotApi = microbotApi; + this.disableTelemetry = disableTelemetry; + } + + public void start() + { + if (disableTelemetry) { + log.info("Telemetry is disabled. ClientSessionManager will not start."); + return; + } + executorService.execute(() -> + { + try + { + sessionId = sessionClient.open(); + microbotSessionId = microbotApi.microbotOpen(); + log.debug("Opened session {}", sessionId); + } + catch (IOException ex) + { + log.warn("error opening session", ex); + } + }); + scheduledFuture = executorService.scheduleWithFixedDelay(RunnableExceptionLogger.wrap(this::ping), (int) (5 * 60 * Math.random()), 10 * 60, TimeUnit.SECONDS); + scheduledFutureMicroBot = executorService.scheduleWithFixedDelay( + RunnableExceptionLogger.wrap(this::microbotPing), 1, 10, TimeUnit.MINUTES); + } + + @Subscribe + private void onClientShutdown(ClientShutdown e) + { + if (disableTelemetry) return; + scheduledFuture.cancel(true); + scheduledFutureMicroBot.cancel(true); + e.waitFor(executorService.submit(() -> + { + try + { + UUID localUuid = sessionId; + if (localUuid != null) + { + sessionClient.delete(localUuid); + } + UUID localMicrobotUuid = microbotSessionId; + if (localMicrobotUuid != null) + { + microbotApi.microbotDelete(localMicrobotUuid); + } + } + catch (IOException ex) + { + log.warn(null, ex); + } + sessionId = null; + })); + } + + private void ping() + { + if (!isWorldHostValid()) + { + return; + } + + GameState gameState = client.getGameState(); + boolean loggedIn = gameState.getState() >= GameState.LOADING.getState(); + + try + { + sessionClient.ping(sessionId, loggedIn); + } + catch (IOException ex) + { + log.warn("Unable to ping session service", ex); + } + } + + private void microbotPing() + { + try + { + if (microbotSessionId == null) { + microbotSessionId = microbotApi.microbotOpen(); + return; + } + } + catch (IOException ex) + { + log.warn("unable to open session", ex); + return; + } + + boolean loggedIn = false; + if (client != null) + { + GameState gameState = client.getGameState(); + loggedIn = gameState.getState() >= GameState.LOADING.getState(); + } + + try + { + microbotApi.microbotPing(microbotSessionId, loggedIn); + } + catch (IOException ex) + { + log.warn("Resetting session", ex); + microbotSessionId = null; + } + + } + + private boolean isWorldHostValid() + { + String host = client.getWorldHost(); + return host != null && host.endsWith(".runescape.com"); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java index 65dfb5d17ce..97d017fab75 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java @@ -107,7 +107,7 @@ public void increasePluginInstall(String internalName, String displayName, Strin } } - public void submitErrors(JsonObject payload) + public void submitErrors(JsonObject payload, Runnable onRejected) { Request request = new Request.Builder() .url(microbotApiUrl + "/plugintelemetry/errors") @@ -121,11 +121,17 @@ public void submitErrors(JsonObject payload) public void onFailure(Call call, IOException e) { log.debug("Error telemetry upload failed", e); + onRejected.run(); } @Override public void onResponse(Call call, Response response) { + if (!response.isSuccessful()) + { + log.debug("Error telemetry upload rejected: HTTP {}", response.code()); + onRejected.run(); + } response.close(); } }); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 86ef3594fa3..1ff435b816e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -644,6 +644,12 @@ public void onGameTick(GameTick event) // Start Leagues teleport calibration ASAP after login (non-blocking; prompts for consent once). Rs2LeaguesTransport.tickLeaguesCalibration(); + + Player localPlayer = Microbot.getClient().getLocalPlayer(); + if (localPlayer != null) + { + scriptErrorReporter.rememberPlayerName(localPlayer.getName()); + } } @Subscribe(priority = 100) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java index 924895132a8..ad4424a41de 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -21,7 +21,6 @@ import javax.inject.Named; import javax.inject.Singleton; import lombok.extern.slf4j.Slf4j; -import net.runelite.api.Player; import net.runelite.client.ClientSessionManager; import net.runelite.client.RuneLiteProperties; import net.runelite.client.plugins.Plugin; @@ -46,6 +45,7 @@ public class ScriptErrorReporter extends UnsynchronizedAppenderBase pending = new LinkedHashMap<>(); private ScheduledFuture flushTask; + private volatile String playerName; @Inject ScriptErrorReporter(MicrobotApi microbotApi, MicrobotPluginManager microbotPluginManager, ScheduledExecutorService executor, @@ -91,7 +91,7 @@ protected void append(ILoggingEvent event) IThrowableProxy root = rootCause(event.getThrowableProxy()); List frames = frames(root); String fingerprint = root == null - ? event.getLoggerName() + "|" + scrub(event.getMessage()) + ? event.getLoggerName() + "|" + scrub(event.getMessage()).replaceAll("\\d+", "#") : root.getClassName() + "|" + frames.stream() .filter(frame -> !frame.startsWith("java.") && !frame.startsWith("javax.") && !frame.startsWith("jdk.") && !frame.startsWith("sun.")) .limit(FINGERPRINT_FRAMES) @@ -153,7 +153,36 @@ private void send() payload.addProperty("osName", System.getProperty("os.name")); payload.addProperty("osArch", System.getProperty("os.arch")); payload.add("errors", errors); - microbotApi.submitErrors(payload); + microbotApi.submitErrors(payload, () -> requeue(errors)); + } + + public void rememberPlayerName(String name) + { + if (name != null && !name.isEmpty()) + { + playerName = name; + } + } + + private void requeue(JsonArray errors) + { + synchronized (pending) + { + for (JsonElement element : errors) + { + JsonObject error = element.getAsJsonObject(); + String fingerprint = error.get("fingerprint").getAsString(); + JsonObject existing = pending.get(fingerprint); + if (existing != null) + { + existing.addProperty("count", existing.get("count").getAsInt() + error.get("count").getAsInt()); + } + else if (pending.size() < MAX_FINGERPRINTS) + { + pending.put(fingerprint, error); + } + } + } } int pendingCount() @@ -164,7 +193,7 @@ int pendingCount() } } - private static JsonObject describe(ILoggingEvent event, IThrowableProxy root, List frames, String fingerprint) + private JsonObject describe(ILoggingEvent event, IThrowableProxy root, List frames, String fingerprint) { JsonObject error = new JsonObject(); error.addProperty("fingerprint", fingerprint); @@ -237,26 +266,13 @@ private static List frames(IThrowableProxy ex) return frames; } - private static String scrub(String raw) + private String scrub(String raw) { - String name = localPlayerName(); + String name = playerName; if (raw != null && name != null && !name.isEmpty()) { raw = raw.replace(name, "[player]").replace(name.replace('\u00A0', ' '), "[player]"); } return DiagnosticReport.clean(raw); } - - private static String localPlayerName() - { - try - { - Player player = Microbot.getClient().getLocalPlayer(); - return player == null ? null : player.getName(); - } - catch (RuntimeException e) - { - return null; - } - } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java index de393c10ce5..4e2b75375a7 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -86,7 +86,7 @@ public void groupsRepeatedErrorsAndAttributesPlugin() reporter.flush(); ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); - verify(api).submitErrors(captor.capture()); + verify(api).submitErrors(captor.capture(), any()); JsonObject first = captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject(); assertEquals(3, first.get("count").getAsInt()); assertEquals("java.lang.IllegalStateException", first.get("exception").getAsString()); @@ -102,7 +102,7 @@ public void sanitisesErrorsWithoutException() log(Level.ERROR, "Failed to load /home/alice/.runelite/x.json for alice@example.com", null); reporter.flush(); ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); - verify(api).submitErrors(captor.capture()); + verify(api).submitErrors(captor.capture(), any()); String payload = captor.getValue().toString(); assertFalse(payload, payload.contains("alice")); } @@ -149,23 +149,66 @@ public void keepsErrorsUntilServerSessionExists() when(sessions.getMicrobotSessionId()).thenReturn(null); log(Level.ERROR, "loop failed", boom()); reporter.flush(); - verify(api, never()).submitErrors(any()); + verify(api, never()).submitErrors(any(), any()); assertEquals(1, reporter.pendingCount()); when(sessions.getMicrobotSessionId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000002")); reporter.flush(); ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); - verify(api).submitErrors(captor.capture()); + verify(api).submitErrors(captor.capture(), any()); assertEquals("00000000-0000-0000-0000-000000000002", captor.getValue().get("sessionId").getAsString()); assertEquals(0, reporter.pendingCount()); } + @Test + public void scrubsRememberedPlayerNameAfterLogout() + { + reporter.rememberPlayerName("Zezima\u00A0Two"); + reporter.rememberPlayerName(null); + log(Level.ERROR, "Zezima Two could not bank", new IllegalStateException("Zezima\u00A0Two is dead")); + reporter.flush(); + ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); + verify(api).submitErrors(captor.capture(), any()); + String payload = captor.getValue().toString(); + assertFalse(payload, payload.contains("Zezima")); + } + + @Test + public void requeuesRejectedBatch() + { + RuntimeException ex = boom(); + log(Level.ERROR, "loop failed", ex); + reporter.flush(); + ArgumentCaptor onRejected = ArgumentCaptor.forClass(Runnable.class); + verify(api).submitErrors(any(), onRejected.capture()); + assertEquals(0, reporter.pendingCount()); + + log(Level.ERROR, "loop failed", ex); + onRejected.getValue().run(); + assertEquals(1, reporter.pendingCount()); + + reporter.flush(); + ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); + verify(api, org.mockito.Mockito.times(2)).submitErrors(captor.capture(), any()); + assertEquals(2, captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject().get("count").getAsInt()); + } + + @Test + public void groupsMessagesThatOnlyDifferInNumbers() + { + for (int i = 0; i < 50; i++) + { + log(Level.ERROR, "failed attempt " + i, null); + } + assertEquals(1, reporter.pendingCount()); + } + @Test public void capsDistinctErrors() { for (int i = 0; i < ScriptErrorReporter.MAX_FINGERPRINTS + 10; i++) { - log(Level.ERROR, "failed " + i, null); + log(Level.ERROR, "failed " + (char) ('a' + i % 26) + (char) ('a' + i / 26), null); } assertEquals(ScriptErrorReporter.MAX_FINGERPRINTS, reporter.pendingCount()); } @@ -177,7 +220,7 @@ public void sendsNothingWhenTelemetryDisabled() log(Level.ERROR, "loop failed", boom()); reporter.flush(); assertEquals(0, reporter.pendingCount()); - verify(api, never()).submitErrors(any()); + verify(api, never()).submitErrors(any(), any()); } @Test @@ -187,6 +230,6 @@ public void sendsNothingWithDisableTelemetryFlag() log(Level.ERROR, "loop failed", boom()); reporter.flush(); assertEquals(0, reporter.pendingCount()); - verify(api, never()).submitErrors(any()); + verify(api, never()).submitErrors(any(), any()); } }