Skip to content

Commit cfe00b3

Browse files
release(v6.2.0): telemetry endpoint_type field (#138)
* release(v6.2.0): telemetry endpoint_type + v6.2.0 version bump #1525 — Add endpoint_type field to checkpoint telemetry - New _classify_endpoint() helper in axonflow/telemetry.py classifies the configured URL into one of: localhost, private_network, remote, unknown. The raw URL is never sent or hashed — only the classification. - _build_payload() now threads endpoint_type through; _do_ping() calls the classifier using the agent endpoint from the client config. - 11 new unit tests cover localhost (hostname, IPv4, IPv6, 0.0.0.0, *.localhost), private network (RFC1918 v4, link-local, *.local, *.internal, *.lan, *.intranet), remote (public IPv4, public hostnames), malformed URLs (unknown), and case-insensitivity. - The payload test explicitly asserts no URL-shaped strings leak into the serialized payload. Version bumps: - axonflow/_version.py: 6.1.0 → 6.2.0 - pyproject.toml: 6.0.0 → 6.2.0 (was a stale version-mismatch bug) * docs(changelog): add v6.2.0 entry * fix(lint): ruff cleanup for telemetry.py - Extract _LOCALHOST_HOSTS frozenset to scope the S104 'bind to all interfaces' lint suppression to the tuple literal (0.0.0.0 is a valid localhost indicator in a client-endpoint context) - Split try/else to satisfy TRY300 (return statement should be in else block, not after try) - Sort imports in test file (I001) - Keep PLR0911 too-many-returns ignore as noqa comment (splitting the classifier further would hurt readability without benefit)
1 parent 158f1d7 commit cfe00b3

5 files changed

Lines changed: 142 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to the AxonFlow Python 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+
## [6.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+
14+
### Changed
15+
16+
- 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; the SDK continues to pass `PII_ACTION` through unchanged.
17+
18+
---
19+
820
## [6.1.0] - 2026-04-06
921

1022
### Added

axonflow/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Single source of truth for the AxonFlow SDK version."""
22

3-
__version__ = "6.1.0"
3+
__version__ = "6.2.0"

axonflow/telemetry.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313

1414
from __future__ import annotations
1515

16+
import ipaddress
1617
import logging
1718
import os
1819
import platform
1920
import threading
2021
import uuid
22+
from urllib.parse import urlparse
2123

2224
import httpx
2325

@@ -74,6 +76,54 @@ def _detect_platform_version(endpoint: str) -> str | None:
7476
return None
7577

7678

79+
# Loopback and any-interface addresses. "0.0.0.0" is intentionally included
80+
# here because it's the canonical bind-all-interfaces address and, in the
81+
# context of an AxonFlow client endpoint, means "talk to localhost".
82+
# noqa: S104 is scoped to the tuple below — this is not a bind operation.
83+
_LOCALHOST_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"}) # noqa: S104
84+
85+
86+
def _classify_endpoint(url: str | None) -> str: # noqa: PLR0911
87+
"""Classify the configured AxonFlow endpoint for analytics (#1525).
88+
89+
Returns one of:
90+
``"localhost"`` — localhost, 127.0.0.1, ::1, 0.0.0.0, ``*.localhost``
91+
``"private_network"`` — RFC1918 ranges, link-local, ``*.local``,
92+
``*.internal``, ``*.lan``, ``*.intranet``
93+
``"remote"`` — everything else
94+
``"unknown"`` — on any parse failure
95+
96+
The raw URL is never sent — only the classification. See issue #1525.
97+
"""
98+
if not url:
99+
return "unknown"
100+
try:
101+
host = urlparse(url).hostname
102+
except (ValueError, AttributeError):
103+
return "unknown"
104+
if not host:
105+
return "unknown"
106+
host = host.lower()
107+
108+
if host in _LOCALHOST_HOSTS or host.endswith(".localhost"):
109+
return "localhost"
110+
111+
if any(host.endswith(suffix) for suffix in (".local", ".internal", ".lan", ".intranet")):
112+
return "private_network"
113+
114+
# Try parsing as an IP address (v4 or v6).
115+
try:
116+
ip = ipaddress.ip_address(host)
117+
except ValueError:
118+
# Not an IP; treat remaining hostnames as remote.
119+
return "remote"
120+
if ip.is_loopback:
121+
return "localhost"
122+
if ip.is_private or ip.is_link_local:
123+
return "private_network"
124+
return "remote"
125+
126+
77127
def _normalize_arch(arch: str) -> str:
78128
"""Normalize architecture names to match other SDKs."""
79129
if arch == "aarch64":
@@ -83,7 +133,11 @@ def _normalize_arch(arch: str) -> str:
83133
return arch
84134

85135

86-
def _build_payload(mode: str, platform_version: str | None = None) -> dict[str, object]:
136+
def _build_payload(
137+
mode: str,
138+
platform_version: str | None = None,
139+
endpoint_type: str = "unknown",
140+
) -> dict[str, object]:
87141
"""Build the JSON payload for the checkpoint ping."""
88142
return {
89143
"sdk": "python",
@@ -93,6 +147,7 @@ def _build_payload(mode: str, platform_version: str | None = None) -> dict[str,
93147
"arch": _normalize_arch(platform.machine()),
94148
"runtime_version": platform.python_version(),
95149
"deployment_mode": mode,
150+
"endpoint_type": endpoint_type,
96151
"features": [],
97152
"instance_id": str(uuid.uuid4()),
98153
}
@@ -102,7 +157,8 @@ def _do_ping(url: str, mode: str, endpoint: str, debug: bool) -> None:
102157
"""Execute the HTTP POST (runs inside a daemon thread)."""
103158
try:
104159
platform_version = _detect_platform_version(endpoint) if endpoint else None
105-
payload = _build_payload(mode, platform_version)
160+
endpoint_type = _classify_endpoint(endpoint)
161+
payload = _build_payload(mode, platform_version, endpoint_type)
106162
resp = httpx.post(url, json=payload, timeout=_TIMEOUT_SECONDS)
107163
if resp.status_code == _HTTP_OK:
108164
try:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "axonflow"
7-
version = "6.0.0"
7+
version = "6.2.0"
88
description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code"
99
readme = "README.md"
1010
license = {text = "MIT"}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Unit tests for _classify_endpoint (issue #1525)."""
2+
3+
from axonflow.telemetry import _build_payload, _classify_endpoint
4+
5+
6+
class TestClassifyEndpoint:
7+
def test_empty_string_is_unknown(self):
8+
assert _classify_endpoint("") == "unknown"
9+
10+
def test_none_is_unknown(self):
11+
assert _classify_endpoint(None) == "unknown"
12+
13+
def test_localhost_variants(self):
14+
assert _classify_endpoint("http://localhost:8080") == "localhost"
15+
assert _classify_endpoint("https://localhost") == "localhost"
16+
assert _classify_endpoint("http://127.0.0.1") == "localhost"
17+
assert _classify_endpoint("http://127.0.0.1:8080") == "localhost"
18+
assert _classify_endpoint("http://[::1]") == "localhost"
19+
assert _classify_endpoint("http://0.0.0.0:8080") == "localhost"
20+
assert _classify_endpoint("http://agent.localhost") == "localhost"
21+
22+
def test_private_network_ipv4(self):
23+
# RFC1918
24+
assert _classify_endpoint("http://10.0.0.1") == "private_network"
25+
assert _classify_endpoint("http://10.1.2.3") == "private_network"
26+
assert _classify_endpoint("http://172.16.0.1") == "private_network"
27+
assert _classify_endpoint("http://172.31.255.254") == "private_network"
28+
assert _classify_endpoint("http://192.168.1.1") == "private_network"
29+
# Link-local
30+
assert _classify_endpoint("http://169.254.169.254") == "private_network"
31+
32+
def test_private_network_hostnames(self):
33+
assert _classify_endpoint("http://agent.internal") == "private_network"
34+
assert _classify_endpoint("http://agent.local") == "private_network"
35+
assert _classify_endpoint("http://agent.lan") == "private_network"
36+
assert _classify_endpoint("http://agent.intranet") == "private_network"
37+
38+
def test_remote(self):
39+
assert _classify_endpoint("https://production-us.getaxonflow.com") == "remote"
40+
assert _classify_endpoint("https://checkpoint.getaxonflow.com") == "remote"
41+
assert _classify_endpoint("https://api.example.com") == "remote"
42+
assert _classify_endpoint("http://8.8.8.8") == "remote"
43+
44+
def test_malformed_url(self):
45+
# urlparse treats "not-a-url" as a scheme-less path, hostname=None → unknown.
46+
assert _classify_endpoint("not-a-url") == "unknown"
47+
assert _classify_endpoint("://nohost") == "unknown"
48+
49+
def test_case_insensitive(self):
50+
assert _classify_endpoint("http://LOCALHOST:8080") == "localhost"
51+
assert _classify_endpoint("http://AGENT.INTERNAL") == "private_network"
52+
53+
54+
class TestPayloadIncludesEndpointType:
55+
def test_payload_default_endpoint_type_is_unknown(self):
56+
payload = _build_payload("production")
57+
assert payload["endpoint_type"] == "unknown"
58+
59+
def test_payload_with_explicit_endpoint_type(self):
60+
payload = _build_payload("production", None, "localhost")
61+
assert payload["endpoint_type"] == "localhost"
62+
63+
def test_payload_does_not_contain_raw_url(self):
64+
"""Critical non-goal: the SDK must never send the configured URL."""
65+
payload = _build_payload("production", None, "remote")
66+
payload_str = str(payload)
67+
# Assert no URL-like strings in the payload.
68+
assert "http://" not in payload_str
69+
assert "https://" not in payload_str
70+
assert "getaxonflow.com" not in payload_str

0 commit comments

Comments
 (0)