Repository: btraceio/btrace (2026-07-13T08:27:48Z)

Issue:
[3.0] Make agent telemetry explicitly opt-in
## Context

The current agent fires a PostHog agent_start request by default from inside the target JVM. BTrace runs in production environments where undocumented outbound traffic can violate policy and damage trust.

## Scope

- Make telemetry disabled unless explicitly enabled.
- Preserve explicit opt-in, preferably an agent argument such as telemetry=true.
- Ensure no telemetry thread, DNS resolution, or network connection occurs by default.
- Document event fields, endpoint, timeout, and opt-in/opt-out behavior.
- Keep identifiers minimal and do not add stable host identity without a separate privacy review.

## Likely files

- btrace-agent/src/main/java/io/btrace/agent/Telemetry.java
- btrace-agent/src/main/java/io/btrace/agent/Main.java
- btrace-agent/src/test/java/io/btrace/agent/TelemetryTest.java
- btrace-core/src/main/java/io/btrace/core/Args.java
- Agent argument documentation

## Acceptance criteria

- [ ] Default startup performs no telemetry network activity.
- [ ] telemetry=true enables one attempted event.
- [ ] telemetry=false remains an explicit disable override.
- [ ] Tests prove the default path does not invoke transport code.
- [ ] Agent startup remains unaffected when the endpoint is unavailable.
- [ ] Release documentation clearly states the behavior.


Discussion:

Patch:
diff --git a/btrace-agent/src/main/java/io/btrace/agent/Main.java b/btrace-agent/src/main/java/io/btrace/agent/Main.java
index 9003b6844..d79747016 100644
--- a/btrace-agent/src/main/java/io/btrace/agent/Main.java
+++ b/btrace-agent/src/main/java/io/btrace/agent/Main.java
@@ -45,6 +45,7 @@
 import static io.btrace.core.Args.STATSD;
 import static io.btrace.core.Args.STDOUT;
 import static io.btrace.core.Args.SYSTEM_CLASS_PATH;
+import static io.btrace.core.Args.TELEMETRY;
 import static io.btrace.core.Args.TRACK_RETRANSFORMS;
 import static io.btrace.core.Args.TRUSTED;
 
@@ -201,7 +202,7 @@ private static synchronized void main(String args, Instrumentation inst) {
       if (AGENT_DEBUG) System.err.println("[BTrace Agent] Parsing arguments");
       parseArgs();
       if (AGENT_DEBUG) System.err.println("[BTrace Agent] Arguments parsed");
-      Telemetry.fireAsync(readBTraceVersion(), agentMode);
+      Telemetry.fireAsync(argMap.get(TELEMETRY), readBTraceVersion(), agentMode);
       // settings are all built-up; set the logging system properties accordingly
       DebugSupport.initLoggers(settings.isDebug(), log);
 
diff --git a/btrace-agent/src/main/java/io/btrace/agent/Telemetry.java b/btrace-agent/src/main/java/io/btrace/agent/Telemetry.java
index b74577300..c9e3ccb9c 100644
--- a/btrace-agent/src/main/java/io/btrace/agent/Telemetry.java
+++ b/btrace-agent/src/main/java/io/btrace/agent/Telemetry.java
@@ -30,18 +30,33 @@ final class Telemetry {
 
   static final String ENDPOINT = "https://eu.posthog.com/capture/";
 
-  // Hard wall-clock cap for the guard thread (covers DNS + connect + read).
-  // connectTimeout alone does not bound DNS resolution — the guard join does.
+  // The guard requests worker cancellation after this interval. DNS implementations are not
+  // required to honor thread interruption, so the daemon worker remains the final startup guard.
   static final int GUARD_TIMEOUT_MS = 2000;
   static final int CONNECT_TIMEOUT_MS = 1000;
   static final int READ_TIMEOUT_MS = 1000;
 
-  private static final String PROP_DISABLED = "btrace.telemetry";
+  private static final String PROP_ENABLED = "btrace.telemetry";
+
+  interface Transport {
+    void send(String payload);
+  }
+
+  private static final Transport HTTP_TRANSPORT =
+      new Transport() {
+        @Override
+        public void send(String payload) {
+          sendHttp(payload);
+        }
+      };
 
   private Telemetry() {}
 
-  static boolean isEnabled() {
-    return !"false".equalsIgnoreCase(System.getProperty(PROP_DISABLED, "true"));
+  static boolean isEnabled(String agentValue) {
+    if (agentValue != null) {
+      return Boolean.parseBoolean(agentValue);
+    }
+    return Boolean.getBoolean(PROP_ENABLED);
   }
 
   static String buildPayload(String btraceVersion, String agentMode) {
@@ -73,24 +88,33 @@ static String buildPayload(String btraceVersion, String agentMode) {
         + "}";
   }
 
-  // fireAsync returns immediately — the calling thread (agent startup) is never blocked.
-  static void fireAsync(final String btraceVersion, final String agentMode) {
-    if (!isEnabled()) {
-      return;
+  // Consent is checked before creating threads or building the payload.
+  static boolean fireAsync(
+      String telemetryValue, final String btraceVersion, final String agentMode) {
+    return fireAsync(telemetryValue, btraceVersion, agentMode, HTTP_TRANSPORT);
+  }
+
+  static boolean fireAsync(
+      String telemetryValue,
+      final String btraceVersion,
+      final String agentMode,
+      final Transport transport) {
+    if (!isEnabled(telemetryValue)) {
+      return false;
     }
     final Thread worker =
         new Thread(
             new Runnable() {
               @Override
               public void run() {
-                send(btraceVersion, agentMode);
+                deliverSafely(transport, buildPayload(btraceVersion, agentMode));
               }
             });
     worker.setDaemon(true);
     worker.setName("btrace-telemetry");
 
-    // Guard caps total wall-clock time including DNS, which connectTimeout
-    // alone does not cover. Both threads are daemon threads.
+    // The guard requests cancellation if DNS or transport work outlives the configured timeout.
+    // Both threads are daemon threads, so telemetry never keeps the target JVM alive.
     Thread guard =
         new Thread(
             new Runnable() {
@@ -106,15 +130,27 @@ public void run() {
             });
     guard.setDaemon(true);
     guard.setName("btrace-telemetry-guard");
-    guard.start();
+    try {
+      guard.start();
+      return true;
+    } catch (Throwable ignored) {
+      return false;
+    }
   }
 
-  private static void send(String btraceVersion, String agentMode) {
+  static void deliverSafely(Transport transport, String payload) {
+    try {
+      transport.send(payload);
+    } catch (Throwable ignored) {
+    }
+  }
+
+  private static void sendHttp(String payload) {
+    HttpURLConnection conn = null;
     try {
-      String payload = buildPayload(btraceVersion, agentMode);
       byte[] body = payload.getBytes(Charset.forName("UTF-8"));
       URL url = new URL(ENDPOINT);
-      HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+      conn = (HttpURLConnection) url.openConnection();
       conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
       conn.setReadTimeout(READ_TIMEOUT_MS);
       conn.setRequestMethod("POST");
@@ -125,8 +161,11 @@ private static void send(String btraceVersion, String agentMode) {
         out.write(body);
       }
       conn.getResponseCode();
-      conn.disconnect();
     } catch (Throwable ignored) {
+    } finally {
+      if (conn != null) {
+        conn.disconnect();
+      }
     }
   }
 
diff --git a/btrace-core/src/main/java/io/btrace/core/Args.java b/btrace-core/src/main/java/io/btrace/core/Args.java
index e67b60d1c..fa054c412 100644
--- a/btrace-core/src/main/java/io/btrace/core/Args.java
+++ b/btrace-core/src/main/java/io/btrace/core/Args.java
@@ -36,6 +36,7 @@ public final class Args {
   public static final String STATSD = "statsd";
   public static final String PROBE_DESC_PATH = "probeDescPath";
   public static final String DEBUG = "debug";
+  public static final String TELEMETRY = "telemetry";
   public static final String PORT = "port";
   public static final String STDOUT = "stdout";
   public static final String NO_SERVER = "noServer";
diff --git a/docs/BTraceTutorial.md b/docs/BTraceTutorial.md
index 01c5481b0..3be8654f6 100644
--- a/docs/BTraceTutorial.md
+++ b/docs/BTraceTutorial.md
@@ -125,6 +125,7 @@ The agent takes a list of comma separated arguments.
 * **dumpClasses** - dump the transformed bytecode to files (true/false)
 * **dumpDir** - specifies the folder where the transformed classes will be dumped to
 * **stdout** - redirect the btrace output to stdout instead of writing it to an arbitrary file (true/false)
+* **telemetry** - explicitly enable the default-off anonymous `agent_start` event (true/false)
 * **probeDescPath** - the path to search for probe descriptor XMLs
 * **startupRetransform** - enable retransform of all the loaded classes at attach (true/false)
 * **scriptdir** - the path to a directory containing scripts to be run at the agent startup
diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md
index d2df15af2..7d07b6520 100644
--- a/docs/GettingStarted.md
+++ b/docs/GettingStarted.md
@@ -389,6 +389,24 @@ Prepared mode remains a 3.0.0 release gate. Until the authenticated path is avai
 release, use startup-script mode with `noServer=true`, or use dynamic attach where the target JVM's
 policy permits it. Do not expose a startup command server as a remote diagnostic endpoint.
 
+#### Optional agent telemetry
+
+Agent telemetry is disabled by default. The agent creates no telemetry thread and performs no
+telemetry-related DNS or network operation unless the operator explicitly opts in:
+
+```bash
+java -javaagent:/path/to/btrace.jar=script=MyTrace.class,noServer=true,telemetry=true MyApp
+```
+
+The legacy target-JVM property `-Dbtrace.telemetry=true` is also recognized when the agent argument
+is absent. An explicit `telemetry=false` agent argument overrides that property.
+
+An opted-in startup attempts one `agent_start` event to
+`https://eu.posthog.com/capture/`. The event contains a fresh random event identifier plus
+`java_version`, `os_name`, `btrace_version`, and `agent_mode`; it contains no stable host identifier
+or application data. HTTP connect and read timeouts are one second each, and a daemon guard requests
+cancellation after two seconds. Telemetry failures are ignored and do not block agent startup.
+
 ### 4. Launcher Mode (btracer)
 
 Use the `btracer` wrapper to compile and attach in one step:
diff --git a/docs/QuickReference.md b/docs/QuickReference.md
index eb9aa270c..a1c9e41a3 100644
--- a/docs/QuickReference.md
+++ b/docs/QuickReference.md
@@ -541,6 +541,8 @@ java -javaagent:/path/to/btrace.jar=script=<script.class>,noServer=true[,arg=val
 - `port=<port>` - Communication port
 - `noServer=true` - Don't start the command server; use for startup scripts that need no later
   client connection
+- `telemetry=true|false` - Explicitly enable or disable the default-off `agent_start` telemetry
+  event
 - `bootClassPath=<path>` - Additional boot classpath
 
 **Examples:**


Test patch:
diff --git a/btrace-agent/src/test/java/io/btrace/agent/TelemetryTest.java b/btrace-agent/src/test/java/io/btrace/agent/TelemetryTest.java
index c9b137495..a7bc375fd 100644
--- a/btrace-agent/src/test/java/io/btrace/agent/TelemetryTest.java
+++ b/btrace-agent/src/test/java/io/btrace/agent/TelemetryTest.java
@@ -16,34 +16,63 @@
  */
 package io.btrace.agent;
 
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 class TelemetryTest {
 
+  @BeforeEach
+  void resetProp() {
+    System.clearProperty("btrace.telemetry");
+  }
+
   @AfterEach
   void clearProp() {
     System.clearProperty("btrace.telemetry");
   }
 
   @Test
-  void enabledByDefault() {
+  void disabledByDefault() {
     System.clearProperty("btrace.telemetry");
-    assertTrue(Telemetry.isEnabled());
+    assertFalse(Telemetry.isEnabled(null));
   }
 
   @Test
-  void disabledWhenPropertyIsFalse() {
+  void enabledWhenAgentArgumentIsTrue() {
     System.setProperty("btrace.telemetry", "false");
-    assertFalse(Telemetry.isEnabled());
+    assertTrue(Telemetry.isEnabled("true"));
   }
 
   @Test
-  void enabledWhenPropertyIsTrue() {
+  void agentArgumentFalseOverridesSystemProperty() {
     System.setProperty("btrace.telemetry", "true");
-    assertTrue(Telemetry.isEnabled());
+    AtomicInteger attempts = new AtomicInteger();
+
+    boolean scheduled =
+        Telemetry.fireAsync("false", "2.2.6", "premain", payload -> attempts.incrementAndGet());
+
+    assertFalse(scheduled);
+    assertEquals(0, attempts.get());
+  }
+
+  @Test
+  void systemPropertyRemainsAnExplicitOptIn() {
+    System.setProperty("btrace.telemetry", "true");
+    assertTrue(Telemetry.isEnabled(null));
+  }
+
+  @Test
+  void nonBooleanValueDoesNotEnableTelemetry() {
+    assertFalse(Telemetry.isEnabled("yes"));
   }
 
   @Test
@@ -65,19 +94,43 @@ void payloadEscapesSpecialCharacters() {
   }
 
   @Test
-  void fireAsyncDoesNotThrowWhenDisabled() {
-    System.setProperty("btrace.telemetry", "false");
-    assertDoesNotThrow(() -> Telemetry.fireAsync("2.2.6", "premain"));
+  void defaultPathDoesNotInvokeTransport() {
+    AtomicInteger attempts = new AtomicInteger();
+
+    boolean scheduled =
+        Telemetry.fireAsync(null, "2.2.6", "premain", payload -> attempts.incrementAndGet());
+
+    assertFalse(scheduled);
+    assertEquals(0, attempts.get());
   }
 
   @Test
-  void fireAsyncReturnsImmediately() throws InterruptedException {
-    System.setProperty("btrace.telemetry", "true");
-    long start = System.currentTimeMillis();
-    assertDoesNotThrow(() -> Telemetry.fireAsync("2.2.6", "agentmain"));
-    long elapsed = System.currentTimeMillis() - start;
-    assertTrue(elapsed < 100, "fireAsync blocked for " + elapsed + "ms");
-    // Let daemon threads settle before the next test
-    Thread.sleep(300);
+  void optInAttemptsExactlyOneEvent() throws InterruptedException {
+    AtomicInteger attempts = new AtomicInteger();
+    CountDownLatch attempted = new CountDownLatch(1);
+
+    boolean scheduled =
+        Telemetry.fireAsync(
+            "true",
+            "2.2.6",
+            "agentmain",
+            payload -> {
+              attempts.incrementAndGet();
+              attempted.countDown();
+            });
+
+    assertTrue(scheduled);
+    assertTrue(attempted.await(5, TimeUnit.SECONDS));
+    assertEquals(1, attempts.get());
+  }
+
+  @Test
+  void transportFailureIsIgnored() {
+    Telemetry.Transport unavailableEndpoint =
+        payload -> {
+          throw new IllegalStateException("endpoint unavailable");
+        };
+
+    assertDoesNotThrow(() -> Telemetry.deliverSafely(unavailableEndpoint, "{}"));
   }
 }