From d726fe83d210691aefdeae5dac403f9a6a067b12 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sun, 1 Mar 2026 14:46:03 +0530 Subject: [PATCH 01/12] feat: version discovery, User-Agent fix, capability detection - Add SDK_VERSION constant (3.8.0) in AxonFlowConfig - Fix default User-Agent from axonflow-java-sdk/1.0.0 to axonflow-sdk-java/3.8.0 - Extend HealthStatus with capabilities and sdkCompatibility fields - Add PlatformCapability and SDKCompatibility types - Add hasCapability() method on HealthStatus - Log warning when SDK version is below platform min_sdk_version - Update tests for new HealthStatus constructor --- .../java/com/getaxonflow/sdk/AxonFlow.java | 13 ++++- .../com/getaxonflow/sdk/AxonFlowConfig.java | 5 +- .../getaxonflow/sdk/types/HealthStatus.java | 42 +++++++++++++++- .../sdk/types/PlatformCapability.java | 48 +++++++++++++++++++ .../sdk/types/SDKCompatibility.java | 42 ++++++++++++++++ .../getaxonflow/sdk/AxonFlowConfigTest.java | 2 +- .../sdk/types/AdditionalTypesTest.java | 28 ++++++----- .../getaxonflow/sdk/types/MoreTypesTest.java | 20 ++++---- 8 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 3c371bb..8cbd359 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -206,12 +206,21 @@ public static AxonFlow sandbox(String agentUrl) { * @throws ConnectionException if the Agent cannot be reached */ public HealthStatus healthCheck() { - return retryExecutor.execute(() -> { + HealthStatus status = retryExecutor.execute(() -> { Request request = buildRequest("GET", "/health", null); try (Response response = httpClient.newCall(request).execute()) { return parseResponse(response, HealthStatus.class); } }, "healthCheck"); + + if (status.getSdkCompatibility() != null + && status.getSdkCompatibility().getMinSdkVersion() != null + && AxonFlowConfig.SDK_VERSION.compareTo(status.getSdkCompatibility().getMinSdkVersion()) < 0) { + logger.warn("SDK version {} is below minimum supported version {}. Please upgrade.", + AxonFlowConfig.SDK_VERSION, status.getSdkCompatibility().getMinSdkVersion()); + } + + return status; } /** @@ -265,7 +274,7 @@ public HealthStatus orchestratorHealthCheck() { Request httpRequest = buildOrchestratorRequest("GET", "/health", null); try (Response response = httpClient.newCall(httpRequest).execute()) { if (!response.isSuccessful()) { - return new HealthStatus("unhealthy", null, null, null); + return new HealthStatus("unhealthy", null, null, null, null, null); } return parseResponse(response, HealthStatus.class); } diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java index 4d85a88..ca78475 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java @@ -42,6 +42,9 @@ */ public final class AxonFlowConfig { + /** SDK version string. */ + public static final String SDK_VERSION = "3.8.0"; + /** Default timeout for HTTP requests. */ public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(60); @@ -69,7 +72,7 @@ private AxonFlowConfig(Builder builder) { this.insecureSkipVerify = builder.insecureSkipVerify; this.retryConfig = builder.retryConfig != null ? builder.retryConfig : RetryConfig.defaults(); this.cacheConfig = builder.cacheConfig != null ? builder.cacheConfig : CacheConfig.defaults(); - this.userAgent = builder.userAgent != null ? builder.userAgent : "axonflow-java-sdk/1.0.0"; + this.userAgent = builder.userAgent != null ? builder.userAgent : "axonflow-sdk-java/" + SDK_VERSION; validate(); } diff --git a/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java b/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java index 14824c4..c7bf4e6 100644 --- a/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java +++ b/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -40,15 +41,25 @@ public final class HealthStatus { @JsonProperty("components") private final Map components; + @JsonProperty("capabilities") + private final List capabilities; + + @JsonProperty("sdk_compatibility") + private final SDKCompatibility sdkCompatibility; + public HealthStatus( @JsonProperty("status") String status, @JsonProperty("version") String version, @JsonProperty("uptime") String uptime, - @JsonProperty("components") Map components) { + @JsonProperty("components") Map components, + @JsonProperty("capabilities") List capabilities, + @JsonProperty("sdk_compatibility") SDKCompatibility sdkCompatibility) { this.status = status; this.version = version; this.uptime = uptime; this.components = components != null ? Collections.unmodifiableMap(components) : Collections.emptyMap(); + this.capabilities = capabilities; + this.sdkCompatibility = sdkCompatibility; } /** @@ -87,6 +98,24 @@ public Map getComponents() { return components; } + /** + * Returns the list of capabilities advertised by the platform. + * + * @return the capabilities list, or null if not provided + */ + public List getCapabilities() { + return capabilities; + } + + /** + * Returns SDK compatibility information from the platform. + * + * @return the SDK compatibility info, or null if not provided + */ + public SDKCompatibility getSdkCompatibility() { + return sdkCompatibility; + } + /** * Checks if the Agent is healthy. * @@ -96,6 +125,17 @@ public boolean isHealthy() { return "healthy".equalsIgnoreCase(status) || "ok".equalsIgnoreCase(status); } + /** + * Checks if the platform advertises a given capability by name. + * + * @param name the capability name to check + * @return true if the capability is present + */ + public boolean hasCapability(String name) { + if (capabilities == null) return false; + return capabilities.stream().anyMatch(c -> name.equals(c.getName())); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java b/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java new file mode 100644 index 0000000..8270897 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a capability advertised by the AxonFlow platform. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PlatformCapability { + + @JsonProperty("name") + private final String name; + + @JsonProperty("since") + private final String since; + + @JsonProperty("description") + private final String description; + + public PlatformCapability( + @JsonProperty("name") String name, + @JsonProperty("since") String since, + @JsonProperty("description") String description) { + this.name = name; + this.since = since; + this.description = description; + } + + public String getName() { return name; } + public String getSince() { return since; } + public String getDescription() { return description; } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java b/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java new file mode 100644 index 0000000..3d386ec --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * SDK compatibility information returned by the AxonFlow platform health endpoint. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class SDKCompatibility { + + @JsonProperty("min_sdk_version") + private final String minSdkVersion; + + @JsonProperty("recommended_sdk_version") + private final String recommendedSdkVersion; + + public SDKCompatibility( + @JsonProperty("min_sdk_version") String minSdkVersion, + @JsonProperty("recommended_sdk_version") String recommendedSdkVersion) { + this.minSdkVersion = minSdkVersion; + this.recommendedSdkVersion = recommendedSdkVersion; + } + + public String getMinSdkVersion() { return minSdkVersion; } + public String getRecommendedSdkVersion() { return recommendedSdkVersion; } +} diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowConfigTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowConfigTest.java index e8820d4..07c4ef9 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowConfigTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowConfigTest.java @@ -171,7 +171,7 @@ void shouldUseDefaultUserAgent() { .endpoint("http://localhost:8080") .build(); - assertThat(config.getUserAgent()).startsWith("axonflow-java-sdk/"); + assertThat(config.getUserAgent()).startsWith("axonflow-sdk-java/"); } @Test diff --git a/src/test/java/com/getaxonflow/sdk/types/AdditionalTypesTest.java b/src/test/java/com/getaxonflow/sdk/types/AdditionalTypesTest.java index a88ce5b..2a22305 100644 --- a/src/test/java/com/getaxonflow/sdk/types/AdditionalTypesTest.java +++ b/src/test/java/com/getaxonflow/sdk/types/AdditionalTypesTest.java @@ -193,7 +193,9 @@ void testHealthStatusCreation() { "healthy", "1.0.0", "24h30m", - components + components, + null, + null ); assertThat(status.getStatus()).isEqualTo("healthy"); @@ -205,7 +207,7 @@ void testHealthStatusCreation() { @Test @DisplayName("Should handle null components") void testHealthStatusNullComponents() { - HealthStatus status = new HealthStatus("healthy", "1.0.0", "1h", null); + HealthStatus status = new HealthStatus("healthy", "1.0.0", "1h", null, null, null); assertThat(status.getComponents()).isEmpty(); } @@ -213,38 +215,38 @@ void testHealthStatusNullComponents() { @Test @DisplayName("isHealthy should return true for healthy status") void testIsHealthyTrue() { - HealthStatus status1 = new HealthStatus("healthy", null, null, null); + HealthStatus status1 = new HealthStatus("healthy", null, null, null, null, null); assertThat(status1.isHealthy()).isTrue(); - HealthStatus status2 = new HealthStatus("HEALTHY", null, null, null); + HealthStatus status2 = new HealthStatus("HEALTHY", null, null, null, null, null); assertThat(status2.isHealthy()).isTrue(); - HealthStatus status3 = new HealthStatus("ok", null, null, null); + HealthStatus status3 = new HealthStatus("ok", null, null, null, null, null); assertThat(status3.isHealthy()).isTrue(); - HealthStatus status4 = new HealthStatus("OK", null, null, null); + HealthStatus status4 = new HealthStatus("OK", null, null, null, null, null); assertThat(status4.isHealthy()).isTrue(); } @Test @DisplayName("isHealthy should return false for unhealthy status") void testIsHealthyFalse() { - HealthStatus status1 = new HealthStatus("unhealthy", null, null, null); + HealthStatus status1 = new HealthStatus("unhealthy", null, null, null, null, null); assertThat(status1.isHealthy()).isFalse(); - HealthStatus status2 = new HealthStatus("degraded", null, null, null); + HealthStatus status2 = new HealthStatus("degraded", null, null, null, null, null); assertThat(status2.isHealthy()).isFalse(); - HealthStatus status3 = new HealthStatus(null, null, null, null); + HealthStatus status3 = new HealthStatus(null, null, null, null, null, null); assertThat(status3.isHealthy()).isFalse(); } @Test @DisplayName("equals and hashCode should work correctly") void testHealthStatusEqualsHashCode() { - HealthStatus status1 = new HealthStatus("healthy", "1.0.0", "1h", null); - HealthStatus status2 = new HealthStatus("healthy", "1.0.0", "1h", null); - HealthStatus status3 = new HealthStatus("unhealthy", "1.0.0", "1h", null); + HealthStatus status1 = new HealthStatus("healthy", "1.0.0", "1h", null, null, null); + HealthStatus status2 = new HealthStatus("healthy", "1.0.0", "1h", null, null, null); + HealthStatus status3 = new HealthStatus("unhealthy", "1.0.0", "1h", null, null, null); assertThat(status1).isEqualTo(status2); assertThat(status1.hashCode()).isEqualTo(status2.hashCode()); @@ -257,7 +259,7 @@ void testHealthStatusEqualsHashCode() { @Test @DisplayName("toString should include relevant fields") void testHealthStatusToString() { - HealthStatus status = new HealthStatus("healthy", "1.0.0", "1h", null); + HealthStatus status = new HealthStatus("healthy", "1.0.0", "1h", null, null, null); String str = status.toString(); assertThat(str).contains("healthy"); diff --git a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java index 97d7537..3f6082c 100644 --- a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java +++ b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java @@ -978,7 +978,7 @@ void shouldCreateWithAllFields() { components.put("database", "healthy"); components.put("cache", "healthy"); - HealthStatus status = new HealthStatus("healthy", "2.6.0", "24h5m", components); + HealthStatus status = new HealthStatus("healthy", "2.6.0", "24h5m", components, null, null); assertThat(status.getStatus()).isEqualTo("healthy"); assertThat(status.getVersion()).isEqualTo("2.6.0"); @@ -989,17 +989,17 @@ void shouldCreateWithAllFields() { @Test @DisplayName("should handle null components") void shouldHandleNullComponents() { - HealthStatus status = new HealthStatus("healthy", "1.0.0", "1h", null); + HealthStatus status = new HealthStatus("healthy", "1.0.0", "1h", null, null, null); assertThat(status.getComponents()).isEmpty(); } @Test @DisplayName("should detect healthy status") void shouldDetectHealthyStatus() { - HealthStatus healthy = new HealthStatus("healthy", "1.0", "1h", null); - HealthStatus ok = new HealthStatus("ok", "1.0", "1h", null); - HealthStatus degraded = new HealthStatus("degraded", "1.0", "1h", null); - HealthStatus unhealthy = new HealthStatus("unhealthy", "1.0", "1h", null); + HealthStatus healthy = new HealthStatus("healthy", "1.0", "1h", null, null, null); + HealthStatus ok = new HealthStatus("ok", "1.0", "1h", null, null, null); + HealthStatus degraded = new HealthStatus("degraded", "1.0", "1h", null, null, null); + HealthStatus unhealthy = new HealthStatus("unhealthy", "1.0", "1h", null, null, null); assertThat(healthy.isHealthy()).isTrue(); assertThat(ok.isHealthy()).isTrue(); @@ -1026,9 +1026,9 @@ void shouldDeserializeFromJson() throws Exception { @Test @DisplayName("should implement equals and hashCode") void shouldImplementEqualsAndHashCode() { - HealthStatus s1 = new HealthStatus("healthy", "1.0", "1h", null); - HealthStatus s2 = new HealthStatus("healthy", "1.0", "1h", null); - HealthStatus s3 = new HealthStatus("degraded", "1.0", "1h", null); + HealthStatus s1 = new HealthStatus("healthy", "1.0", "1h", null, null, null); + HealthStatus s2 = new HealthStatus("healthy", "1.0", "1h", null, null, null); + HealthStatus s3 = new HealthStatus("degraded", "1.0", "1h", null, null, null); assertThat(s1).isEqualTo(s2); assertThat(s1.hashCode()).isEqualTo(s2.hashCode()); @@ -1038,7 +1038,7 @@ void shouldImplementEqualsAndHashCode() { @Test @DisplayName("should have toString") void shouldHaveToString() { - HealthStatus status = new HealthStatus("healthy", "2.0.0", "5h", null); + HealthStatus status = new HealthStatus("healthy", "2.0.0", "5h", null, null, null); assertThat(status.toString()).contains("HealthStatus").contains("healthy"); } } From 53143ab2ac7e60e6aa7d50e899bc466c9ad4a4ee Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sun, 1 Mar 2026 15:41:05 +0530 Subject: [PATCH 02/12] docs: add v3.8.0 changelog entry --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13cf6f3..34bf029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ 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). +## [3.8.0] - TBD + +### Added + +- `healthCheck()` now returns `capabilities` list and `sdkCompatibility` in `HealthStatus` +- `hasCapability(name)` method on `HealthStatus` to check if platform supports a specific feature +- `SDK_VERSION` constant on `AxonFlowConfig` for programmatic SDK version access +- User-Agent header corrected from `axonflow-java-sdk/1.0.0` to `axonflow-sdk-java/{version}` +- Version mismatch warning logged when SDK version is below platform's `min_sdk_version` +- `PlatformCapability` and `SDKCompatibility` types + +### Fixed + +- Default User-Agent was hardcoded to `1.0.0` regardless of actual SDK version + +--- + ## [3.7.0] - 2026-02-28 ### Added From 811ddfb4e9e75b512e2323ad70cf210baef93fc3 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sun, 1 Mar 2026 15:53:34 +0530 Subject: [PATCH 03/12] fix: use semantic version comparison for mismatch warning --- .../java/com/getaxonflow/sdk/AxonFlow.java | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 8cbd359..1c769e9 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -149,6 +149,38 @@ private static ObjectMapper createObjectMapper() { return mapper; } + /** + * Compares two semantic version strings numerically (major.minor.patch). + * Returns negative if a < b, zero if equal, positive if a > b. + */ + private static int compareSemver(String a, String b) { + String[] partsA = a.split("\\."); + String[] partsB = b.split("\\."); + int length = Math.max(partsA.length, partsB.length); + for (int i = 0; i < length; i++) { + int numA = 0; + int numB = 0; + if (i < partsA.length) { + try { + numA = Integer.parseInt(partsA[i]); + } catch (NumberFormatException ignored) { + // default to 0 + } + } + if (i < partsB.length) { + try { + numB = Integer.parseInt(partsB[i]); + } catch (NumberFormatException ignored) { + // default to 0 + } + } + if (numA != numB) { + return Integer.compare(numA, numB); + } + } + return 0; + } + // ======================================================================== // Factory Methods // ======================================================================== @@ -215,7 +247,7 @@ public HealthStatus healthCheck() { if (status.getSdkCompatibility() != null && status.getSdkCompatibility().getMinSdkVersion() != null - && AxonFlowConfig.SDK_VERSION.compareTo(status.getSdkCompatibility().getMinSdkVersion()) < 0) { + && compareSemver(AxonFlowConfig.SDK_VERSION, status.getSdkCompatibility().getMinSdkVersion()) < 0) { logger.warn("SDK version {} is below minimum supported version {}. Please upgrade.", AxonFlowConfig.SDK_VERSION, status.getSdkCompatibility().getMinSdkVersion()); } From bec0e9201d86c756db8cb7e2dc4e19de3abf7638 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sun, 1 Mar 2026 16:08:23 +0530 Subject: [PATCH 04/12] feat: add trace_id and ToolContext to workflow types --- .../java/com/getaxonflow/sdk/AxonFlow.java | 3 + .../sdk/types/workflow/WorkflowTypes.java | 131 ++++++++++++++++-- 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 3c371bb..f937af8 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -4303,6 +4303,9 @@ public com.getaxonflow.sdk.types.workflow.WorkflowTypes.ListWorkflowsResponse li if (options.getOffset() > 0) { appendQueryParam(query, "offset", String.valueOf(options.getOffset())); } + if (options.getTraceId() != null) { + appendQueryParam(query, "trace_id", options.getTraceId()); + } } if (query.length() > 0) { diff --git a/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java b/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java index a68ba75..3ebc2e6 100644 --- a/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java +++ b/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java @@ -23,6 +23,7 @@ import java.time.Instant; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -230,16 +231,21 @@ public static final class CreateWorkflowRequest { @JsonProperty("metadata") private final Map metadata; + @JsonProperty("trace_id") + private final String traceId; + @JsonCreator public CreateWorkflowRequest( @JsonProperty("workflow_name") String workflowName, @JsonProperty("source") WorkflowSource source, @JsonProperty("total_steps") Integer totalSteps, - @JsonProperty("metadata") Map metadata) { + @JsonProperty("metadata") Map metadata, + @JsonProperty("trace_id") String traceId) { this.workflowName = Objects.requireNonNull(workflowName, "workflowName is required"); this.source = source != null ? source : WorkflowSource.EXTERNAL; this.totalSteps = totalSteps; this.metadata = metadata != null ? Collections.unmodifiableMap(metadata) : Collections.emptyMap(); + this.traceId = traceId; } public String getWorkflowName() { @@ -258,6 +264,10 @@ public Map getMetadata() { return metadata; } + public String getTraceId() { + return traceId; + } + public static Builder builder() { return new Builder(); } @@ -267,6 +277,7 @@ public static final class Builder { private WorkflowSource source = WorkflowSource.EXTERNAL; private Integer totalSteps; private Map metadata; + private String traceId; public Builder workflowName(String workflowName) { this.workflowName = workflowName; @@ -288,8 +299,13 @@ public Builder metadata(Map metadata) { return this; } + public Builder traceId(String traceId) { + this.traceId = traceId; + return this; + } + public CreateWorkflowRequest build() { - return new CreateWorkflowRequest(workflowName, source, totalSteps, metadata); + return new CreateWorkflowRequest(workflowName, source, totalSteps, metadata, traceId); } } } @@ -315,18 +331,23 @@ public static final class CreateWorkflowResponse { @JsonProperty("created_at") private final Instant createdAt; + @JsonProperty("trace_id") + private final String traceId; + @JsonCreator public CreateWorkflowResponse( @JsonProperty("workflow_id") String workflowId, @JsonProperty("workflow_name") String workflowName, @JsonProperty("source") WorkflowSource source, @JsonProperty("status") WorkflowStatus status, - @JsonProperty("created_at") Instant createdAt) { + @JsonProperty("created_at") Instant createdAt, + @JsonProperty("trace_id") String traceId) { this.workflowId = workflowId; this.workflowName = workflowName; this.source = source; this.status = status; this.createdAt = createdAt; + this.traceId = traceId; } public String getWorkflowId() { @@ -348,6 +369,64 @@ public WorkflowStatus getStatus() { public Instant getCreatedAt() { return createdAt; } + + public String getTraceId() { + return traceId; + } + } + + /** + * Tool-level context for per-tool governance within tool_call steps. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class ToolContext { + + @JsonProperty("tool_name") + private final String toolName; + + @JsonProperty("tool_type") + private final String toolType; + + @JsonProperty("tool_input") + private final Map toolInput; + + private ToolContext(Builder builder) { + this.toolName = builder.toolName; + this.toolType = builder.toolType; + this.toolInput = builder.toolInput != null ? Collections.unmodifiableMap(new HashMap<>(builder.toolInput)) : null; + } + + @JsonCreator + public ToolContext( + @JsonProperty("tool_name") String toolName, + @JsonProperty("tool_type") String toolType, + @JsonProperty("tool_input") Map toolInput) { + this.toolName = toolName; + this.toolType = toolType; + this.toolInput = toolInput != null ? Collections.unmodifiableMap(new HashMap<>(toolInput)) : null; + } + + public String getToolName() { return toolName; } + public String getToolType() { return toolType; } + public Map getToolInput() { return toolInput; } + + public static Builder builder(String toolName) { + return new Builder(toolName); + } + + public static final class Builder { + private final String toolName; + private String toolType; + private Map toolInput; + + public Builder(String toolName) { + this.toolName = Objects.requireNonNull(toolName, "toolName must not be null"); + } + + public Builder toolType(String toolType) { this.toolType = toolType; return this; } + public Builder toolInput(Map toolInput) { this.toolInput = toolInput; return this; } + public ToolContext build() { return new ToolContext(this); } + } } /** @@ -371,18 +450,23 @@ public static final class StepGateRequest { @JsonProperty("provider") private final String provider; + @JsonProperty("tool_context") + private final ToolContext toolContext; + @JsonCreator public StepGateRequest( @JsonProperty("step_name") String stepName, @JsonProperty("step_type") StepType stepType, @JsonProperty("step_input") Map stepInput, @JsonProperty("model") String model, - @JsonProperty("provider") String provider) { + @JsonProperty("provider") String provider, + @JsonProperty("tool_context") ToolContext toolContext) { this.stepName = stepName; this.stepType = Objects.requireNonNull(stepType, "stepType is required"); this.stepInput = stepInput != null ? Collections.unmodifiableMap(stepInput) : Collections.emptyMap(); this.model = model; this.provider = provider; + this.toolContext = toolContext; } public String getStepName() { @@ -405,6 +489,10 @@ public String getProvider() { return provider; } + public ToolContext getToolContext() { + return toolContext; + } + public static Builder builder() { return new Builder(); } @@ -415,6 +503,7 @@ public static final class Builder { private Map stepInput; private String model; private String provider; + private ToolContext toolContext; public Builder stepName(String stepName) { this.stepName = stepName; @@ -441,8 +530,13 @@ public Builder provider(String provider) { return this; } + public Builder toolContext(ToolContext toolContext) { + this.toolContext = toolContext; + return this; + } + public StepGateRequest build() { - return new StepGateRequest(stepName, stepType, stepInput, model, provider); + return new StepGateRequest(stepName, stepType, stepInput, model, provider, toolContext); } } } @@ -679,6 +773,9 @@ public static final class WorkflowStatusResponse { @JsonProperty("steps") private final List steps; + @JsonProperty("trace_id") + private final String traceId; + @JsonCreator public WorkflowStatusResponse( @JsonProperty("workflow_id") String workflowId, @@ -689,7 +786,8 @@ public WorkflowStatusResponse( @JsonProperty("total_steps") Integer totalSteps, @JsonProperty("started_at") Instant startedAt, @JsonProperty("completed_at") Instant completedAt, - @JsonProperty("steps") List steps) { + @JsonProperty("steps") List steps, + @JsonProperty("trace_id") String traceId) { this.workflowId = workflowId; this.workflowName = workflowName; this.source = source; @@ -699,6 +797,7 @@ public WorkflowStatusResponse( this.startedAt = startedAt; this.completedAt = completedAt; this.steps = steps != null ? Collections.unmodifiableList(steps) : Collections.emptyList(); + this.traceId = traceId; } public String getWorkflowId() { @@ -737,6 +836,10 @@ public List getSteps() { return steps; } + public String getTraceId() { + return traceId; + } + public boolean isTerminal() { return status == WorkflowStatus.COMPLETED || status == WorkflowStatus.ABORTED || @@ -753,12 +856,14 @@ public static final class ListWorkflowsOptions { private final WorkflowSource source; private final int limit; private final int offset; + private final String traceId; - public ListWorkflowsOptions(WorkflowStatus status, WorkflowSource source, int limit, int offset) { + public ListWorkflowsOptions(WorkflowStatus status, WorkflowSource source, int limit, int offset, String traceId) { this.status = status; this.source = source; this.limit = limit > 0 ? limit : 50; this.offset = Math.max(offset, 0); + this.traceId = traceId; } public WorkflowStatus getStatus() { @@ -777,6 +882,10 @@ public int getOffset() { return offset; } + public String getTraceId() { + return traceId; + } + public static Builder builder() { return new Builder(); } @@ -786,6 +895,7 @@ public static final class Builder { private WorkflowSource source; private int limit = 50; private int offset = 0; + private String traceId; public Builder status(WorkflowStatus status) { this.status = status; @@ -807,8 +917,13 @@ public Builder offset(int offset) { return this; } + public Builder traceId(String traceId) { + this.traceId = traceId; + return this; + } + public ListWorkflowsOptions build() { - return new ListWorkflowsOptions(status, source, limit, offset); + return new ListWorkflowsOptions(status, source, limit, offset, traceId); } } } From eafccfab98079cf9a726304b71a4bc07cfd28ee6 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sun, 1 Mar 2026 17:46:41 +0530 Subject: [PATCH 05/12] fix: address code review findings P1: Add backward-compatible constructor overloads for HealthStatus (4-arg) and workflow types (CreateWorkflowRequest, CreateWorkflowResponse, StepGateRequest, WorkflowStatusResponse, ListWorkflowsOptions) to avoid breaking callers that don't use new traceId/toolContext parameters. P1: Make capabilities list immutable and never-null in HealthStatus constructor. Add null guard to hasCapability() to prevent NPE. P2: Make PlatformCapability and SDKCompatibility final. Add equals/hashCode/toString to both. Update HealthStatus equals/hashCode/toString to include capabilities and sdkCompatibility. P2: Strip pre-release suffixes in compareSemver before parsing to handle versions like "3.8.0-beta.1" correctly. Changelog: Add traceId, ToolContext, and toolContext entries to [3.8.0]. --- CHANGELOG.md | 4 ++ .../java/com/getaxonflow/sdk/AxonFlow.java | 6 ++- .../getaxonflow/sdk/types/HealthStatus.java | 21 ++++++--- .../sdk/types/PlatformCapability.java | 22 ++++++++- .../sdk/types/SDKCompatibility.java | 22 ++++++++- .../sdk/types/workflow/WorkflowTypes.java | 45 +++++++++++++++++++ 6 files changed, 111 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bf029..457bd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - User-Agent header corrected from `axonflow-java-sdk/1.0.0` to `axonflow-sdk-java/{version}` - Version mismatch warning logged when SDK version is below platform's `min_sdk_version` - `PlatformCapability` and `SDKCompatibility` types +- `traceId` field on `CreateWorkflowRequest`, `CreateWorkflowResponse`, `WorkflowStatusResponse`, and `ListWorkflowsOptions` for distributed tracing correlation +- `ToolContext` type for per-tool governance within workflow steps +- `toolContext` field on `StepGateRequest` for tool-level policy enforcement +- `listWorkflows()` now supports `traceId` filter parameter ### Fixed diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 1c1a3fc..cd9a31e 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -162,14 +162,16 @@ private static int compareSemver(String a, String b) { int numB = 0; if (i < partsA.length) { try { - numA = Integer.parseInt(partsA[i]); + String cleanA = partsA[i].contains("-") ? partsA[i].substring(0, partsA[i].indexOf("-")) : partsA[i]; + numA = Integer.parseInt(cleanA); } catch (NumberFormatException ignored) { // default to 0 } } if (i < partsB.length) { try { - numB = Integer.parseInt(partsB[i]); + String cleanB = partsB[i].contains("-") ? partsB[i].substring(0, partsB[i].indexOf("-")) : partsB[i]; + numB = Integer.parseInt(cleanB); } catch (NumberFormatException ignored) { // default to 0 } diff --git a/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java b/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java index c7bf4e6..052297a 100644 --- a/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java +++ b/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java @@ -47,6 +47,13 @@ public final class HealthStatus { @JsonProperty("sdk_compatibility") private final SDKCompatibility sdkCompatibility; + /** + * Backward-compatible constructor without capabilities and sdkCompatibility. + */ + public HealthStatus(String status, String version, String uptime, Map components) { + this(status, version, uptime, components, null, null); + } + public HealthStatus( @JsonProperty("status") String status, @JsonProperty("version") String version, @@ -58,7 +65,7 @@ public HealthStatus( this.version = version; this.uptime = uptime; this.components = components != null ? Collections.unmodifiableMap(components) : Collections.emptyMap(); - this.capabilities = capabilities; + this.capabilities = capabilities != null ? Collections.unmodifiableList(capabilities) : Collections.emptyList(); this.sdkCompatibility = sdkCompatibility; } @@ -101,7 +108,7 @@ public Map getComponents() { /** * Returns the list of capabilities advertised by the platform. * - * @return the capabilities list, or null if not provided + * @return immutable list of capabilities (never null) */ public List getCapabilities() { return capabilities; @@ -132,7 +139,7 @@ public boolean isHealthy() { * @return true if the capability is present */ public boolean hasCapability(String name) { - if (capabilities == null) return false; + if (name == null) return false; return capabilities.stream().anyMatch(c -> name.equals(c.getName())); } @@ -143,12 +150,14 @@ public boolean equals(Object o) { HealthStatus that = (HealthStatus) o; return Objects.equals(status, that.status) && Objects.equals(version, that.version) && - Objects.equals(uptime, that.uptime); + Objects.equals(uptime, that.uptime) && + Objects.equals(capabilities, that.capabilities) && + Objects.equals(sdkCompatibility, that.sdkCompatibility); } @Override public int hashCode() { - return Objects.hash(status, version, uptime); + return Objects.hash(status, version, uptime, capabilities, sdkCompatibility); } @Override @@ -157,6 +166,8 @@ public String toString() { "status='" + status + '\'' + ", version='" + version + '\'' + ", uptime='" + uptime + '\'' + + ", capabilities=" + capabilities + + ", sdkCompatibility=" + sdkCompatibility + '}'; } } diff --git a/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java b/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java index 8270897..bad352d 100644 --- a/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java +++ b/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java @@ -18,11 +18,13 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + /** * Represents a capability advertised by the AxonFlow platform. */ @JsonIgnoreProperties(ignoreUnknown = true) -public class PlatformCapability { +public final class PlatformCapability { @JsonProperty("name") private final String name; @@ -45,4 +47,22 @@ public PlatformCapability( public String getName() { return name; } public String getSince() { return since; } public String getDescription() { return description; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PlatformCapability that = (PlatformCapability) o; + return Objects.equals(name, that.name) && Objects.equals(since, that.since) && Objects.equals(description, that.description); + } + + @Override + public int hashCode() { + return Objects.hash(name, since, description); + } + + @Override + public String toString() { + return "PlatformCapability{name='" + name + "', since='" + since + "', description='" + description + "'}"; + } } diff --git a/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java b/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java index 3d386ec..a901bac 100644 --- a/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java +++ b/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java @@ -18,11 +18,13 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + /** * SDK compatibility information returned by the AxonFlow platform health endpoint. */ @JsonIgnoreProperties(ignoreUnknown = true) -public class SDKCompatibility { +public final class SDKCompatibility { @JsonProperty("min_sdk_version") private final String minSdkVersion; @@ -39,4 +41,22 @@ public SDKCompatibility( public String getMinSdkVersion() { return minSdkVersion; } public String getRecommendedSdkVersion() { return recommendedSdkVersion; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SDKCompatibility that = (SDKCompatibility) o; + return Objects.equals(minSdkVersion, that.minSdkVersion) && Objects.equals(recommendedSdkVersion, that.recommendedSdkVersion); + } + + @Override + public int hashCode() { + return Objects.hash(minSdkVersion, recommendedSdkVersion); + } + + @Override + public String toString() { + return "SDKCompatibility{minSdkVersion='" + minSdkVersion + "', recommendedSdkVersion='" + recommendedSdkVersion + "'}"; + } } diff --git a/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java b/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java index 3ebc2e6..5cc3f0f 100644 --- a/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java +++ b/src/main/java/com/getaxonflow/sdk/types/workflow/WorkflowTypes.java @@ -234,6 +234,14 @@ public static final class CreateWorkflowRequest { @JsonProperty("trace_id") private final String traceId; + /** + * Backward-compatible constructor without traceId. + */ + public CreateWorkflowRequest(String workflowName, WorkflowSource source, + Integer totalSteps, Map metadata) { + this(workflowName, source, totalSteps, metadata, null); + } + @JsonCreator public CreateWorkflowRequest( @JsonProperty("workflow_name") String workflowName, @@ -334,6 +342,15 @@ public static final class CreateWorkflowResponse { @JsonProperty("trace_id") private final String traceId; + /** + * Backward-compatible constructor without traceId. + */ + public CreateWorkflowResponse(String workflowId, String workflowName, + WorkflowSource source, WorkflowStatus status, + Instant createdAt) { + this(workflowId, workflowName, source, status, createdAt, null); + } + @JsonCreator public CreateWorkflowResponse( @JsonProperty("workflow_id") String workflowId, @@ -453,6 +470,15 @@ public static final class StepGateRequest { @JsonProperty("tool_context") private final ToolContext toolContext; + /** + * Backward-compatible constructor without toolContext. + */ + public StepGateRequest(String stepName, StepType stepType, + Map stepInput, String model, + String provider) { + this(stepName, stepType, stepInput, model, provider, null); + } + @JsonCreator public StepGateRequest( @JsonProperty("step_name") String stepName, @@ -776,6 +802,18 @@ public static final class WorkflowStatusResponse { @JsonProperty("trace_id") private final String traceId; + /** + * Backward-compatible constructor without traceId. + */ + public WorkflowStatusResponse(String workflowId, String workflowName, + WorkflowSource source, WorkflowStatus status, + int currentStepIndex, Integer totalSteps, + Instant startedAt, Instant completedAt, + List steps) { + this(workflowId, workflowName, source, status, currentStepIndex, + totalSteps, startedAt, completedAt, steps, null); + } + @JsonCreator public WorkflowStatusResponse( @JsonProperty("workflow_id") String workflowId, @@ -858,6 +896,13 @@ public static final class ListWorkflowsOptions { private final int offset; private final String traceId; + /** + * Backward-compatible constructor without traceId. + */ + public ListWorkflowsOptions(WorkflowStatus status, WorkflowSource source, int limit, int offset) { + this(status, source, limit, offset, null); + } + public ListWorkflowsOptions(WorkflowStatus status, WorkflowSource source, int limit, int offset, String traceId) { this.status = status; this.source = source; From 669335aa06b4b5c7c7815b6621bd21c6ad35c98a Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 3 Mar 2026 02:38:51 +0530 Subject: [PATCH 06/12] feat: add anonymous runtime telemetry Fire-and-forget checkpoint ping on client init with SDK version, OS, arch, runtime version, deployment mode, and enabled features. Off by default for community/sandbox, on for production. Opt out via AXONFLOW_TELEMETRY=off or DO_NOT_TRACK=1. --- CHANGELOG.md | 3 + README.md | 6 + .../java/com/getaxonflow/sdk/AxonFlow.java | 9 + .../com/getaxonflow/sdk/AxonFlowConfig.java | 32 +++ .../sdk/telemetry/TelemetryReporter.java | 184 +++++++++++++ .../sdk/telemetry/TelemetryReporterTest.java | 250 ++++++++++++++++++ 6 files changed, 484 insertions(+) create mode 100644 src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java create mode 100644 src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 457bd81..5832f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ToolContext` type for per-tool governance within workflow steps - `toolContext` field on `StepGateRequest` for tool-level policy enforcement - `listWorkflows()` now supports `traceId` filter parameter +- Anonymous runtime telemetry for version adoption tracking and feature usage signals +- `TelemetryEnabled` / `telemetry` configuration option to explicitly control telemetry +- `AXONFLOW_TELEMETRY=off` and `DO_NOT_TRACK=1` environment variable opt-out support ### Fixed diff --git a/README.md b/README.md index d79d8c2..9b41c6e 100644 --- a/README.md +++ b/README.md @@ -576,6 +576,12 @@ MCPQueryResponse resp = client.queryConnector(query); For enterprise features, contact [sales@getaxonflow.com](mailto:sales@getaxonflow.com). +## Telemetry + +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` or `DO_NOT_TRACK=1`. +See [Telemetry Documentation](https://getaxonflow.com/docs/telemetry) for full details. + ## Contributing We welcome contributions. Please see our [Contributing Guide](CONTRIBUTING.md) for details. diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index cd9a31e..e5aad9a 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -16,6 +16,7 @@ package com.getaxonflow.sdk; import com.getaxonflow.sdk.exceptions.*; +import com.getaxonflow.sdk.telemetry.TelemetryReporter; import com.getaxonflow.sdk.types.*; import com.getaxonflow.sdk.types.codegovernance.*; import com.getaxonflow.sdk.types.costcontrols.CostControlTypes.*; @@ -138,6 +139,14 @@ private AxonFlow(AxonFlowConfig config) { this.masfeatNamespace = new MASFEATNamespace(); logger.info("AxonFlow client initialized for {}", config.getEndpoint()); + + // Send telemetry ping (fire-and-forget). + TelemetryReporter.sendPing( + config.getMode() != null ? config.getMode().getValue() : "production", + config.getEndpoint(), + config.getTelemetry(), + config.isDebug() + ); } private static ObjectMapper createObjectMapper() { diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java index ca78475..fe9b12d 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java @@ -61,6 +61,7 @@ public final class AxonFlowConfig { private final RetryConfig retryConfig; private final CacheConfig cacheConfig; private final String userAgent; + private final Boolean telemetry; private AxonFlowConfig(Builder builder) { this.endpoint = normalizeUrl(builder.endpoint != null ? builder.endpoint : DEFAULT_ENDPOINT); @@ -73,6 +74,7 @@ private AxonFlowConfig(Builder builder) { this.retryConfig = builder.retryConfig != null ? builder.retryConfig : RetryConfig.defaults(); 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(); } @@ -211,6 +213,18 @@ public String getUserAgent() { return userAgent; } + /** + * 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(); } @@ -257,6 +271,7 @@ public static final class Builder { private RetryConfig retryConfig; private CacheConfig cacheConfig; private String userAgent; + private Boolean telemetry; private Builder() {} @@ -389,6 +404,23 @@ 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 variables: + * {@code DO_NOT_TRACK=1} or {@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 new file mode 100644 index 0000000..bff06e5 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -0,0 +1,184 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.telemetry; + +import com.getaxonflow.sdk.AxonFlowConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Fire-and-forget telemetry reporter that sends anonymous usage pings + * to the AxonFlow checkpoint endpoint. + * + *

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

Telemetry can be disabled via: + *

    + *
  • Setting environment variable {@code DO_NOT_TRACK=1}
  • + *
  • Setting environment variable {@code AXONFLOW_TELEMETRY=off}
  • + *
  • Setting {@code telemetry(false)} on the config builder
  • + *
+ * + *

By default, telemetry is OFF in sandbox mode and ON in production mode. + */ +public class TelemetryReporter { + + private static final Logger logger = LoggerFactory.getLogger(TelemetryReporter.class); + + static final String DEFAULT_ENDPOINT = "https://checkpoint.getaxonflow.com/v1/ping"; + private static final int TIMEOUT_SECONDS = 3; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + /** + * Sends an anonymous telemetry ping asynchronously (fire-and-forget). + * + * @param mode the deployment mode (e.g. "production", "sandbox") + * @param sdkEndpoint the configured SDK endpoint (unused in payload, present for future use) + * @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) { + sendPing(mode, sdkEndpoint, telemetryEnabled, debug, + System.getenv("DO_NOT_TRACK"), + System.getenv("AXONFLOW_TELEMETRY"), + System.getenv("AXONFLOW_CHECKPOINT_URL")); + } + + /** + * Package-private overload for testability, accepting env var values as parameters. + */ + static void sendPing(String mode, String sdkEndpoint, Boolean telemetryEnabled, boolean debug, + String doNotTrack, String axonflowTelemetry, String checkpointUrl) { + if (!isEnabled(mode, telemetryEnabled, doNotTrack, axonflowTelemetry)) { + if (debug) { + logger.debug("Telemetry is disabled, skipping ping"); + } + return; + } + + String endpoint = (checkpointUrl != null && !checkpointUrl.isEmpty()) + ? checkpointUrl + : DEFAULT_ENDPOINT; + + CompletableFuture.runAsync(() -> { + try { + String payload = buildPayload(mode); + + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(); + + RequestBody body = RequestBody.create(payload, JSON); + Request request = new Request.Builder() + .url(endpoint) + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (debug) { + logger.debug("Telemetry ping sent, status={}", response.code()); + } + } + } catch (Exception e) { + // Silent failure - telemetry must never disrupt SDK operation + if (debug) { + logger.debug("Telemetry ping failed (silent): {}", e.getMessage()); + } + } + }); + } + + /** + * Determines whether telemetry is enabled based on environment and config. + * + *

Priority order: + *

    + *
  1. {@code DO_NOT_TRACK=1} environment variable disables telemetry
  2. + *
  3. {@code AXONFLOW_TELEMETRY=off} environment variable disables telemetry
  4. + *
  5. Config override ({@code Boolean.TRUE} or {@code Boolean.FALSE}) takes precedence
  6. + *
  7. Default: ON for production/enterprise, OFF for sandbox
  8. + *
+ * + * @param mode the deployment mode + * @param configOverride explicit config override (null = use default) + * @return true if telemetry should be sent + */ + static boolean isEnabled(String mode, Boolean configOverride) { + return isEnabled(mode, configOverride, System.getenv("DO_NOT_TRACK"), System.getenv("AXONFLOW_TELEMETRY")); + } + + /** + * Package-private for testing. Accepts env var values as parameters. + */ + static boolean isEnabled(String mode, Boolean configOverride, String doNotTrack, String axonflowTelemetry) { + if ("1".equals(doNotTrack)) { + return false; + } + if ("off".equalsIgnoreCase(axonflowTelemetry)) { + return false; + } + if (configOverride != null) { + return configOverride; + } + return "production".equals(mode) || "enterprise".equals(mode); + } + + /** + * Builds the JSON payload for the telemetry ping. + */ + static String buildPayload(String mode) { + try { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + root.put("sdk", "java"); + root.put("sdk_version", AxonFlowConfig.SDK_VERSION); + root.putNull("platform_version"); + root.put("os", System.getProperty("os.name")); + root.put("arch", System.getProperty("os.arch")); + root.put("runtime_version", System.getProperty("java.version")); + root.put("deployment_mode", mode); + + ArrayNode features = mapper.createArrayNode(); + root.set("features", features); + + root.put("instance_id", UUID.randomUUID().toString()); + + return mapper.writeValueAsString(root); + } catch (Exception e) { + // Fallback minimal payload + return "{\"sdk\":\"java\",\"sdk_version\":\"" + AxonFlowConfig.SDK_VERSION + "\"}"; + } + } + + private TelemetryReporter() { + // Utility class + } +} diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java new file mode 100644 index 0000000..824c078 --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java @@ -0,0 +1,250 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.telemetry; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.getaxonflow.sdk.AxonFlowConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.*; + +@DisplayName("TelemetryReporter") +@WireMockTest +class TelemetryReporterTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // --- isEnabled tests (using the 4-arg package-private method) --- + + @Test + @DisplayName("should disable telemetry when DO_NOT_TRACK=1") + void testTelemetryDisabledByDoNotTrack() { + assertThat(TelemetryReporter.isEnabled("production", null, "1", null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, "1", null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("sandbox", null, "1", null)).isFalse(); + } + + @Test + @DisplayName("should disable telemetry when AXONFLOW_TELEMETRY=off") + void testTelemetryDisabledByAxonflowEnv() { + assertThat(TelemetryReporter.isEnabled("production", null, null, "off")).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", null, null, "OFF")).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, null, "off")).isFalse(); + } + + @Test + @DisplayName("should default telemetry OFF for sandbox mode") + void testTelemetryDefaultOffForSandbox() { + assertThat(TelemetryReporter.isEnabled("sandbox", null, null, null)).isFalse(); + } + + @Test + @DisplayName("should default telemetry ON for production mode") + void testTelemetryDefaultOnForProduction() { + assertThat(TelemetryReporter.isEnabled("production", null, null, null)).isTrue(); + } + + @Test + @DisplayName("should default telemetry ON for enterprise mode") + void testTelemetryDefaultOnForEnterprise() { + assertThat(TelemetryReporter.isEnabled("enterprise", null, null, null)).isTrue(); + } + + @Test + @DisplayName("should allow config override to enable telemetry in sandbox") + void testTelemetryConfigOverrideEnable() { + assertThat(TelemetryReporter.isEnabled("sandbox", Boolean.TRUE, null, null)).isTrue(); + } + + @Test + @DisplayName("should allow config override to disable telemetry in production") + void testTelemetryConfigOverrideDisable() { + assertThat(TelemetryReporter.isEnabled("production", Boolean.FALSE, null, null)).isFalse(); + } + + @Test + @DisplayName("DO_NOT_TRACK takes precedence over config override") + void testDoNotTrackPrecedence() { + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, "1", null)).isFalse(); + } + + @Test + @DisplayName("AXONFLOW_TELEMETRY=off takes precedence over config override") + void testAxonflowTelemetryPrecedence() { + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, null, "off")).isFalse(); + } + + // --- Payload format test --- + + @Test + @DisplayName("should produce correct payload JSON format") + void testPayloadFormat() throws Exception { + String payload = TelemetryReporter.buildPayload("production"); + JsonNode root = objectMapper.readTree(payload); + + assertThat(root.get("sdk").asText()).isEqualTo("java"); + assertThat(root.get("sdk_version").asText()).isEqualTo(AxonFlowConfig.SDK_VERSION); + assertThat(root.get("platform_version").isNull()).isTrue(); + assertThat(root.get("os").asText()).isEqualTo(System.getProperty("os.name")); + assertThat(root.get("arch").asText()).isEqualTo(System.getProperty("os.arch")); + assertThat(root.get("runtime_version").asText()).isEqualTo(System.getProperty("java.version")); + assertThat(root.get("deployment_mode").asText()).isEqualTo("production"); + assertThat(root.get("features").isArray()).isTrue(); + assertThat(root.get("features").size()).isEqualTo(0); + assertThat(root.get("instance_id").asText()).isNotEmpty(); + // 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}"); + } + + @Test + @DisplayName("payload should reflect the given mode") + void testPayloadModeReflection() throws Exception { + String payload = TelemetryReporter.buildPayload("sandbox"); + JsonNode root = objectMapper.readTree(payload); + assertThat(root.get("deployment_mode").asText()).isEqualTo("sandbox"); + } + + // --- HTTP integration tests --- + + @Test + @DisplayName("should send telemetry ping to custom endpoint") + void testCustomEndpoint(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/v1/ping").willReturn(ok())); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + // Call sendPing with custom checkpoint URL, no env opt-outs + TelemetryReporter.sendPing( + "production", + "http://localhost:8080", + null, + false, + null, // doNotTrack + null, // axonflowTelemetry + customUrl // checkpointUrl + ); + + // Give the async call time to complete + Thread.sleep(2000); + + verify(postRequestedFor(urlEqualTo("/v1/ping")) + .withHeader("Content-Type", containing("application/json"))); + + // Verify the request body has expected fields + var requests = WireMock.findAll(postRequestedFor(urlEqualTo("/v1/ping"))); + assertThat(requests).hasSize(1); + + JsonNode body = objectMapper.readTree(requests.get(0).getBodyAsString()); + assertThat(body.get("sdk").asText()).isEqualTo("java"); + 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(); + } + + @Test + @DisplayName("should not send ping when telemetry is disabled") + void testNoRequestWhenDisabled(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/v1/ping").willReturn(ok())); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + // Disable via DO_NOT_TRACK + TelemetryReporter.sendPing( + "production", + "http://localhost:8080", + null, + false, + "1", // doNotTrack = disabled + null, + customUrl + ); + + Thread.sleep(1000); + + verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); + } + + @Test + @DisplayName("should silently handle connection failure") + void testSilentFailure() { + // Point to a port that is almost certainly not listening + assertThatCode(() -> { + TelemetryReporter.sendPing( + "production", + "http://localhost:8080", + null, + false, + null, + null, + "http://127.0.0.1:1" // port 1 - connection refused + ); + + // Give the async call time to run and fail + Thread.sleep(4000); + }).doesNotThrowAnyException(); + } + + @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, + null, + 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 { + stubFor(post("/v1/ping").willReturn(ok())); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + TelemetryReporter.sendPing( + "sandbox", + "http://localhost:8080", + Boolean.TRUE, // explicit enable + false, + null, + null, + customUrl + ); + + Thread.sleep(2000); + + verify(exactly(1), postRequestedFor(urlEqualTo("/v1/ping"))); + } +} From ea471e87c552464f0e8933f35ff28df393b59e75 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 3 Mar 2026 09:41:25 +0530 Subject: [PATCH 07/12] fix: telemetry defaults OFF for self-hosted (no credentials) Production mode without credentials (community/self-hosted) now defaults telemetry to OFF, matching Go/TypeScript SDKs and documented policy. --- .../java/com/getaxonflow/sdk/AxonFlow.java | 5 +- .../sdk/telemetry/TelemetryReporter.java | 24 ++++--- .../sdk/telemetry/TelemetryReporterTest.java | 72 ++++++++++++++----- 3 files changed, 73 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index e5aad9a..9730814 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -141,11 +141,14 @@ private AxonFlow(AxonFlowConfig config) { logger.info("AxonFlow client initialized for {}", config.getEndpoint()); // Send telemetry ping (fire-and-forget). + boolean hasCredentials = config.getClientId() != null && !config.getClientId().isEmpty() + && config.getClientSecret() != null && !config.getClientSecret().isEmpty(); TelemetryReporter.sendPing( config.getMode() != null ? config.getMode().getValue() : "production", config.getEndpoint(), config.getTelemetry(), - config.isDebug() + config.isDebug(), + hasCredentials ); } diff --git a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java index bff06e5..e76ba42 100644 --- a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -63,8 +63,9 @@ public class TelemetryReporter { * @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) { - sendPing(mode, sdkEndpoint, telemetryEnabled, debug, + public static void sendPing(String mode, String sdkEndpoint, Boolean telemetryEnabled, boolean debug, + boolean hasCredentials) { + sendPing(mode, sdkEndpoint, telemetryEnabled, debug, hasCredentials, System.getenv("DO_NOT_TRACK"), System.getenv("AXONFLOW_TELEMETRY"), System.getenv("AXONFLOW_CHECKPOINT_URL")); @@ -74,8 +75,9 @@ public static void sendPing(String mode, String sdkEndpoint, Boolean telemetryEn * Package-private overload for testability, accepting env var values as parameters. */ static void sendPing(String mode, String sdkEndpoint, Boolean telemetryEnabled, boolean debug, + boolean hasCredentials, String doNotTrack, String axonflowTelemetry, String checkpointUrl) { - if (!isEnabled(mode, telemetryEnabled, doNotTrack, axonflowTelemetry)) { + if (!isEnabled(mode, telemetryEnabled, hasCredentials, doNotTrack, axonflowTelemetry)) { if (debug) { logger.debug("Telemetry is disabled, skipping ping"); } @@ -124,21 +126,24 @@ static void sendPing(String mode, String sdkEndpoint, Boolean telemetryEnabled, *
  • {@code DO_NOT_TRACK=1} environment variable disables telemetry
  • *
  • {@code AXONFLOW_TELEMETRY=off} environment variable disables telemetry
  • *
  • Config override ({@code Boolean.TRUE} or {@code Boolean.FALSE}) takes precedence
  • - *
  • Default: ON for production/enterprise, OFF for sandbox
  • + *
  • Default: ON for production/enterprise with credentials, OFF for sandbox or no credentials
  • * * * @param mode the deployment mode * @param configOverride explicit config override (null = use default) + * @param hasCredentials whether the client has credentials (clientId + clientSecret) * @return true if telemetry should be sent */ - static boolean isEnabled(String mode, Boolean configOverride) { - return isEnabled(mode, configOverride, System.getenv("DO_NOT_TRACK"), System.getenv("AXONFLOW_TELEMETRY")); + static boolean isEnabled(String mode, Boolean configOverride, boolean hasCredentials) { + return isEnabled(mode, configOverride, hasCredentials, + System.getenv("DO_NOT_TRACK"), System.getenv("AXONFLOW_TELEMETRY")); } /** * Package-private for testing. Accepts env var values as parameters. */ - static boolean isEnabled(String mode, Boolean configOverride, String doNotTrack, String axonflowTelemetry) { + static boolean isEnabled(String mode, Boolean configOverride, boolean hasCredentials, + String doNotTrack, String axonflowTelemetry) { if ("1".equals(doNotTrack)) { return false; } @@ -148,7 +153,10 @@ static boolean isEnabled(String mode, Boolean configOverride, String doNotTrack, if (configOverride != null) { return configOverride; } - return "production".equals(mode) || "enterprise".equals(mode); + if ("sandbox".equals(mode)) { + return false; + } + return hasCredentials; } /** diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java index 824c078..7d92e69 100644 --- a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java @@ -33,64 +33,70 @@ class TelemetryReporterTest { private final ObjectMapper objectMapper = new ObjectMapper(); - // --- isEnabled tests (using the 4-arg package-private method) --- + // --- isEnabled tests (using the 5-arg package-private method) --- @Test @DisplayName("should disable telemetry when DO_NOT_TRACK=1") void testTelemetryDisabledByDoNotTrack() { - assertThat(TelemetryReporter.isEnabled("production", null, "1", null)).isFalse(); - assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, "1", null)).isFalse(); - assertThat(TelemetryReporter.isEnabled("sandbox", null, "1", null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", null, true, "1", null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, true, "1", null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("sandbox", null, true, "1", null)).isFalse(); } @Test @DisplayName("should disable telemetry when AXONFLOW_TELEMETRY=off") void testTelemetryDisabledByAxonflowEnv() { - assertThat(TelemetryReporter.isEnabled("production", null, null, "off")).isFalse(); - assertThat(TelemetryReporter.isEnabled("production", null, null, "OFF")).isFalse(); - assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, null, "off")).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", null, true, null, "off")).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", null, true, null, "OFF")).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, true, null, "off")).isFalse(); } @Test @DisplayName("should default telemetry OFF for sandbox mode") void testTelemetryDefaultOffForSandbox() { - assertThat(TelemetryReporter.isEnabled("sandbox", null, null, null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("sandbox", null, true, null, null)).isFalse(); } @Test - @DisplayName("should default telemetry ON for production mode") - void testTelemetryDefaultOnForProduction() { - assertThat(TelemetryReporter.isEnabled("production", null, null, null)).isTrue(); + @DisplayName("should default telemetry ON for production mode with credentials") + void testTelemetryDefaultOnForProductionWithCredentials() { + assertThat(TelemetryReporter.isEnabled("production", null, true, null, null)).isTrue(); } @Test - @DisplayName("should default telemetry ON for enterprise mode") - void testTelemetryDefaultOnForEnterprise() { - assertThat(TelemetryReporter.isEnabled("enterprise", null, null, null)).isTrue(); + @DisplayName("should default telemetry OFF for production mode without credentials") + void testTelemetryDefaultOffForProductionWithoutCredentials() { + assertThat(TelemetryReporter.isEnabled("production", null, false, null, null)).isFalse(); + } + + @Test + @DisplayName("should default telemetry ON for enterprise mode with credentials") + void testTelemetryDefaultOnForEnterpriseWithCredentials() { + assertThat(TelemetryReporter.isEnabled("enterprise", null, true, null, null)).isTrue(); } @Test @DisplayName("should allow config override to enable telemetry in sandbox") void testTelemetryConfigOverrideEnable() { - assertThat(TelemetryReporter.isEnabled("sandbox", Boolean.TRUE, null, null)).isTrue(); + assertThat(TelemetryReporter.isEnabled("sandbox", Boolean.TRUE, false, null, null)).isTrue(); } @Test @DisplayName("should allow config override to disable telemetry in production") void testTelemetryConfigOverrideDisable() { - assertThat(TelemetryReporter.isEnabled("production", Boolean.FALSE, null, null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", Boolean.FALSE, true, null, null)).isFalse(); } @Test @DisplayName("DO_NOT_TRACK takes precedence over config override") void testDoNotTrackPrecedence() { - assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, "1", null)).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, true, "1", null)).isFalse(); } @Test @DisplayName("AXONFLOW_TELEMETRY=off takes precedence over config override") void testAxonflowTelemetryPrecedence() { - assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, null, "off")).isFalse(); + assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, true, null, "off")).isFalse(); } // --- Payload format test --- @@ -133,12 +139,13 @@ void testCustomEndpoint(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; - // Call sendPing with custom checkpoint URL, no env opt-outs + // Call sendPing with custom checkpoint URL, no env opt-outs, with credentials TelemetryReporter.sendPing( "production", "http://localhost:8080", null, false, + true, // hasCredentials null, // doNotTrack null, // axonflowTelemetry customUrl // checkpointUrl @@ -174,6 +181,7 @@ void testNoRequestWhenDisabled(WireMockRuntimeInfo wmRuntimeInfo) throws Excepti "http://localhost:8080", null, false, + true, // hasCredentials "1", // doNotTrack = disabled null, customUrl @@ -194,6 +202,7 @@ void testSilentFailure() { "http://localhost:8080", null, false, + true, // hasCredentials null, null, "http://127.0.0.1:1" // port 1 - connection refused @@ -216,6 +225,7 @@ void testSandboxModeDefaultOff(WireMockRuntimeInfo wmRuntimeInfo) throws Excepti "http://localhost:8080", null, // no override false, + true, // hasCredentials null, null, customUrl @@ -238,6 +248,7 @@ void testSandboxModeExplicitEnable(WireMockRuntimeInfo wmRuntimeInfo) throws Exc "http://localhost:8080", Boolean.TRUE, // explicit enable false, + false, // hasCredentials (doesn't matter with explicit override) null, null, customUrl @@ -247,4 +258,27 @@ void testSandboxModeExplicitEnable(WireMockRuntimeInfo wmRuntimeInfo) throws Exc verify(exactly(1), postRequestedFor(urlEqualTo("/v1/ping"))); } + + @Test + @DisplayName("should not send ping in production mode without credentials") + void testProductionModeWithoutCredentials(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/v1/ping").willReturn(ok())); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + TelemetryReporter.sendPing( + "production", + "http://localhost:8080", + null, // no override + false, + false, // no credentials (self-hosted/community) + null, + null, + customUrl + ); + + Thread.sleep(1000); + + verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); + } } From fee1e0aa2b73cbb720f6eb41d708c8d4ed1b2e15 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 3 Mar 2026 10:28:35 +0530 Subject: [PATCH 08/12] test: align telemetry tests with canonical 24-test matrix Add unique instance_id, config disable integration, timeout handling, non-200 response, AXONFLOW_TELEMETRY integration, and enterprise mode payload verification tests. --- .../sdk/telemetry/TelemetryReporterTest.java | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java index 7d92e69..477c0c0 100644 --- a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java @@ -281,4 +281,169 @@ void testProductionModeWithoutCredentials(WireMockRuntimeInfo wmRuntimeInfo) thr verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); } + + // --- Additional tests for parity with Python SDK --- + + @Test + @DisplayName("each buildPayload call should generate a unique instance_id") + void testUniqueInstanceId() throws Exception { + String payload1 = TelemetryReporter.buildPayload("production"); + String payload2 = TelemetryReporter.buildPayload("production"); + String payload3 = TelemetryReporter.buildPayload("production"); + + JsonNode root1 = objectMapper.readTree(payload1); + JsonNode root2 = objectMapper.readTree(payload2); + JsonNode root3 = objectMapper.readTree(payload3); + + String id1 = root1.get("instance_id").asText(); + String id2 = root2.get("instance_id").asText(); + String id3 = root3.get("instance_id").asText(); + + // All three should be valid UUIDs + assertThat(id1).matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + assertThat(id2).matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + assertThat(id3).matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + + // All three should be distinct + assertThat(id1).isNotEqualTo(id2); + assertThat(id1).isNotEqualTo(id3); + 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, + null, + customUrl + ); + + Thread.sleep(1000); + + verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); + } + + @Test + @DisplayName("should silently handle server timeout without crashing") + void testSilentFailureOnTimeout(WireMockRuntimeInfo wmRuntimeInfo) { + // Delay response for 5 seconds, exceeding the 3s timeout + stubFor(post("/v1/ping").willReturn(ok().withFixedDelay(5000))); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + assertThatCode(() -> { + TelemetryReporter.sendPing( + "production", + "http://localhost:8080", + null, + false, + true, // hasCredentials + null, + null, + customUrl + ); + + // Wait long enough for the async call to hit the timeout and fail + Thread.sleep(5000); + }).doesNotThrowAnyException(); + } + + @Test + @DisplayName("should not crash when server returns HTTP 500") + void testNon200ResponseNoCrash(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post("/v1/ping").willReturn(serverError().withBody("Internal Server Error"))); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + assertThatCode(() -> { + TelemetryReporter.sendPing( + "production", + "http://localhost:8080", + null, + false, + true, // hasCredentials + null, + null, + customUrl + ); + + // Give the async call time to complete + Thread.sleep(2000); + }).doesNotThrowAnyException(); + + // Verify the request was still made (the server just returned 500) + verify(exactly(1), postRequestedFor(urlEqualTo("/v1/ping"))); + } + + @Test + @DisplayName("AXONFLOW_TELEMETRY=off should skip POST even with credentials in production") + void testAxonflowTelemetrySkipsPost(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/v1/ping").willReturn(ok())); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + TelemetryReporter.sendPing( + "production", + "http://localhost:8080", + null, + false, + true, // hasCredentials + null, + "off", // AXONFLOW_TELEMETRY=off + customUrl + ); + + Thread.sleep(1000); + + verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); + } + + @Test + @DisplayName("should send correct payload fields in enterprise mode via HTTP") + void testPayloadDeploymentModeEnterprise(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { + stubFor(post("/v1/ping").willReturn(ok())); + + String customUrl = wmRuntimeInfo.getHttpBaseUrl() + "/v1/ping"; + + TelemetryReporter.sendPing( + "enterprise", + "http://localhost:8080", + null, + false, + true, // hasCredentials + null, + null, + customUrl + ); + + Thread.sleep(2000); + + verify(exactly(1), postRequestedFor(urlEqualTo("/v1/ping")) + .withHeader("Content-Type", containing("application/json"))); + + var requests = WireMock.findAll(postRequestedFor(urlEqualTo("/v1/ping"))); + assertThat(requests).hasSize(1); + + JsonNode body = objectMapper.readTree(requests.get(0).getBodyAsString()); + assertThat(body.get("sdk").asText()).isEqualTo("java"); + assertThat(body.get("sdk_version").asText()).isEqualTo(AxonFlowConfig.SDK_VERSION); + assertThat(body.get("deployment_mode").asText()).isEqualTo("enterprise"); + assertThat(body.get("os").asText()).isEqualTo(System.getProperty("os.name")); + assertThat(body.get("arch").asText()).isEqualTo(System.getProperty("os.arch")); + assertThat(body.get("runtime_version").asText()).isEqualTo(System.getProperty("java.version")); + assertThat(body.get("platform_version").isNull()).isTrue(); + 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}"); + } } From 9b86af977f238b451f33f61372f048337ec91b01 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 3 Mar 2026 12:42:09 +0530 Subject: [PATCH 09/12] fix(telemetry): fix docs URL to docs.getaxonflow.com --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b41c6e..2d51f5b 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ 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` or `DO_NOT_TRACK=1`. -See [Telemetry Documentation](https://getaxonflow.com/docs/telemetry) for full details. +See [Telemetry Documentation](https://docs.getaxonflow.com/docs/telemetry) for full details. ## Contributing From fa1b7ca2112a3ff1de17d23d491975d6d9df2e5a Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 3 Mar 2026 13:49:23 +0530 Subject: [PATCH 10/12] feat(telemetry): default ON for all modes except sandbox Remove credential-based default logic. Telemetry is now ON by default for all modes except sandbox. Opt out via DO_NOT_TRACK=1, AXONFLOW_TELEMETRY=off, or telemetry config flag. --- .../sdk/telemetry/TelemetryReporter.java | 10 ++++------ .../sdk/telemetry/TelemetryReporterTest.java | 14 +++++++------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java index e76ba42..09c1ccf 100644 --- a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -126,12 +126,12 @@ static void sendPing(String mode, String sdkEndpoint, Boolean telemetryEnabled, *
  • {@code DO_NOT_TRACK=1} environment variable disables telemetry
  • *
  • {@code AXONFLOW_TELEMETRY=off} environment variable disables telemetry
  • *
  • Config override ({@code Boolean.TRUE} or {@code Boolean.FALSE}) takes precedence
  • - *
  • Default: ON for production/enterprise with credentials, OFF for sandbox or no credentials
  • + *
  • Default: ON for all modes except sandbox
  • * * * @param mode the deployment mode * @param configOverride explicit config override (null = use default) - * @param hasCredentials whether the client has credentials (clientId + clientSecret) + * @param hasCredentials whether the client has credentials (kept for API compat, no longer used in default logic) * @return true if telemetry should be sent */ static boolean isEnabled(String mode, Boolean configOverride, boolean hasCredentials) { @@ -153,10 +153,8 @@ static boolean isEnabled(String mode, Boolean configOverride, boolean hasCredent if (configOverride != null) { return configOverride; } - if ("sandbox".equals(mode)) { - return false; - } - return hasCredentials; + // Default: ON everywhere except sandbox mode. + return !"sandbox".equals(mode); } /** diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java index 477c0c0..b15297d 100644 --- a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java @@ -64,9 +64,9 @@ void testTelemetryDefaultOnForProductionWithCredentials() { } @Test - @DisplayName("should default telemetry OFF for production mode without credentials") - void testTelemetryDefaultOffForProductionWithoutCredentials() { - assertThat(TelemetryReporter.isEnabled("production", null, false, null, null)).isFalse(); + @DisplayName("should default telemetry ON for production mode even without credentials") + void testTelemetryDefaultOnForProductionWithoutCredentials() { + assertThat(TelemetryReporter.isEnabled("production", null, false, null, null)).isTrue(); } @Test @@ -260,7 +260,7 @@ void testSandboxModeExplicitEnable(WireMockRuntimeInfo wmRuntimeInfo) throws Exc } @Test - @DisplayName("should not send ping in production mode without credentials") + @DisplayName("should send ping in production mode even without credentials") void testProductionModeWithoutCredentials(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { stubFor(post("/v1/ping").willReturn(ok())); @@ -271,15 +271,15 @@ void testProductionModeWithoutCredentials(WireMockRuntimeInfo wmRuntimeInfo) thr "http://localhost:8080", null, // no override false, - false, // no credentials (self-hosted/community) + false, // no credentials — no longer affects default null, null, customUrl ); - Thread.sleep(1000); + Thread.sleep(2000); - verify(exactly(0), postRequestedFor(urlEqualTo("/v1/ping"))); + verify(exactly(1), postRequestedFor(urlEqualTo("/v1/ping"))); } // --- Additional tests for parity with Python SDK --- From b9562b6cb5ecb445266b990b12e4e4d18e8f390c Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 3 Mar 2026 13:55:39 +0530 Subject: [PATCH 11/12] fix(telemetry): trim whitespace on env vars per TELEMETRY_CONTRACT Align with cross-SDK contract: trim DO_NOT_TRACK and AXONFLOW_TELEMETRY before comparison so "1 " and " off" are handled correctly. --- .../java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java index 09c1ccf..6160a34 100644 --- a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -144,10 +144,10 @@ static boolean isEnabled(String mode, Boolean configOverride, boolean hasCredent */ static boolean isEnabled(String mode, Boolean configOverride, boolean hasCredentials, String doNotTrack, String axonflowTelemetry) { - if ("1".equals(doNotTrack)) { + if (doNotTrack != null && "1".equals(doNotTrack.trim())) { return false; } - if ("off".equalsIgnoreCase(axonflowTelemetry)) { + if (axonflowTelemetry != null && "off".equalsIgnoreCase(axonflowTelemetry.trim())) { return false; } if (configOverride != null) { From d3d010288ac0e7f3b9dd28e0fe9b143571832ffb Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 3 Mar 2026 14:14:40 +0530 Subject: [PATCH 12/12] fix: set v3.8.0 release date, add telemetry first-run notice --- CHANGELOG.md | 2 +- .../java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5832f86..b674cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## [3.8.0] - TBD +## [3.8.0] - 2026-03-03 ### Added diff --git a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java index 6160a34..630e05d 100644 --- a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -84,6 +84,8 @@ static void sendPing(String mode, String sdkEndpoint, Boolean telemetryEnabled, return; } + logger.info("AxonFlow: anonymous telemetry enabled. Opt out: AXONFLOW_TELEMETRY=off | https://docs.getaxonflow.com/telemetry"); + String endpoint = (checkpointUrl != null && !checkpointUrl.isEmpty()) ? checkpointUrl : DEFAULT_ENDPOINT;