diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13cf6f3..b674cb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ 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] - 2026-03-03
+
+### 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
+- `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
+- 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
+
+- Default User-Agent was hardcoded to `1.0.0` regardless of actual SDK version
+
+---
+
## [3.7.0] - 2026-02-28
### Added
diff --git a/README.md b/README.md
index d79d8c2..2d51f5b 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://docs.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 3c371bb..9730814 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,17 @@ private AxonFlow(AxonFlowConfig config) {
this.masfeatNamespace = new MASFEATNamespace();
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(),
+ hasCredentials
+ );
}
private static ObjectMapper createObjectMapper() {
@@ -149,6 +161,40 @@ 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 {
+ 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 {
+ String cleanB = partsB[i].contains("-") ? partsB[i].substring(0, partsB[i].indexOf("-")) : partsB[i];
+ numB = Integer.parseInt(cleanB);
+ } catch (NumberFormatException ignored) {
+ // default to 0
+ }
+ }
+ if (numA != numB) {
+ return Integer.compare(numA, numB);
+ }
+ }
+ return 0;
+ }
+
// ========================================================================
// Factory Methods
// ========================================================================
@@ -206,12 +252,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
+ && 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());
+ }
+
+ return status;
}
/**
@@ -265,7 +320,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);
}
@@ -4303,6 +4358,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/AxonFlowConfig.java b/src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java
index 4d85a88..fe9b12d 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);
@@ -58,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);
@@ -69,7 +73,8 @@ 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;
+ this.telemetry = builder.telemetry;
validate();
}
@@ -208,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();
}
@@ -254,6 +271,7 @@ public static final class Builder {
private RetryConfig retryConfig;
private CacheConfig cacheConfig;
private String userAgent;
+ private Boolean telemetry;
private Builder() {}
@@ -386,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..630e05d
--- /dev/null
+++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java
@@ -0,0 +1,192 @@
+/*
+ * 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,
+ boolean hasCredentials) {
+ sendPing(mode, sdkEndpoint, telemetryEnabled, debug, hasCredentials,
+ 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,
+ boolean hasCredentials,
+ String doNotTrack, String axonflowTelemetry, String checkpointUrl) {
+ if (!isEnabled(mode, telemetryEnabled, hasCredentials, doNotTrack, axonflowTelemetry)) {
+ if (debug) {
+ logger.debug("Telemetry is disabled, skipping ping");
+ }
+ 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;
+
+ 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:
+ *
+ * - {@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 all modes except sandbox
+ *
+ *
+ * @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)
+ * @return true if telemetry should be sent
+ */
+ 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, boolean hasCredentials,
+ String doNotTrack, String axonflowTelemetry) {
+ if (doNotTrack != null && "1".equals(doNotTrack.trim())) {
+ return false;
+ }
+ if (axonflowTelemetry != null && "off".equalsIgnoreCase(axonflowTelemetry.trim())) {
+ return false;
+ }
+ if (configOverride != null) {
+ return configOverride;
+ }
+ // Default: ON everywhere except sandbox mode.
+ return !"sandbox".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/main/java/com/getaxonflow/sdk/types/HealthStatus.java b/src/main/java/com/getaxonflow/sdk/types/HealthStatus.java
index 14824c4..052297a 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,32 @@ public final class HealthStatus {
@JsonProperty("components")
private final Map components;
+ @JsonProperty("capabilities")
+ private final List capabilities;
+
+ @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,
@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 != null ? Collections.unmodifiableList(capabilities) : Collections.emptyList();
+ this.sdkCompatibility = sdkCompatibility;
}
/**
@@ -87,6 +105,24 @@ public Map getComponents() {
return components;
}
+ /**
+ * Returns the list of capabilities advertised by the platform.
+ *
+ * @return immutable list of capabilities (never null)
+ */
+ 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 +132,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 (name == null) return false;
+ return capabilities.stream().anyMatch(c -> name.equals(c.getName()));
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -103,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
@@ -117,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
new file mode 100644
index 0000000..bad352d
--- /dev/null
+++ b/src/main/java/com/getaxonflow/sdk/types/PlatformCapability.java
@@ -0,0 +1,68 @@
+/*
+ * 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;
+
+import java.util.Objects;
+
+/**
+ * Represents a capability advertised by the AxonFlow platform.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final 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; }
+
+ @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
new file mode 100644
index 0000000..a901bac
--- /dev/null
+++ b/src/main/java/com/getaxonflow/sdk/types/SDKCompatibility.java
@@ -0,0 +1,62 @@
+/*
+ * 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;
+
+import java.util.Objects;
+
+/**
+ * SDK compatibility information returned by the AxonFlow platform health endpoint.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final 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; }
+
+ @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 a68ba75..5cc3f0f 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,29 @@ public static final class CreateWorkflowRequest {
@JsonProperty("metadata")
private final Map metadata;
+ @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,
@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 +272,10 @@ public Map getMetadata() {
return metadata;
}
+ public String getTraceId() {
+ return traceId;
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -267,6 +285,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 +307,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 +339,32 @@ public static final class CreateWorkflowResponse {
@JsonProperty("created_at")
private final Instant createdAt;
+ @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,
@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 +386,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 +467,32 @@ public static final class StepGateRequest {
@JsonProperty("provider")
private final String provider;
+ @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,
@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 +515,10 @@ public String getProvider() {
return provider;
}
+ public ToolContext getToolContext() {
+ return toolContext;
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -415,6 +529,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 +556,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 +799,21 @@ public static final class WorkflowStatusResponse {
@JsonProperty("steps")
private final List steps;
+ @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,
@@ -689,7 +824,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 +835,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 +874,10 @@ public List getSteps() {
return steps;
}
+ public String getTraceId() {
+ return traceId;
+ }
+
public boolean isTerminal() {
return status == WorkflowStatus.COMPLETED ||
status == WorkflowStatus.ABORTED ||
@@ -753,12 +894,21 @@ public static final class ListWorkflowsOptions {
private final WorkflowSource source;
private final int limit;
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;
this.limit = limit > 0 ? limit : 50;
this.offset = Math.max(offset, 0);
+ this.traceId = traceId;
}
public WorkflowStatus getStatus() {
@@ -777,6 +927,10 @@ public int getOffset() {
return offset;
}
+ public String getTraceId() {
+ return traceId;
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -786,6 +940,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 +962,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);
}
}
}
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/telemetry/TelemetryReporterTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java
new file mode 100644
index 0000000..b15297d
--- /dev/null
+++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryReporterTest.java
@@ -0,0 +1,449 @@
+/*
+ * 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 5-arg package-private method) ---
+
+ @Test
+ @DisplayName("should disable telemetry when DO_NOT_TRACK=1")
+ void testTelemetryDisabledByDoNotTrack() {
+ 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, 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, true, null, null)).isFalse();
+ }
+
+ @Test
+ @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 production mode even without credentials")
+ void testTelemetryDefaultOnForProductionWithoutCredentials() {
+ assertThat(TelemetryReporter.isEnabled("production", null, false, null, null)).isTrue();
+ }
+
+ @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, false, null, null)).isTrue();
+ }
+
+ @Test
+ @DisplayName("should allow config override to disable telemetry in production")
+ void testTelemetryConfigOverrideDisable() {
+ 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, true, "1", null)).isFalse();
+ }
+
+ @Test
+ @DisplayName("AXONFLOW_TELEMETRY=off takes precedence over config override")
+ void testAxonflowTelemetryPrecedence() {
+ assertThat(TelemetryReporter.isEnabled("production", Boolean.TRUE, 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, with credentials
+ TelemetryReporter.sendPing(
+ "production",
+ "http://localhost:8080",
+ null,
+ false,
+ true, // hasCredentials
+ 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,
+ true, // hasCredentials
+ "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,
+ true, // hasCredentials
+ 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,
+ true, // hasCredentials
+ 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,
+ false, // hasCredentials (doesn't matter with explicit override)
+ null,
+ null,
+ customUrl
+ );
+
+ Thread.sleep(2000);
+
+ verify(exactly(1), postRequestedFor(urlEqualTo("/v1/ping")));
+ }
+
+ @Test
+ @DisplayName("should send ping in production mode even 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 — no longer affects default
+ null,
+ null,
+ customUrl
+ );
+
+ Thread.sleep(2000);
+
+ verify(exactly(1), 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}");
+ }
+}
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");
}
}