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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ contract — see `Removed` at the bottom of this entry for that.
production heartbeat — see the checkpoint-service
`IsValidIncomingStream` allowlist for the wire-side gate.

### Telemetry payload (v1 schema, axonflow-enterprise#2008)

- New heartbeat fields: `telemetry_type: "sdk"`, `profile` (from `AXONFLOW_PROFILE`, `unknown` when unset), `deployment_mode` aligned to `self_hosted | community_saas | unknown` via the new `classifyDeploymentMode` (host + `AXONFLOW_TRY=1` override). New `DeploymentMode` constants class.
- `classifyEndpoint` no longer returns `community-saas` and `EndpointType.COMMUNITY_SAAS` is removed — that value moved off endpoint_type onto deployment_mode; analytics queries on the legacy value must update.

## [7.1.0] - 2026-05-06 — X-Axonflow-Client header + scope-aware license validation

**Companion release to platform v7.7.0.** The Java SDK now sends an
Expand Down
73 changes: 68 additions & 5 deletions src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ public static boolean sendPingNow(
(checkpointUrl != null && !checkpointUrl.isEmpty()) ? checkpointUrl : DEFAULT_ENDPOINT;

String endpointType = classifyEndpoint(sdkEndpoint);
// v1 telemetry-schema: deployment_mode now derives from endpoint host
// (axonflow-enterprise#2008). config.Mode no longer drives this dimension.
String deploymentMode = classifyDeploymentMode(sdkEndpoint);

try {
long deadlineMs =
Expand All @@ -133,7 +136,7 @@ public static boolean sendPingNow(
? detectPlatformVersion(sdkEndpoint, healthBudgetMs)
: null;

String payload = buildPayload(mode, platformVersion, endpointType);
String payload = buildPayload(mode, platformVersion, endpointType, deploymentMode);

long postBudgetMs = Math.max(0L, deadlineMs - System.nanoTime() / 1_000_000L);
if (postBudgetMs < MIN_BUDGET_MS) {
Expand Down Expand Up @@ -192,14 +195,25 @@ public static boolean isEnabled(String axonflowTelemetry) {

/** Builds the JSON payload for the telemetry ping. */
static String buildPayload(String mode, String platformVersion) {
return buildPayload(mode, platformVersion, EndpointType.UNKNOWN);
return buildPayload(mode, platformVersion, EndpointType.UNKNOWN, DeploymentMode.UNKNOWN);
}

/** Builds the JSON payload with an explicit endpoint_type classification. */
static String buildPayload(String mode, String platformVersion, String endpointType) {
return buildPayload(mode, platformVersion, endpointType, DeploymentMode.UNKNOWN);
}

/**
* Builds the JSON payload with explicit endpoint_type + deployment_mode classifications
* (v1 telemetry-schema, axonflow-enterprise#2008).
*/
static String buildPayload(
String mode, String platformVersion, String endpointType, String deploymentMode) {
try {
ObjectMapper mapper = new ObjectMapper();
ObjectNode root = mapper.createObjectNode();
// v1 schema discriminator. Always "sdk" for this package.
root.put("telemetry_type", "sdk");
root.put("sdk", "java");
root.put("sdk_version", AxonFlowConfig.SDK_VERSION);
if (platformVersion != null) {
Expand All @@ -210,14 +224,24 @@ static String buildPayload(String mode, String platformVersion, String endpointT
root.put("os", normalizeOS(System.getProperty("os.name")));
root.put("arch", normalizeArch(System.getProperty("os.arch")));
root.put("runtime_version", System.getProperty("java.version"));
root.put("deployment_mode", mode);
// v1 schema deployment_mode allowlist: self_hosted | community_saas | unknown.
// The prior config.Mode-based dimension is removed — deployment_mode now
// reflects deployment topology only (see classifyDeploymentMode).
root.put("deployment_mode", deploymentMode);
root.put("endpoint_type", endpointType);

ArrayNode features = mapper.createArrayNode();
root.set("features", features);

root.put("instance_id", UUID.randomUUID().toString());

// v1 schema profile dimension. Free-form deployment classifier sourced from
// AXONFLOW_PROFILE; "unknown" when unset. Analytics dimension only.
String profileEnv = System.getenv("AXONFLOW_PROFILE");
String profile =
(profileEnv == null || profileEnv.trim().isEmpty()) ? "unknown" : profileEnv.trim();
root.put("profile", profile);

// Stream classifier: sandbox-mode clients self-tag so analytics can distinguish dev/test
// pings from production. Production-mode (and other modes) omit the field entirely so the
// server defaults to "heartbeat" — preserving byte-identical wire shape relative to v7.x
Expand All @@ -238,17 +262,57 @@ static String buildPayload(String mode, String platformVersion, String endpointT
* Endpoint type classifications for telemetry. See issue #1525.
*
* <p>The raw URL is never sent to the checkpoint service — only the classification.
*
* <p>As of v8.0 the legacy {@code COMMUNITY_SAAS} value is removed — deployment topology
* lives on {@link DeploymentMode} per the v1 schema (axonflow-enterprise#2008).
*/
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 COMMUNITY_SAAS = "community-saas";
public static final String UNKNOWN = "unknown";

private EndpointType() {}
}

/**
* Deployment-mode classifications for telemetry (v1 schema,
* axonflow-enterprise#2008). Reflects deployment topology — distinct from
* the endpoint reachability classification on {@link EndpointType}.
*/
public static final class DeploymentMode {
public static final String SELF_HOSTED = "self_hosted";
public static final String COMMUNITY_SAAS = "community_saas";
public static final String UNKNOWN = "unknown";

private DeploymentMode() {}
}

/**
* Classifies the configured AxonFlow endpoint into the v1 deployment-mode allowlist
* ({@code self_hosted | community_saas | unknown}). Community-SaaS detection fires on
* either an {@code *.try.getaxonflow.com} host or {@code AXONFLOW_TRY=1} (the explicit
* override path for tenants behind a custom hostname proxying try.getaxonflow.com).
* Empty/unparseable endpoint resolves to {@code unknown}.
*/
public static String classifyDeploymentMode(String url) {
if ("1".equals(System.getenv("AXONFLOW_TRY"))) return DeploymentMode.COMMUNITY_SAAS;
if (url == null || url.isEmpty()) return DeploymentMode.UNKNOWN;
String host;
try {
URI u = new URI(url);
host = u.getHost();
if (host == null || host.isEmpty()) return DeploymentMode.UNKNOWN;
} catch (URISyntaxException e) {
return DeploymentMode.UNKNOWN;
}
host = host.toLowerCase();
if ("try.getaxonflow.com".equals(host) || host.endsWith(".try.getaxonflow.com")) {
return DeploymentMode.COMMUNITY_SAAS;
}
return DeploymentMode.SELF_HOSTED;
}

private static final Pattern IPV4_PATTERN =
Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$");

Expand All @@ -261,7 +325,6 @@ private EndpointType() {}
* <p>The raw URL is never sent — only the classification.
*/
public static String classifyEndpoint(String url) {
if ("1".equals(System.getenv("AXONFLOW_TRY"))) return EndpointType.COMMUNITY_SAAS;
if (url == null || url.isEmpty()) {
return EndpointType.UNKNOWN;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ void testPayloadFormat() throws Exception {
String payload = TelemetryReporter.buildPayload("production", null);
JsonNode root = objectMapper.readTree(payload);

assertThat(root.get("telemetry_type").asText()).isEqualTo("sdk");
assertThat(root.get("sdk").asText()).isEqualTo("java");
assertThat(root.get("sdk_version").asText()).isEqualTo(AxonFlowConfig.SDK_VERSION);
assertThat(root.get("platform_version").isNull()).isTrue();
Expand All @@ -74,7 +75,9 @@ void testPayloadFormat() throws Exception {
assertThat(root.get("arch").asText())
.isEqualTo(TelemetryReporter.normalizeArch(System.getProperty("os.arch")));
assertThat(root.get("runtime_version").asText()).isEqualTo(System.getProperty("java.version"));
assertThat(root.get("deployment_mode").asText()).isEqualTo("production");
// v1 schema: 2-arg buildPayload defaults deployment_mode to "unknown".
assertThat(root.get("deployment_mode").asText()).isEqualTo("unknown");
assertThat(root.get("profile").asText()).isEqualTo("unknown");
assertThat(root.get("features").isArray()).isTrue();
assertThat(root.get("features").size()).isEqualTo(0);
assertThat(root.get("instance_id").asText()).isNotEmpty();
Expand All @@ -88,11 +91,14 @@ void testPayloadFormat() throws Exception {
}

@Test
@DisplayName("payload should reflect the given mode")
void testPayloadModeReflection() throws Exception {
String payload = TelemetryReporter.buildPayload("sandbox", null);
@DisplayName("payload deployment_mode reflects the v1 schema classifier output")
void testPayloadDeploymentModeReflection() throws Exception {
String payload =
TelemetryReporter.buildPayload(
"sandbox", null, TelemetryReporter.EndpointType.LOCALHOST,
TelemetryReporter.DeploymentMode.SELF_HOSTED);
JsonNode root = objectMapper.readTree(payload);
assertThat(root.get("deployment_mode").asText()).isEqualTo("sandbox");
assertThat(root.get("deployment_mode").asText()).isEqualTo("self_hosted");
}

@Test
Expand Down Expand Up @@ -152,9 +158,13 @@ void testCustomEndpoint(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
assertThat(requests).hasSize(1);

JsonNode body = objectMapper.readTree(requests.get(0).getBodyAsString());
assertThat(body.get("telemetry_type").asText()).isEqualTo("sdk");
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");
// v1 schema: deployment_mode classifies from sdk endpoint host; localhost
// resolves to self_hosted (the v1 allowlist removes the production label).
assertThat(body.get("deployment_mode").asText()).isEqualTo("self_hosted");
assertThat(body.get("profile").asText()).isEqualTo("unknown");
assertThat(body.get("instance_id").asText()).isNotEmpty();
// production-mode payloads still omit stream on the wire.
assertThat(body.has("stream")).isFalse();
Expand Down Expand Up @@ -250,7 +260,9 @@ void shouldFirePingWithStreamSandboxInSandboxMode(WireMockRuntimeInfo wmRuntimeI
var requests = WireMock.findAll(postRequestedFor(urlEqualTo("/v1/ping")));
assertThat(requests).hasSize(1);
JsonNode body = objectMapper.readTree(requests.get(0).getBodyAsString());
assertThat(body.get("deployment_mode").asText()).isEqualTo("sandbox");
// v1 schema: deployment_mode classifies from endpoint host (localhost ->
// self_hosted), NOT from config.Mode. The sandbox marker lives on `stream`.
assertThat(body.get("deployment_mode").asText()).isEqualTo("self_hosted");
assertThat(body.get("stream")).isNotNull();
assertThat(body.get("stream").asText()).isEqualTo("sandbox");
}
Expand Down Expand Up @@ -403,7 +415,8 @@ void testPayloadDeploymentModeEnterprise(WireMockRuntimeInfo wmRuntimeInfo) thro
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");
// v1 schema: deployment_mode is endpoint-derived; localhost -> self_hosted.
assertThat(body.get("deployment_mode").asText()).isEqualTo("self_hosted");
assertThat(body.get("os").asText())
.isEqualTo(TelemetryReporter.normalizeOS(System.getProperty("os.name")));
assertThat(body.get("arch").asText())
Expand Down
Loading