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: + *

+ * + *

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: + *

    + *
  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 all modes except sandbox
  8. + *
+ * + * @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"); } }