Skip to content

Commit 06e8b90

Browse files
release(v5.2.0): telemetry endpoint_type field (#125)
* release(v5.2.0): telemetry endpoint_type + v5.2.0 version bump #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 * docs(changelog): add v5.2.0 entry
1 parent 870f7a5 commit 06e8b90

4 files changed

Lines changed: 268 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [5.2.0] - 2026-04-09
9+
10+
### Added
11+
12+
- **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`.
13+
- **`TelemetryReporter.classifyEndpoint(url)` method and `TelemetryReporter.EndpointType` constants** exported publicly for applications that want to inspect the classification.
14+
15+
### Changed
16+
17+
- 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.
18+
19+
---
20+
821
## [5.1.0] - 2026-04-06
922

1023
### Added

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.getaxonflow</groupId>
88
<artifactId>axonflow-sdk</artifactId>
9-
<version>5.1.0</version>
9+
<version>5.2.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>AxonFlow Java SDK</name>

src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@
2020
import com.fasterxml.jackson.databind.node.ArrayNode;
2121
import com.fasterxml.jackson.databind.node.ObjectNode;
2222
import com.getaxonflow.sdk.AxonFlowConfig;
23+
import java.net.URI;
24+
import java.net.URISyntaxException;
2325
import java.util.UUID;
2426
import java.util.concurrent.CompletableFuture;
2527
import java.util.concurrent.TimeUnit;
28+
import java.util.regex.Matcher;
29+
import java.util.regex.Pattern;
2630
import okhttp3.MediaType;
2731
import okhttp3.OkHttpClient;
2832
import okhttp3.Request;
@@ -105,11 +109,12 @@ static void sendPing(
105109
(checkpointUrl != null && !checkpointUrl.isEmpty()) ? checkpointUrl : DEFAULT_ENDPOINT;
106110

107111
final String finalSdkEndpoint = sdkEndpoint;
112+
final String endpointType = classifyEndpoint(finalSdkEndpoint);
108113
CompletableFuture.runAsync(
109114
() -> {
110115
try {
111116
String platformVersion = detectPlatformVersion(finalSdkEndpoint);
112-
String payload = buildPayload(mode, platformVersion);
117+
String payload = buildPayload(mode, platformVersion, endpointType);
113118

114119
OkHttpClient client =
115120
new OkHttpClient.Builder()
@@ -184,6 +189,11 @@ static boolean isEnabled(
184189

185190
/** Builds the JSON payload for the telemetry ping. */
186191
static String buildPayload(String mode, String platformVersion) {
192+
return buildPayload(mode, platformVersion, EndpointType.UNKNOWN);
193+
}
194+
195+
/** Builds the JSON payload with an explicit endpoint_type classification. */
196+
static String buildPayload(String mode, String platformVersion, String endpointType) {
187197
try {
188198
ObjectMapper mapper = new ObjectMapper();
189199
ObjectNode root = mapper.createObjectNode();
@@ -198,6 +208,7 @@ static String buildPayload(String mode, String platformVersion) {
198208
root.put("arch", normalizeArch(System.getProperty("os.arch")));
199209
root.put("runtime_version", System.getProperty("java.version"));
200210
root.put("deployment_mode", mode);
211+
root.put("endpoint_type", endpointType);
201212

202213
ArrayNode features = mapper.createArrayNode();
203214
root.set("features", features);
@@ -211,6 +222,83 @@ static String buildPayload(String mode, String platformVersion) {
211222
}
212223
}
213224

225+
/**
226+
* Endpoint type classifications for telemetry. See issue #1525.
227+
*
228+
* <p>The raw URL is never sent to the checkpoint service — only the classification.
229+
*/
230+
public static final class EndpointType {
231+
public static final String LOCALHOST = "localhost";
232+
public static final String PRIVATE_NETWORK = "private_network";
233+
public static final String REMOTE = "remote";
234+
public static final String UNKNOWN = "unknown";
235+
236+
private EndpointType() {}
237+
}
238+
239+
private static final Pattern IPV4_PATTERN =
240+
Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$");
241+
242+
/**
243+
* Classifies the configured AxonFlow endpoint URL for analytics (#1525).
244+
*
245+
* <p>Returns one of {@link EndpointType#LOCALHOST}, {@link EndpointType#PRIVATE_NETWORK}, {@link
246+
* EndpointType#REMOTE}, or {@link EndpointType#UNKNOWN}.
247+
*
248+
* <p>The raw URL is never sent — only the classification.
249+
*/
250+
public static String classifyEndpoint(String url) {
251+
if (url == null || url.isEmpty()) {
252+
return EndpointType.UNKNOWN;
253+
}
254+
String host;
255+
try {
256+
URI u = new URI(url);
257+
host = u.getHost();
258+
if (host == null || host.isEmpty()) {
259+
return EndpointType.UNKNOWN;
260+
}
261+
} catch (URISyntaxException e) {
262+
return EndpointType.UNKNOWN;
263+
}
264+
host = host.toLowerCase();
265+
266+
// Strip IPv6 brackets if present.
267+
if (host.startsWith("[") && host.endsWith("]")) {
268+
host = host.substring(1, host.length() - 1);
269+
}
270+
271+
if ("localhost".equals(host)
272+
|| "0.0.0.0".equals(host)
273+
|| "::1".equals(host)
274+
|| host.endsWith(".localhost")) {
275+
return EndpointType.LOCALHOST;
276+
}
277+
278+
if (host.endsWith(".local")
279+
|| host.endsWith(".internal")
280+
|| host.endsWith(".lan")
281+
|| host.endsWith(".intranet")) {
282+
return EndpointType.PRIVATE_NETWORK;
283+
}
284+
285+
// IPv4 classification.
286+
Matcher m = IPV4_PATTERN.matcher(host);
287+
if (m.matches()) {
288+
int a = Integer.parseInt(m.group(1));
289+
int b = Integer.parseInt(m.group(2));
290+
if (a == 127) return EndpointType.LOCALHOST;
291+
if (a == 10) return EndpointType.PRIVATE_NETWORK;
292+
if (a == 192 && b == 168) return EndpointType.PRIVATE_NETWORK;
293+
if (a == 172 && b >= 16 && b <= 31) return EndpointType.PRIVATE_NETWORK;
294+
if (a == 169 && b == 254) return EndpointType.PRIVATE_NETWORK;
295+
return EndpointType.REMOTE;
296+
}
297+
298+
// Public hostname (not an IP, not a known private suffix).
299+
return EndpointType.REMOTE;
300+
}
301+
214302
/**
215303
* Detect platform version by calling the agent's /health endpoint. Returns null on any failure.
216304
*/
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
* Copyright 2026 AxonFlow
3+
* Licensed under the Apache License, Version 2.0.
4+
*
5+
* Tests for classifyEndpoint (issue #1525).
6+
*/
7+
package com.getaxonflow.sdk.telemetry;
8+
9+
import static org.junit.jupiter.api.Assertions.assertEquals;
10+
import static org.junit.jupiter.api.Assertions.assertFalse;
11+
12+
import org.junit.jupiter.api.DisplayName;
13+
import org.junit.jupiter.api.Test;
14+
15+
class TelemetryEndpointTypeTest {
16+
17+
// ---- localhost ----
18+
19+
@Test
20+
@DisplayName("localhost: hostname")
21+
void localhostHostname() {
22+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://localhost:8080"));
23+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("https://localhost"));
24+
}
25+
26+
@Test
27+
@DisplayName("localhost: 127.0.0.1")
28+
void localhostIPv4() {
29+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://127.0.0.1"));
30+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://127.0.0.1:8080"));
31+
}
32+
33+
@Test
34+
@DisplayName("localhost: 127/8")
35+
void localhost127Eight() {
36+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://127.1.2.3"));
37+
}
38+
39+
@Test
40+
@DisplayName("localhost: IPv6 ::1 with brackets")
41+
void localhostIPv6() {
42+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::1]"));
43+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::1]:8080"));
44+
}
45+
46+
@Test
47+
@DisplayName("localhost: 0.0.0.0")
48+
void localhostZero() {
49+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://0.0.0.0:8080"));
50+
}
51+
52+
@Test
53+
@DisplayName("localhost: *.localhost")
54+
void localhostSubdomain() {
55+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://agent.localhost"));
56+
}
57+
58+
@Test
59+
@DisplayName("localhost: case insensitive")
60+
void localhostCaseInsensitive() {
61+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://LOCALHOST"));
62+
}
63+
64+
// ---- private_network ----
65+
66+
@Test
67+
@DisplayName("private: RFC1918 10.x")
68+
void privateRFC1918Ten() {
69+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://10.0.0.1"));
70+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://10.1.2.3"));
71+
}
72+
73+
@Test
74+
@DisplayName("private: RFC1918 192.168.x")
75+
void privateRFC1918OneNineTwo() {
76+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://192.168.1.1"));
77+
}
78+
79+
@Test
80+
@DisplayName("private: RFC1918 172.16-31")
81+
void privateRFC1918OneSevenTwo() {
82+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://172.16.0.1"));
83+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://172.31.255.254"));
84+
}
85+
86+
@Test
87+
@DisplayName("private: boundary 172.15 and 172.32 NOT private")
88+
void privateRFC1918Boundary() {
89+
assertEquals("remote", TelemetryReporter.classifyEndpoint("http://172.15.0.1"));
90+
assertEquals("remote", TelemetryReporter.classifyEndpoint("http://172.32.0.1"));
91+
}
92+
93+
@Test
94+
@DisplayName("private: link-local 169.254")
95+
void privateLinkLocal() {
96+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://169.254.169.254"));
97+
}
98+
99+
@Test
100+
@DisplayName("private: hostname suffixes")
101+
void privateHostnameSuffixes() {
102+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.internal"));
103+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.local"));
104+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.lan"));
105+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://agent.intranet"));
106+
}
107+
108+
@Test
109+
@DisplayName("private: case insensitive .internal")
110+
void privateCaseInsensitive() {
111+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://AGENT.INTERNAL"));
112+
}
113+
114+
// ---- remote ----
115+
116+
@Test
117+
@DisplayName("remote: public hostnames")
118+
void remotePublicHostname() {
119+
assertEquals(
120+
"remote", TelemetryReporter.classifyEndpoint("https://production-us.getaxonflow.com"));
121+
assertEquals("remote", TelemetryReporter.classifyEndpoint("https://api.example.com"));
122+
}
123+
124+
@Test
125+
@DisplayName("remote: public IPv4")
126+
void remotePublicIPv4() {
127+
assertEquals("remote", TelemetryReporter.classifyEndpoint("http://8.8.8.8"));
128+
assertEquals("remote", TelemetryReporter.classifyEndpoint("http://1.1.1.1"));
129+
}
130+
131+
// ---- unknown ----
132+
133+
@Test
134+
@DisplayName("unknown: empty")
135+
void unknownEmpty() {
136+
assertEquals("unknown", TelemetryReporter.classifyEndpoint(""));
137+
}
138+
139+
@Test
140+
@DisplayName("unknown: null")
141+
void unknownNull() {
142+
assertEquals("unknown", TelemetryReporter.classifyEndpoint(null));
143+
}
144+
145+
@Test
146+
@DisplayName("unknown: malformed")
147+
void unknownMalformed() {
148+
assertEquals("unknown", TelemetryReporter.classifyEndpoint("not-a-url"));
149+
}
150+
151+
// ---- payload does not leak URL ----
152+
153+
@Test
154+
@DisplayName("payload does not contain raw URL")
155+
void payloadDoesNotLeakURL() {
156+
String secret = "https://my-private-cluster.banking-internal.example.com:8443";
157+
String type = TelemetryReporter.classifyEndpoint(secret);
158+
assertEquals("remote", type);
159+
String json = TelemetryReporter.buildPayload("production", null, type);
160+
assertFalse(json.contains("my-private-cluster"), "payload leaked hostname");
161+
assertFalse(json.contains("banking-internal"), "payload leaked domain");
162+
assertFalse(json.contains("8443"), "payload leaked port");
163+
assertFalse(json.contains("https://"), "payload leaked scheme");
164+
}
165+
}

0 commit comments

Comments
 (0)