From 4b3af60af75410f66e39a07d291c52cb80f78099 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 8 May 2026 11:54:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat!:=20v8.0=20=E2=80=94=20decision=20hist?= =?UTF-8?q?ory=20API=20+=20telemetry=20simplification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combined major release for the Java SDK. Bundles a new decision-history client API surface (`listDecisions` + runnable explain example, both landed since v7.1.0) with a telemetry contract simplification that removes silent customer-side suppression paths. After this lands: - `AXONFLOW_TELEMETRY=off` is the SOLE opt-out for SDK telemetry — same pattern as HashiCorp checkpoint, Docker, Datadog Agent. - Sandbox-mode clients now fire telemetry, tagged `stream="sandbox"` so they're distinguishable from production heartbeat in analytics. - `AxonFlowConfig.Builder.telemetry(Boolean)` and `AxonFlowConfig.getTelemetry()` removed — the config-level kill switch is gone. Why combined into a major: the telemetry contract had two redundant levers (env var + programmatic field) and one silent-suppression path (mode-based default-off). Cleanup is technically a breaking change, so it must ride a major version bump. We bundled the decision-history feature work (#166, #167) into the same major instead of releasing a v7.2.0 right before a v8.0.0 — fewer customer-facing version transitions. Signed-off-by: Saurabh Jain --- CHANGELOG.md | 55 +++++ README.md | 10 +- examples/explain-decision/pom.xml | 2 +- examples/list-decisions/pom.xml | 2 +- pom.xml | 2 +- .../sandbox_telemetry_stream_tag/README.md | 45 ++++ .../sandbox_telemetry_stream_tag/test.sh | 161 +++++++++++++++ .../java/com/getaxonflow/sdk/AxonFlow.java | 14 +- .../com/getaxonflow/sdk/AxonFlowConfig.java | 32 --- .../sdk/telemetry/TelemetryReporter.java | 80 +++---- .../TelemetryReporterShortLivedTest.java | 2 - .../sdk/telemetry/TelemetryReporterTest.java | 195 +++++++----------- 12 files changed, 384 insertions(+), 216 deletions(-) create mode 100644 runtime-e2e/sandbox_telemetry_stream_tag/README.md create mode 100755 runtime-e2e/sandbox_telemetry_stream_tag/test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a4642ea..6a35a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.0.0] - 2026-05-08 — Decision history API + telemetry simplification + +**Major release.** The headline feature is the new decision-history client +API: `listDecisions` for paging through recorded decisions, plus a +runnable example showing the full record → list → explain audit flow. +Bundled into a major because the v8 line also tightens the telemetry +contract — see `Removed` at the bottom of this entry for that. + +### Added + +- **`listDecisions(ListDecisionsOptions opts)` client method.** Pages + over recorded decision history from the orchestrator, mirroring `GET + /api/v1/decisions`. Companion to the v7.4.0 `getDecisionExplain` + method — callers can now both list and drill in. See + `examples/list-decisions/`. +- **`examples/explain-decision/`** end-to-end runnable example covering + the full decision audit flow: record → list → explain. + +### Migration guide (v7 → v8) + +- **`AxonFlowConfig.Builder.telemetry(Boolean)` removed.** Code that + called `.telemetry(true)` or `.telemetry(false)` on the builder will + fail to compile. Migration: remove the call from your builder chain. + If you were using it to disable telemetry, set + `AXONFLOW_TELEMETRY=off` in the environment instead — that's the + sole opt-out lever as of v8. If you were using it to force-enable, + the default is now ON for every mode so the override is no longer + needed. +- **`AxonFlowConfig.getTelemetry()` removed.** Code reading the + override field will fail to compile. Same migration: drop the call + site; `AXONFLOW_TELEMETRY=off` is the only telemetry knob. +- **`TelemetryReporter.isEnabled` and `TelemetryReporter.sendPing` + signatures simplified.** Both methods previously took the + `(mode, configOverride, hasCredentials, ...)` parameter shape from + the v7 mode-and-override gate. v8 collapses to a single env-var + signal: `isEnabled(String axonflowTelemetry)` and + `sendPing(String mode, String sdkEndpoint, boolean debug, ...)`. + Application code does not call these directly; only test harnesses + that exercise the testability surface need to update. + +### Removed + +- **`AxonFlowConfig.Builder.telemetry(Boolean)` builder method** (was + `Builder telemetry(Boolean)`) and **`AxonFlowConfig.getTelemetry()`** + accessor. `AXONFLOW_TELEMETRY=off` is now the sole opt-out path. Tests + that need to defend against contaminated dev environments should + pass `null` to the testability `axonflowTelemetry` parameter or set + `AXONFLOW_TELEMETRY=` (empty) at the test level. +- **Sandbox-mode silent telemetry suppression.** Sandbox-mode clients + (constructed via `Mode.SANDBOX`) now fire telemetry on the same + heartbeat schedule as production-mode clients. Pings are tagged + `stream="sandbox"` so analytics can distinguish dev pings from + production heartbeat — see the checkpoint-service + `IsValidIncomingStream` allowlist for the wire-side gate. + ## [7.1.0] - 2026-05-06 — X-Axonflow-Client header + scope-aware license validation **Companion release to platform v7.7.0.** The Java SDK now sends an diff --git a/README.md b/README.md index 18a9efc..0a0a6d5 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,14 @@ Three short videos covering different angles of the platform: com.getaxonflow axonflow-sdk - 4.0.0 + 8.0.0 ``` ### Gradle ```groovy -implementation 'com.getaxonflow:axonflow-sdk:4.0.0' +implementation 'com.getaxonflow:axonflow-sdk:8.0.0' ``` ## Evaluation Tier (Free License) @@ -604,6 +604,12 @@ For enterprise features, contact [sales@getaxonflow.com](mailto:sales@getaxonflo This SDK sends anonymous usage telemetry (SDK version, OS, enabled features) to help improve AxonFlow. No prompts, payloads, or PII are ever collected. Opt out: `AXONFLOW_TELEMETRY=off`. +`AXONFLOW_TELEMETRY=off` is the **sole opt-out lever** as of v8.0. The +v7.x `telemetry(Boolean)` config-builder method has been removed; the +previous silent suppression of sandbox-mode pings has also been removed +(sandbox-mode pings now fire and are tagged `stream="sandbox"` so +they're distinguishable from production heartbeat). + ### Scope of `AXONFLOW_TELEMETRY=off` `AXONFLOW_TELEMETRY=off` disables the anonymous SDK heartbeat (version, OS, architecture). On **self-hosted** and **in-VPC** deployments, that heartbeat is the only data the SDK sends to AxonFlow, so setting `=off` means we receive nothing. On **Community SaaS** (`try.getaxonflow.com`) the hosted service also processes operational data — registrations, audit logs, policy enforcement records, workflow state, plan data, and request-header metadata aggregated for usage analytics — as part of running the platform; that operational data flow is governed by the [Privacy Policy](https://getaxonflow.com/privacy/), not by `AXONFLOW_TELEMETRY`. diff --git a/examples/explain-decision/pom.xml b/examples/explain-decision/pom.xml index 10f97b4..f778def 100644 --- a/examples/explain-decision/pom.xml +++ b/examples/explain-decision/pom.xml @@ -25,7 +25,7 @@ com.getaxonflow axonflow-sdk - 7.1.0 + 8.0.0 diff --git a/examples/list-decisions/pom.xml b/examples/list-decisions/pom.xml index 863f773..87c5214 100644 --- a/examples/list-decisions/pom.xml +++ b/examples/list-decisions/pom.xml @@ -24,7 +24,7 @@ com.getaxonflow axonflow-sdk - 7.1.0 + 8.0.0 diff --git a/pom.xml b/pom.xml index d908787..b4cc631 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 7.1.0 + 8.0.0 jar AxonFlow Java SDK diff --git a/runtime-e2e/sandbox_telemetry_stream_tag/README.md b/runtime-e2e/sandbox_telemetry_stream_tag/README.md new file mode 100644 index 0000000..6d6998a --- /dev/null +++ b/runtime-e2e/sandbox_telemetry_stream_tag/README.md @@ -0,0 +1,45 @@ +# Runtime proof — Sandbox-mode telemetry fires with stream=sandbox (Java SDK v8) + +Verifies the v8 contract: a Java SDK client constructed with +`Mode.SANDBOX` produces an anonymous heartbeat ping that lands in +checkpoint DynamoDB with the row tagged `stream="sandbox"`. + +## When to run + +**Post-deploy verification.** Two infrastructure prerequisites: + +1. **`axonflow-enterprise` PR #2005 deployed** — without the server-side + wire-allowlist, the Lambda hardcodes `stream=heartbeat` regardless of + payload, and this test will fail at the assertion step. Confirm with: + ```sh + curl -sS -X POST -H 'Content-Type: application/json' \ + -d '{"sdk":"java","sdk_version":"8.0.0","stream":"community_saas_operational","instance_id":"x"}' \ + https://checkpoint.getaxonflow.com/v1/ping + # Expect HTTP 400 "invalid stream value" + ``` +2. **AWS credentials** with read on `/aws/lambda/prod-axonflow-checkpoint`. + +## Usage + +```sh +AWS_REGION=us-east-1 ./test.sh +``` + +## What it asserts + +1. Builds the local SDK to the Maven local repo via `mvn install -DskipTests`. +2. A small Java consumer that depends on `com.getaxonflow:axonflow-sdk:8.0.0` is + compiled and run. +3. The consumer constructs `AxonFlow.create(AxonFlowConfig.builder() + .endpoint("http://localhost:65530").mode(Mode.SANDBOX)...)` — pointing + at an unreachable port so we exercise the heartbeat ping but not any + platform call. +4. The Lambda's CloudWatch audit log records an `event_stored` row with + `sdk=java/8` AND `stream=sandbox`. + +## Pre-v8 behavior (regression-guard context) + +In v7.x, sandbox-mode clients were silently suppressed by the SDK gate +(`mode != "sandbox"` default-off rule). This test guards against that +hole being re-introduced. If a future refactor restores any mode-based +suppression, this test fires loudly. diff --git a/runtime-e2e/sandbox_telemetry_stream_tag/test.sh b/runtime-e2e/sandbox_telemetry_stream_tag/test.sh new file mode 100755 index 0000000..3626ebb --- /dev/null +++ b/runtime-e2e/sandbox_telemetry_stream_tag/test.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Runtime proof — Java SDK v8 sandbox-mode telemetry fires with stream=sandbox. +# +# Builds the local SDK with Maven (`mvn install -DskipTests`), then a tiny +# consumer that depends on `com.getaxonflow:axonflow-sdk:8.0.0`, constructs +# a Mode.SANDBOX client against an unreachable agent endpoint, and waits for +# the anonymous telemetry ping to fire. We then query the deployed +# checkpoint Lambda's CloudWatch logs for the audit line that should record +# stream=sandbox in DynamoDB. +# +# Pre-v8 this test would have produced ZERO pings (sandbox-mode silent +# suppression). Post-v8 we expect exactly one ping with stream=sandbox. +# +# Stack-state assumptions: +# - axonflow-enterprise PR #2005 is deployed (server-side stream allowlist +# accepts and persists "sandbox" — without that, this row is stored +# as stream=heartbeat, defeating the test's purpose). +# - AWS credentials with read access on /aws/lambda/prod-axonflow-checkpoint. +# +# Usage: +# AWS_REGION=us-east-1 ./test.sh + +set -uo pipefail + +REGION=${AWS_REGION:-us-east-1} +LOG_GROUP=${LOG_GROUP:-/aws/lambda/prod-axonflow-checkpoint} +RUN_TAG=$(date -u +%s) +SDK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } + +# 1. Install the local SDK to the Maven local repo so the consumer can +# resolve `com.getaxonflow:axonflow-sdk:8.0.0` from `~/.m2/repository`. +echo "Installing local SDK to Maven local repo..." +( + cd "$SDK_ROOT" + ./mvnw -q -DskipTests install +) || { + red "FAIL: mvn install of local SDK failed" + exit 1 +} + +# 2. Build a transient consumer pom + main class. The unreachable :65530 +# endpoint is intentional — we only want the anonymous heartbeat to fire, +# not any platform call. +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/pom.xml" <<'EOF' + + + 4.0.0 + com.getaxonflow.runtime-e2e + sandbox-telemetry-stream-tag + 0.0.1 + jar + + + 11 + 11 + UTF-8 + + + + + com.getaxonflow + axonflow-sdk + 8.0.0 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + SandboxRuntimeProof + + + + + +EOF + +mkdir -p "$WORK/src/main/java" +cat > "$WORK/src/main/java/SandboxRuntimeProof.java" <<'EOF' +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.types.Mode; + +public class SandboxRuntimeProof { + public static void main(String[] args) throws Exception { + System.out.println("[runtime-e2e] Constructing Sandbox client (unreachable agent)..."); + AxonFlowConfig config = AxonFlowConfig.builder() + .endpoint("http://localhost:65530") + .clientId("rt-test") + .clientSecret("rt-test") + .mode(Mode.SANDBOX) + .build(); + // Construction triggers the synchronous heartbeat ping to checkpoint. + @SuppressWarnings("unused") + AxonFlow client = AxonFlow.create(config); + System.out.println("[runtime-e2e] AxonFlow.create returned. Sleeping 2s for inflight HTTP..."); + Thread.sleep(2000); + System.out.println("[runtime-e2e] Done."); + } +} +EOF + +T0_MS=$(($(date -u +%s)*1000)) +echo "Run tag: $RUN_TAG" +echo "T0 (ms): $T0_MS" +echo + +# Note: the SDK reads AXONFLOW_TELEMETRY directly via System.getenv, so we +# explicitly clear it for this run. Pre-v8 dev envs commonly had it set to +# `off` to suppress noise. +unset AXONFLOW_TELEMETRY + +( + cd "$WORK" + mvn -q -DskipTests package 2>&1 | tail -3 + mvn -q exec:java 2>&1 +) + +echo +echo "Waiting 10s for CloudWatch log delivery..." +sleep 10 + +# Look for the audit row our run produced — match by sdk=java and a fresh +# correlation_id stamped within the last ~1 minute window. +echo "Querying CloudWatch logs since T0 for sdk=java event_stored entries..." +HITS=$(aws --region "$REGION" logs filter-log-events \ + --log-group-name "$LOG_GROUP" \ + --start-time "$T0_MS" \ + --filter-pattern '"event_stored" "sdk=java/8"' \ + --query 'events[*].message' \ + --output text 2>&1) + +if [ -z "$HITS" ]; then + red "FAIL: no event_stored sdk=java/8 row landed in checkpoint logs since T0" + red " Expected: one audit row tagged stream=sandbox" + red " CloudWatch query window: $T0_MS → now" + exit 1 +fi + +echo "Audit rows found:" +echo "$HITS" +echo + +if echo "$HITS" | grep -q 'stream=sandbox'; then + green "PASS: Java SDK sandbox-mode ping landed with stream=sandbox" +else + red "FAIL: audit row did not include stream=sandbox" + red " This usually means PR #2005 (server-side allowlist) is not yet deployed —" + red " the server still hardcodes stream=heartbeat regardless of payload." + exit 1 +fi diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 10ea461..d85dec9 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -227,18 +227,12 @@ private AxonFlow(AxonFlowConfig config) { * delays a user API call. */ private void invokeHeartbeat() { - boolean hasCredentials = - config.getClientId() != null - && !config.getClientId().isEmpty() - && config.getClientSecret() != null - && !config.getClientSecret().isEmpty(); String modeStr = config.getMode() != null ? config.getMode().getValue() : "production"; String envOptOut = System.getenv("AXONFLOW_TELEMETRY"); - boolean isEnabled = - TelemetryReporter.isEnabled(modeStr, config.getTelemetry(), hasCredentials, envOptOut); - // Two-arg public overload reads AXONFLOW_TELEMETRY=off itself via - // System.getenv. Tests that bypass this method use the package-private - // 3-arg overload to inject the env value directly. + // v8: AXONFLOW_TELEMETRY=off is the SOLE opt-out path. The v7.x mode-based suppression + // and the AxonFlowConfig.telemetry(Boolean) override were both removed. Sandbox-mode + // pings now fire and are tagged stream="sandbox" in the payload. + boolean isEnabled = TelemetryReporter.isEnabled(envOptOut); HeartbeatState.shared() .maybeSendHeartbeat( isEnabled, diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java index 90e44d8..06f7298 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java @@ -106,7 +106,6 @@ private static String detectSdkVersion() { private final RetryConfig retryConfig; private final CacheConfig cacheConfig; private final String userAgent; - private final Boolean telemetry; private final boolean tryMode; private AxonFlowConfig(Builder builder) { @@ -126,7 +125,6 @@ private AxonFlowConfig(Builder builder) { this.cacheConfig = builder.cacheConfig != null ? builder.cacheConfig : CacheConfig.defaults(); this.userAgent = builder.userAgent != null ? builder.userAgent : "axonflow-sdk-java/" + SDK_VERSION; - this.telemetry = builder.telemetry; validate(); } @@ -306,18 +304,6 @@ public String getClientHeader() { return "sdk-java/" + SDK_VERSION; } - /** - * Returns the telemetry config override. - * - *

{@code null} means use the default behavior (ON for production, OFF for sandbox). {@code - * Boolean.TRUE} forces telemetry on, {@code Boolean.FALSE} forces it off. - * - * @return the telemetry override, or null for default behavior - */ - public Boolean getTelemetry() { - return telemetry; - } - public static Builder builder() { return new Builder(); } @@ -370,7 +356,6 @@ public static final class Builder { private RetryConfig retryConfig; private CacheConfig cacheConfig; private String userAgent; - private Boolean telemetry; private Builder() {} @@ -533,23 +518,6 @@ public Builder userAgent(String userAgent) { return this; } - /** - * Sets the telemetry override. - * - *

{@code null} (default) uses the mode-based default: ON for production, OFF for sandbox. - * {@code Boolean.TRUE} forces telemetry on, {@code Boolean.FALSE} forces it off. - * - *

Telemetry can also be disabled globally via environment variable - * {@code AXONFLOW_TELEMETRY=off}. - * - * @param telemetry true to enable, false to disable, null for default behavior - * @return this builder - */ - public Builder telemetry(Boolean telemetry) { - this.telemetry = telemetry; - return this; - } - /** * Builds the configuration. * diff --git a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java index 2fe0687..181b398 100644 --- a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -41,14 +41,12 @@ *

Telemetry is completely anonymous and contains no user data, only SDK version, runtime * environment, and deployment mode information. * - *

Telemetry can be disabled via: - * - *

- * - *

By default, telemetry is OFF in sandbox mode and ON in production mode. + *

{@code AXONFLOW_TELEMETRY=off} in the environment is the SOLE opt-out path as of v8.0. The + * v7.x {@code telemetry(Boolean)} config-builder override has been removed; the previous silent + * suppression of sandbox-mode pings has also been removed. Sandbox-mode pings now fire on the + * same heartbeat schedule as production-mode pings, tagged {@code stream="sandbox"} in the + * payload so analytics can distinguish dev/test pings from production heartbeat (the wire-side + * allowlist is enforced by the checkpoint service — see {@code IsValidIncomingStream}). */ public class TelemetryReporter { @@ -67,25 +65,17 @@ public class TelemetryReporter { private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); /** - * Sends an anonymous telemetry ping asynchronously (fire-and-forget). + * Sends an anonymous telemetry ping synchronously (blocks until the round-trip completes). * * @param mode the deployment mode (e.g. "production", "sandbox") * @param sdkEndpoint the configured SDK endpoint, used to detect platform version via /health - * @param telemetryEnabled config override for telemetry (null = use default based on mode) * @param debug whether debug logging is enabled */ - public static void sendPing( - String mode, - String sdkEndpoint, - Boolean telemetryEnabled, - boolean debug, - boolean hasCredentials) { + public static void sendPing(String mode, String sdkEndpoint, boolean debug) { sendPing( mode, sdkEndpoint, - telemetryEnabled, debug, - hasCredentials, System.getenv("AXONFLOW_TELEMETRY"), System.getenv("AXONFLOW_CHECKPOINT_URL")); } @@ -94,12 +84,10 @@ public static void sendPing( static void sendPing( String mode, String sdkEndpoint, - Boolean telemetryEnabled, boolean debug, - boolean hasCredentials, String axonflowTelemetry, String checkpointUrl) { - if (!isEnabled(mode, telemetryEnabled, hasCredentials, axonflowTelemetry)) { + if (!isEnabled(axonflowTelemetry)) { if (debug) { logger.debug("Telemetry is disabled, skipping ping"); } @@ -178,43 +166,28 @@ public static boolean sendPingNow( } /** - * Determines whether telemetry is enabled based on environment and config. + * Determines whether telemetry is enabled. * - *

Priority order: + *

{@code AXONFLOW_TELEMETRY=off} in the environment is the SOLE opt-out path as of v8.0. + * Telemetry is otherwise ON by default, regardless of mode (sandbox / production / anything + * else). Sandbox-mode pings are tagged {@code stream="sandbox"} in the payload so analytics + * can still distinguish them — see {@link #buildPayload}. * - *

    - *
  1. {@code AXONFLOW_TELEMETRY=off} environment variable disables telemetry (canonical - * AxonFlow-specific opt-out, always wins) - *
  2. Config override ({@code Boolean.TRUE} or {@code Boolean.FALSE}) takes precedence - *
  3. Default: ON for all modes except sandbox - *
+ *

Historical context: v7.x supported a {@code Boolean configOverride} parameter and a + * {@code mode != "sandbox"} default-suppression rule. Both were removed in v8.0 to leave a + * single, ops-controlled opt-out lever and avoid silent suppression that masks real adoption + * signal. See CHANGELOG v8.0.0. * *

{@code DO_NOT_TRACK} is intentionally NOT honored. It is commonly inherited from host * tools and developer environments (CLIs like Codex and Claude Code inject it unconditionally), * which makes it an unreliable expression of user intent for AxonFlow telemetry. * - * @param mode the deployment mode - * @param configOverride explicit config override (null = use default) - * @param hasCredentials whether the client has credentials (kept for API compat, no longer used - * in default logic) + * @param axonflowTelemetry value of {@code AXONFLOW_TELEMETRY} env var (null = unset) * @return true if telemetry should be sent */ - public static boolean isEnabled(String mode, Boolean configOverride, boolean hasCredentials) { - return isEnabled( - mode, configOverride, hasCredentials, System.getenv("AXONFLOW_TELEMETRY")); - } - - /** Package-private for testing. Accepts env var values as parameters. */ - public static boolean isEnabled( - String mode, Boolean configOverride, boolean hasCredentials, String axonflowTelemetry) { - if (axonflowTelemetry != null && "off".equalsIgnoreCase(axonflowTelemetry.trim())) { - return false; - } - if (configOverride != null) { - return configOverride; - } - // Default: ON everywhere except sandbox mode. - return !"sandbox".equals(mode); + public static boolean isEnabled(String axonflowTelemetry) { + // AXONFLOW_TELEMETRY=off is the SOLE opt-out path. + return !(axonflowTelemetry != null && "off".equalsIgnoreCase(axonflowTelemetry.trim())); } /** Builds the JSON payload for the telemetry ping. */ @@ -245,6 +218,15 @@ static String buildPayload(String mode, String platformVersion, String endpointT root.put("instance_id", UUID.randomUUID().toString()); + // Stream classifier: sandbox-mode clients self-tag so analytics can distinguish dev/test + // pings from production. Production-mode (and other modes) omit the field entirely so the + // server defaults to "heartbeat" — preserving byte-identical wire shape relative to v7.x + // for the production-mode case. See CHANGELOG v8.0.0 and checkpoint-service + // IsValidIncomingStream. + if ("sandbox".equals(mode)) { + root.put("stream", "sandbox"); + } + return mapper.writeValueAsString(root); } catch (Exception e) { // Fallback minimal payload diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterShortLivedTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterShortLivedTest.java index 41d13ca..9437015 100644 --- a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterShortLivedTest.java +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterShortLivedTest.java @@ -64,8 +64,6 @@ void sendPingBlocksUntilRoundTripCompletes(WireMockRuntimeInfo info) { TelemetryReporter.sendPing( "production", "", // empty SDK endpoint: skip /health probe so we measure only the POST - Boolean.TRUE, - false, false, null, // AXONFLOW_TELEMETRY checkpointUrl); diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java index 0477c3c..7142533 100644 --- a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java @@ -33,58 +33,29 @@ class TelemetryReporterTest { private final ObjectMapper objectMapper = new ObjectMapper(); - // --- isEnabled tests (using the 4-arg package-private method) --- - // DO_NOT_TRACK is intentionally NOT honored; the 4-arg overload only takes - // AXONFLOW_TELEMETRY. The DNT-related tests below pin that new invariant. + // --- isEnabled tests --- + // v8: AXONFLOW_TELEMETRY=off is the SOLE opt-out signal. The v7.x mode-based default + // suppression and the Boolean configOverride parameter were both removed. + // DO_NOT_TRACK is intentionally NOT honored. @Test - @DisplayName("should disable telemetry when AXONFLOW_TELEMETRY=off") + @DisplayName("AXONFLOW_TELEMETRY=off disables telemetry") void testTelemetryDisabledByAxonflowEnv() { - assertThat(TelemetryReporter.isEnabled("production", null, true, "off")).isFalse(); - assertThat(TelemetryReporter.isEnabled("production", null, true, "OFF")).isFalse(); - assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, true, "off")).isFalse(); + assertThat(TelemetryReporter.isEnabled("off")).isFalse(); + assertThat(TelemetryReporter.isEnabled("OFF")).isFalse(); + assertThat(TelemetryReporter.isEnabled(" off ")).isFalse(); } @Test - @DisplayName("should default telemetry OFF for sandbox mode") - void testTelemetryDefaultOffForSandbox() { - assertThat(TelemetryReporter.isEnabled("sandbox", null, true, null)).isFalse(); - } - - @Test - @DisplayName("should default telemetry ON for production mode with credentials") - void testTelemetryDefaultOnForProductionWithCredentials() { - assertThat(TelemetryReporter.isEnabled("production", null, true, null)).isTrue(); - } - - @Test - @DisplayName("should default telemetry ON for production mode even without credentials") - void testTelemetryDefaultOnForProductionWithoutCredentials() { - assertThat(TelemetryReporter.isEnabled("production", null, false, null)).isTrue(); - } - - @Test - @DisplayName("should default telemetry ON for enterprise mode with credentials") - void testTelemetryDefaultOnForEnterpriseWithCredentials() { - assertThat(TelemetryReporter.isEnabled("enterprise", null, true, null)).isTrue(); - } - - @Test - @DisplayName("should allow config override to enable telemetry in sandbox") - void testTelemetryConfigOverrideEnable() { - assertThat(TelemetryReporter.isEnabled("sandbox", Boolean.TRUE, false, null)).isTrue(); - } - - @Test - @DisplayName("should allow config override to disable telemetry in production") - void testTelemetryConfigOverrideDisable() { - assertThat(TelemetryReporter.isEnabled("production", Boolean.FALSE, true, null)).isFalse(); - } - - @Test - @DisplayName("AXONFLOW_TELEMETRY=off takes precedence over config override") - void testAxonflowTelemetryPrecedence() { - assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, true, "off")).isFalse(); + @DisplayName("v8: telemetry is ON by default for every mode (no env opt-out)") + void testTelemetryOnByDefault() { + // null env (unset) → telemetry is ON. The mode-specific suppression + // that used to disable sandbox-mode pings was removed in v8 — sandbox + // pings now fire and are tagged stream="sandbox" in the payload. + assertThat(TelemetryReporter.isEnabled(null)).isTrue(); + assertThat(TelemetryReporter.isEnabled("")).isTrue(); + assertThat(TelemetryReporter.isEnabled("on")).isTrue(); + assertThat(TelemetryReporter.isEnabled("anything-not-off")).isTrue(); } // --- Payload format test --- @@ -110,6 +81,10 @@ void testPayloadFormat() throws Exception { // instance_id should be a valid UUID format assertThat(root.get("instance_id").asText()) .matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + // v8: production-mode payloads OMIT the `stream` field entirely so the wire shape is + // byte-identical to v7.x for the production-mode case. The server defaults empty/missing + // to "heartbeat". + assertThat(root.has("stream")).isFalse(); } @Test @@ -120,6 +95,33 @@ void testPayloadModeReflection() throws Exception { assertThat(root.get("deployment_mode").asText()).isEqualTo("sandbox"); } + @Test + @DisplayName("v8: sandbox-mode payload carries stream=\"sandbox\"") + void testPayloadStreamTagSandbox() throws Exception { + String payload = TelemetryReporter.buildPayload("sandbox", null); + JsonNode root = objectMapper.readTree(payload); + assertThat(root.get("stream")).isNotNull(); + assertThat(root.get("stream").asText()).isEqualTo("sandbox"); + } + + @Test + @DisplayName("v8: production-mode payload omits the stream field") + void testPayloadStreamTagProductionOmitted() throws Exception { + String payload = TelemetryReporter.buildPayload("production", null); + JsonNode root = objectMapper.readTree(payload); + assertThat(root.has("stream")).isFalse(); + } + + @Test + @DisplayName("v8: enterprise / staging / empty modes also omit the stream field") + void testPayloadStreamTagOtherModesOmitted() throws Exception { + for (String mode : new String[] {"enterprise", "staging", "", "unknown-mode"}) { + String payload = TelemetryReporter.buildPayload(mode, null); + JsonNode root = objectMapper.readTree(payload); + assertThat(root.has("stream")).as("mode=%s should omit stream", mode).isFalse(); + } + } + // --- HTTP integration tests --- @Test @@ -129,13 +131,11 @@ void testCustomEndpoint(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; - // Call sendPing with custom checkpoint URL, no env opt-outs, with credentials + // Call sendPing with custom checkpoint URL, no env opt-outs TelemetryReporter.sendPing( "production", "http://localhost:8080", - Boolean.TRUE, false, - true, // hasCredentials null, // axonflowTelemetry customUrl // checkpointUrl ); @@ -156,6 +156,8 @@ void testCustomEndpoint(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { assertThat(body.get("sdk_version").asText()).isEqualTo(AxonFlowConfig.SDK_VERSION); assertThat(body.get("deployment_mode").asText()).isEqualTo("production"); assertThat(body.get("instance_id").asText()).isNotEmpty(); + // production-mode payloads still omit stream on the wire. + assertThat(body.has("stream")).isFalse(); } @Test @@ -168,9 +170,7 @@ void testNoRequestWhenDisabled(WireMockRuntimeInfo wmRuntimeInfo) throws Excepti TelemetryReporter.sendPing( "production", "http://localhost:8080", - null, false, - true, // hasCredentials "off", // axonflowTelemetry = canonical opt-out customUrl); @@ -180,13 +180,13 @@ void testNoRequestWhenDisabled(WireMockRuntimeInfo wmRuntimeInfo) throws Excepti } @Test - @DisplayName("should STILL send ping when only DO_NOT_TRACK=1 is set in process env (DNT no longer honored)") + @DisplayName("should STILL send ping when only DO_NOT_TRACK=1 is set (DNT no longer honored)") void testRequestSentEvenWithDoNotTrackInProcessEnv(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { // Note: this test passes axonflowTelemetry=null, which is what the public // sendPing wrapper would supply if DO_NOT_TRACK=1 were the only env signal. - // After the DNT removal, the SDK no longer reads DO_NOT_TRACK at all, so a - // null axonflowTelemetry means "no opt-out env" and telemetry should fire. + // The SDK does not read DO_NOT_TRACK at all, so a null axonflowTelemetry + // means "no opt-out env" and telemetry should fire. stubFor(post("/v1/ping").willReturn(ok())); String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; @@ -194,9 +194,7 @@ void testRequestSentEvenWithDoNotTrackInProcessEnv(WireMockRuntimeInfo wmRuntime TelemetryReporter.sendPing( "production", "http://localhost:8080", - null, false, - true, // hasCredentials null, // axonflowTelemetry = not set, telemetry should fire customUrl); @@ -214,9 +212,7 @@ void testSilentFailure() { TelemetryReporter.sendPing( "production", "http://localhost:8080", - null, false, - true, // hasCredentials null, "http://127.0.0.1:1" // port 1 - connection refused ); @@ -228,29 +224,13 @@ void testSilentFailure() { } @Test - @DisplayName("should not send ping in sandbox mode without explicit enable") - void testSandboxModeDefaultOff(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { - stubFor(post("/v1/ping").willReturn(ok())); - - String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; - - TelemetryReporter.sendPing( - "sandbox", - "http://localhost:8080", - null, // no override - false, - true, // hasCredentials - null, - customUrl); - - Thread.sleep(1000); - - verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); - } - - @Test - @DisplayName("should send ping in sandbox mode when explicitly enabled via config") - void testSandboxModeExplicitEnable(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + @DisplayName( + "v8: ping fires in sandbox mode AND payload carries stream=\"sandbox\"") + void shouldFirePingWithStreamSandboxInSandboxMode(WireMockRuntimeInfo wmRuntimeInfo) + throws Exception { + // v8 contract: sandbox-mode clients fire telemetry (v7 silently suppressed them) and + // tag their payload with stream="sandbox" so analytics can distinguish dev/test pings + // from production heartbeat. This is the headline behavioral flip. stubFor(post("/v1/ping").willReturn(ok())); String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; @@ -258,15 +238,21 @@ void testSandboxModeExplicitEnable(WireMockRuntimeInfo wmRuntimeInfo) throws Exc TelemetryReporter.sendPing( "sandbox", "http://localhost:8080", - Boolean.TRUE, // explicit enable false, - false, // hasCredentials (doesn't matter with explicit override) - null, + null, // no env opt-out customUrl); Thread.sleep(2000); + // Both the ping fires AND the stream tag is on the wire. verify(exactly(1), postRequestedFor(urlEqualTo("/v1/ping"))); + + var requests = WireMock.findAll(postRequestedFor(urlEqualTo("/v1/ping"))); + assertThat(requests).hasSize(1); + JsonNode body = objectMapper.readTree(requests.get(0).getBodyAsString()); + assertThat(body.get("deployment_mode").asText()).isEqualTo("sandbox"); + assertThat(body.get("stream")).isNotNull(); + assertThat(body.get("stream").asText()).isEqualTo("sandbox"); } @Test @@ -276,13 +262,10 @@ void testProductionModeWithoutCredentials(WireMockRuntimeInfo wmRuntimeInfo) thr String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; - // telemetryEnabled=true: explicit enable for this test TelemetryReporter.sendPing( "production", "http://localhost:8080", - Boolean.TRUE, false, - false, // no credentials — no longer affects default null, customUrl); @@ -319,26 +302,10 @@ void testUniqueInstanceId() throws Exception { assertThat(id2).isNotEqualTo(id3); } - @Test - @DisplayName("config false in production should skip POST even with credentials") - void testConfigDisableInProduction(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { - stubFor(post("/v1/ping").willReturn(ok())); - - String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; - - TelemetryReporter.sendPing( - "production", - "http://localhost:8080", - Boolean.FALSE, // config override disables - false, - true, // hasCredentials (would normally enable) - null, - customUrl); - - Thread.sleep(1000); - - verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); - } + // testConfigDisableInProduction and testSandboxModeDefaultOff were removed in v8.0 along + // with the AxonFlowConfig.telemetry(Boolean) builder method and the mode-based default + // suppression. AXONFLOW_TELEMETRY=off is the SOLE opt-out path; programmatic suppression + // is no longer supported. See CHANGELOG v8.0.0. @Test @DisplayName("should silently handle server timeout without crashing") @@ -353,9 +320,7 @@ void testSilentFailureOnTimeout(WireMockRuntimeInfo wmRuntimeInfo) { TelemetryReporter.sendPing( "production", "http://localhost:8080", - null, false, - true, // hasCredentials null, customUrl); @@ -372,15 +337,12 @@ void testNon200ResponseNoCrash(WireMockRuntimeInfo wmRuntimeInfo) { String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; - // telemetryEnabled=true: explicit enable for this test assertThatCode( () -> { TelemetryReporter.sendPing( "production", "http://localhost:8080", - Boolean.TRUE, false, - true, // hasCredentials null, customUrl); @@ -394,7 +356,7 @@ void testNon200ResponseNoCrash(WireMockRuntimeInfo wmRuntimeInfo) { } @Test - @DisplayName("AXONFLOW_TELEMETRY=off should skip POST even with credentials in production") + @DisplayName("AXONFLOW_TELEMETRY=off should skip POST in production") void testAxonflowTelemetrySkipsPost(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { stubFor(post("/v1/ping").willReturn(ok())); @@ -403,9 +365,7 @@ void testAxonflowTelemetrySkipsPost(WireMockRuntimeInfo wmRuntimeInfo) throws Ex TelemetryReporter.sendPing( "production", "http://localhost:8080", - null, false, - true, // hasCredentials "off", // AXONFLOW_TELEMETRY=off customUrl); @@ -421,15 +381,12 @@ void testPayloadDeploymentModeEnterprise(WireMockRuntimeInfo wmRuntimeInfo) thro String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; - // telemetryEnabled=true: explicit enable for this test // Use localhost:1 so detectPlatformVersion gets immediate connection-refused // (localhost:8080 may have a running service that returns a version) TelemetryReporter.sendPing( "enterprise", "http://localhost:1", - Boolean.TRUE, false, - true, // hasCredentials null, customUrl); @@ -456,5 +413,7 @@ void testPayloadDeploymentModeEnterprise(WireMockRuntimeInfo wmRuntimeInfo) thro assertThat(body.get("features").isArray()).isTrue(); assertThat(body.get("instance_id").asText()) .matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + // enterprise mode is not "sandbox", so stream is omitted + assertThat(body.has("stream")).isFalse(); } } From d8b84a1538b3567ca6cfa8c2e0ed89527283eb3b Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 8 May 2026 12:22:22 +0200 Subject: [PATCH 2/2] chore: retrigger CI after transient CodeQL ECONNRESET Signed-off-by: Saurabh Jain