diff --git a/CHANGELOG.md b/CHANGELOG.md index 6636308..6800f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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). +## [5.2.0] - 2026-04-09 + +### Added + +- **Telemetry `endpoint_type` field.** The anonymous telemetry ping now includes an SDK-derived classification of the configured AxonFlow endpoint as one of `localhost`, `private_network`, `remote`, or `unknown`. The raw URL is never sent and is not hashed. This helps distinguish self-hosted evaluation from real production deployments on the checkpoint dashboard. Opt out as before via `DO_NOT_TRACK=1` or `AXONFLOW_TELEMETRY=off`. +- **`TelemetryReporter.classifyEndpoint(url)` method and `TelemetryReporter.EndpointType` constants** exported publicly for applications that want to inspect the classification. + +### Changed + +- Examples and documentation updated to reflect the new AxonFlow platform v6.2.0 defaults for `PII_ACTION` (now `warn` — was `redact`) and the new `AXONFLOW_PROFILE` env var. No SDK API changes. + +--- + ## [5.1.0] - 2026-04-06 ### Added diff --git a/pom.xml b/pom.xml index 5ecb7eb..75e71db 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 5.1.0 + 5.2.0 jar AxonFlow Java SDK diff --git a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java index bc0fd6b..e9a3be6 100644 --- a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -20,9 +20,13 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.getaxonflow.sdk.AxonFlowConfig; +import java.net.URI; +import java.net.URISyntaxException; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -105,11 +109,12 @@ static void sendPing( (checkpointUrl != null && !checkpointUrl.isEmpty()) ? checkpointUrl : DEFAULT_ENDPOINT; final String finalSdkEndpoint = sdkEndpoint; + final String endpointType = classifyEndpoint(finalSdkEndpoint); CompletableFuture.runAsync( () -> { try { String platformVersion = detectPlatformVersion(finalSdkEndpoint); - String payload = buildPayload(mode, platformVersion); + String payload = buildPayload(mode, platformVersion, endpointType); OkHttpClient client = new OkHttpClient.Builder() @@ -184,6 +189,11 @@ static boolean isEnabled( /** Builds the JSON payload for the telemetry ping. */ static String buildPayload(String mode, String platformVersion) { + return buildPayload(mode, platformVersion, EndpointType.UNKNOWN); + } + + /** Builds the JSON payload with an explicit endpoint_type classification. */ + static String buildPayload(String mode, String platformVersion, String endpointType) { try { ObjectMapper mapper = new ObjectMapper(); ObjectNode root = mapper.createObjectNode(); @@ -198,6 +208,7 @@ static String buildPayload(String mode, String platformVersion) { root.put("arch", normalizeArch(System.getProperty("os.arch"))); root.put("runtime_version", System.getProperty("java.version")); root.put("deployment_mode", mode); + root.put("endpoint_type", endpointType); ArrayNode features = mapper.createArrayNode(); root.set("features", features); @@ -211,6 +222,83 @@ static String buildPayload(String mode, String platformVersion) { } } + /** + * Endpoint type classifications for telemetry. See issue #1525. + * + *

The raw URL is never sent to the checkpoint service — only the classification. + */ + public static final class EndpointType { + public static final String LOCALHOST = "localhost"; + public static final String PRIVATE_NETWORK = "private_network"; + public static final String REMOTE = "remote"; + public static final String UNKNOWN = "unknown"; + + private EndpointType() {} + } + + private static final Pattern IPV4_PATTERN = + Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$"); + + /** + * Classifies the configured AxonFlow endpoint URL for analytics (#1525). + * + *

Returns one of {@link EndpointType#LOCALHOST}, {@link EndpointType#PRIVATE_NETWORK}, {@link + * EndpointType#REMOTE}, or {@link EndpointType#UNKNOWN}. + * + *

The raw URL is never sent — only the classification. + */ + public static String classifyEndpoint(String url) { + if (url == null || url.isEmpty()) { + return EndpointType.UNKNOWN; + } + String host; + try { + URI u = new URI(url); + host = u.getHost(); + if (host == null || host.isEmpty()) { + return EndpointType.UNKNOWN; + } + } catch (URISyntaxException e) { + return EndpointType.UNKNOWN; + } + host = host.toLowerCase(); + + // Strip IPv6 brackets if present. + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + + if ("localhost".equals(host) + || "0.0.0.0".equals(host) + || "::1".equals(host) + || host.endsWith(".localhost")) { + return EndpointType.LOCALHOST; + } + + if (host.endsWith(".local") + || host.endsWith(".internal") + || host.endsWith(".lan") + || host.endsWith(".intranet")) { + return EndpointType.PRIVATE_NETWORK; + } + + // IPv4 classification. + Matcher m = IPV4_PATTERN.matcher(host); + if (m.matches()) { + int a = Integer.parseInt(m.group(1)); + int b = Integer.parseInt(m.group(2)); + if (a == 127) return EndpointType.LOCALHOST; + if (a == 10) return EndpointType.PRIVATE_NETWORK; + if (a == 192 && b == 168) return EndpointType.PRIVATE_NETWORK; + if (a == 172 && b >= 16 && b <= 31) return EndpointType.PRIVATE_NETWORK; + if (a == 169 && b == 254) return EndpointType.PRIVATE_NETWORK; + return EndpointType.REMOTE; + } + + // Public hostname (not an IP, not a known private suffix). + return EndpointType.REMOTE; + } + /** * Detect platform version by calling the agent's /health endpoint. Returns null on any failure. */ diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java new file mode 100644 index 0000000..164f7fe --- /dev/null +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java @@ -0,0 +1,165 @@ +/* + * Copyright 2026 AxonFlow + * Licensed under the Apache License, Version 2.0. + * + * Tests for classifyEndpoint (issue #1525). + */ +package com.getaxonflow.sdk.telemetry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class TelemetryEndpointTypeTest { + + // ---- localhost ---- + + @Test + @DisplayName("localhost: hostname") + void localhostHostname() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://localhost:8080")); + assertEquals("localhost", TelemetryReporter.classifyEndpoint("https://localhost")); + } + + @Test + @DisplayName("localhost: 127.0.0.1") + void localhostIPv4() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://127.0.0.1")); + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://127.0.0.1:8080")); + } + + @Test + @DisplayName("localhost: 127/8") + void localhost127Eight() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://127.1.2.3")); + } + + @Test + @DisplayName("localhost: IPv6 ::1 with brackets") + void localhostIPv6() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::1]")); + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::1]:8080")); + } + + @Test + @DisplayName("localhost: 0.0.0.0") + void localhostZero() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://0.0.0.0:8080")); + } + + @Test + @DisplayName("localhost: *.localhost") + void localhostSubdomain() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://agent.localhost")); + } + + @Test + @DisplayName("localhost: case insensitive") + void localhostCaseInsensitive() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://LOCALHOST")); + } + + // ---- private_network ---- + + @Test + @DisplayName("private: RFC1918 10.x") + void privateRFC1918Ten() { + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://10.0.0.1")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://10.1.2.3")); + } + + @Test + @DisplayName("private: RFC1918 192.168.x") + void privateRFC1918OneNineTwo() { + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://192.168.1.1")); + } + + @Test + @DisplayName("private: RFC1918 172.16-31") + void privateRFC1918OneSevenTwo() { + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://172.16.0.1")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://172.31.255.254")); + } + + @Test + @DisplayName("private: boundary 172.15 and 172.32 NOT private") + void privateRFC1918Boundary() { + assertEquals("remote", TelemetryReporter.classifyEndpoint("http://172.15.0.1")); + assertEquals("remote", TelemetryReporter.classifyEndpoint("http://172.32.0.1")); + } + + @Test + @DisplayName("private: link-local 169.254") + void privateLinkLocal() { + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://169.254.169.254")); + } + + @Test + @DisplayName("private: hostname suffixes") + void privateHostnameSuffixes() { + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.internal")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.local")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.lan")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.intranet")); + } + + @Test + @DisplayName("private: case insensitive .internal") + void privateCaseInsensitive() { + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://AGENT.INTERNAL")); + } + + // ---- remote ---- + + @Test + @DisplayName("remote: public hostnames") + void remotePublicHostname() { + assertEquals( + "remote", TelemetryReporter.classifyEndpoint("https://production-us.getaxonflow.com")); + assertEquals("remote", TelemetryReporter.classifyEndpoint("https://api.example.com")); + } + + @Test + @DisplayName("remote: public IPv4") + void remotePublicIPv4() { + assertEquals("remote", TelemetryReporter.classifyEndpoint("http://8.8.8.8")); + assertEquals("remote", TelemetryReporter.classifyEndpoint("http://1.1.1.1")); + } + + // ---- unknown ---- + + @Test + @DisplayName("unknown: empty") + void unknownEmpty() { + assertEquals("unknown", TelemetryReporter.classifyEndpoint("")); + } + + @Test + @DisplayName("unknown: null") + void unknownNull() { + assertEquals("unknown", TelemetryReporter.classifyEndpoint(null)); + } + + @Test + @DisplayName("unknown: malformed") + void unknownMalformed() { + assertEquals("unknown", TelemetryReporter.classifyEndpoint("not-a-url")); + } + + // ---- payload does not leak URL ---- + + @Test + @DisplayName("payload does not contain raw URL") + void payloadDoesNotLeakURL() { + String secret = "https://my-private-cluster.banking-internal.example.com:8443"; + String type = TelemetryReporter.classifyEndpoint(secret); + assertEquals("remote", type); + String json = TelemetryReporter.buildPayload("production", null, type); + assertFalse(json.contains("my-private-cluster"), "payload leaked hostname"); + assertFalse(json.contains("banking-internal"), "payload leaked domain"); + assertFalse(json.contains("8443"), "payload leaked port"); + assertFalse(json.contains("https://"), "payload leaked scheme"); + } +}