From e2287cc43f719663992234e4bd4d99d5548272fd Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Wed, 8 Apr 2026 22:43:20 +0200 Subject: [PATCH 1/2] release(v5.2.0): telemetry endpoint_type + v5.2.0 version bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1525 — Add endpoint_type field to checkpoint telemetry - New TelemetryReporter.classifyEndpoint(url) public method and EndpointType inner class with LOCALHOST, PRIVATE_NETWORK, REMOTE, UNKNOWN constants. - buildPayload overload accepting endpoint_type threads it into the JSON payload; sendPing classifies the configured URL once and reuses the classification inside the async task. - 20 JUnit 5 tests cover localhost (hostname, IPv4, 127/8, bracketed IPv6, 0.0.0.0, *.localhost), RFC1918 (10.x, 192.168.x, 172.16-31 plus 172.15/172.32 boundary), link-local, hostname suffixes (.local, .internal, .lan, .intranet), public hostnames, public IPv4, empty, null, and malformed inputs, case insensitivity, and an explicit leak test that asserts the serialized payload contains no URL fragments. - IPv6 hostname handling: URI.getHost() returns "[::1]" with brackets in some JVM versions; we strip them before comparison. Version bump: - pom.xml: 5.1.0 → 5.2.0 --- pom.xml | 2 +- .../sdk/telemetry/TelemetryReporter.java | 90 +++++++++- .../telemetry/TelemetryEndpointTypeTest.java | 165 ++++++++++++++++++ 3 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java 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"); + } +} From 86656e23bc3de4735a12b4a7689b27ee8564112e Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Wed, 8 Apr 2026 22:43:38 +0200 Subject: [PATCH 2/2] docs(changelog): add v5.2.0 entry --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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