Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>5.1.0</version>
<version>5.2.0</version>
<packaging>jar</packaging>

<name>AxonFlow Java SDK</name>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -211,6 +222,83 @@ static String buildPayload(String mode, String platformVersion) {
}
}

/**
* Endpoint type classifications for telemetry. See issue #1525.
*
* <p>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).
*
* <p>Returns one of {@link EndpointType#LOCALHOST}, {@link EndpointType#PRIVATE_NETWORK}, {@link
* EndpointType#REMOTE}, or {@link EndpointType#UNKNOWN}.
*
* <p>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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading