diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68aa526c75..90a58f5144 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 `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/ClientSessionManager.java b/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java index 9555835d81..cba51592f1 100644 --- a/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java +++ b/runelite-client/src/main/java/net/runelite/client/ClientSessionManager.java @@ -24,6 +24,7 @@ */ package net.runelite.client; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.api.GameState; @@ -54,7 +55,8 @@ public class ClientSessionManager private ScheduledFuture scheduledFutureMicroBot; private UUID sessionId = UUID.randomUUID(); - private UUID microbotSessionId; + @Getter + private volatile UUID microbotSessionId; private MicrobotApi microbotApi; @Inject @@ -172,7 +174,7 @@ private void microbotPing() catch (IOException ex) { log.warn("Resetting session", ex); - sessionId = null; + microbotSessionId = null; } } 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 041dba8c8b..97d017fab7 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,36 @@ public void increasePluginInstall(String internalName, String displayName, Strin } } + public void submitErrors(JsonObject payload, Runnable onRejected) + { + 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); + 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(); + } + }); + } + /** * 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 ec8490eed9..dbf2032120 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 25962198f7..1ff435b816 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(); } @@ -634,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 new file mode 100644 index 0000000000..ad4424a41d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -0,0 +1,278 @@ +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 java.util.stream.Collectors; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.ClientSessionManager; +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; + +@Slf4j +@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 ClientSessionManager clientSessionManager; + private final Map pending = new LinkedHashMap<>(); + private ScheduledFuture flushTask; + private volatile String playerName; + + @Inject + ScriptErrorReporter(MicrobotApi microbotApi, MicrobotPluginManager microbotPluginManager, ScheduledExecutorService executor, + 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"); + } + + @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() + "|" + 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) + .collect(Collectors.joining("|")); + + 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() + { + try + { + send(); + } + catch (RuntimeException e) + { + log.debug("Error telemetry flush failed", e); + } + } + + private void send() + { + UUID sessionId = clientSessionManager.getMicrobotSessionId(); + if (sessionId == null) + { + return; + } + 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.toString()); + 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, () -> 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() + { + synchronized (pending) + { + return pending.size(); + } + } + + private 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 String scrub(String raw) + { + String name = playerName; + if (raw != null && name != null && !name.isEmpty()) + { + 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 new file mode 100644 index 0000000000..4e2b75375a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -0,0 +1,235 @@ +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.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; +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; +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 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")); + reporter = reporter(pluginManager, false); + } + + private ScriptErrorReporter reporter(MicrobotPluginManager pluginManager, boolean disableTelemetry) + { + ScriptErrorReporter reporter = new ScriptErrorReporter(api, pluginManager, mock(ScheduledExecutorService.class), sessions, 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(), any()); + 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 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(), any()); + 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 keepsErrorsUntilServerSessionExists() + { + when(sessions.getMicrobotSessionId()).thenReturn(null); + log(Level.ERROR, "loop failed", boom()); + reporter.flush(); + 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(), 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 " + (char) ('a' + i % 26) + (char) ('a' + i / 26), 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(), 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(), any()); + } +}