Skip to content

Commit bb97a80

Browse files
fix: telemetry fixes, audit_tool_call, export audit (#116)
* fix: detect platform version in telemetry ping - Call /health endpoint to detect platform version before sending telemetry ping (2s timeout, silent failure on any error) - Platform version is included in the checkpoint payload when available - endpoint parameter is no longer unused (was reserved for this purpose) * fix: normalize os to lowercase in telemetry payload Report 'darwin' instead of 'Darwin' for consistency across SDKs. * fix: guard _detect_platform_version against non-dict JSON responses Add TypeError and AttributeError to exception tuple so non-object /health responses (arrays, strings) don't escape and kill the telemetry thread before POST. * fix: normalize arch values and eliminate thread-safety issue in payload construction * feat: add search_audit_logs and get_audit_logs_by_tenant methods (#878) Export AuditSearchRequest, AuditSearchResponse, AuditLogEntry, and AuditQueryOptions from the package __init__.py. The types, client methods, and tests were already implemented but not publicly exported. * feat: add audit_tool_call method (#1260) Add audit_tool_call SDK method to record non-LLM tool calls (MCP tools, API calls, function calls) in the audit trail. Posts to POST /api/v1/audit/tool-call with AuditToolCallRequest and returns AuditToolCallResponse. * fix: export all public types from package entry point Add 10 missing type exports (AxonFlowConfig, ConnectorHealthStatus, PolicyMatchInfo, ExfiltrationCheckInfo, DynamicPolicyMatch, DynamicPolicyInfo, ConnectorPolicyInfo, FindingSeverity, FindingStatus, Finding) to both imports and __all__ in axonflow/__init__.py. * fix: suppress telemetry for localhost endpoints When the SDK endpoint is localhost, 127.0.0.1, or ::1, telemetry pings are now suppressed unless telemetry_enabled is explicitly set to True. Prevents telemetry leaks during local development. * chore: bump version to 4.1.0 * docs: add v4.1.0 changelog entry * fix: resolve ruff lint errors in new audit and telemetry code - Sort imports in __init__.py (I001) - Extract string literal from ValueError (EM101) - Narrow blind Exception catch to ValueError (BLE001) - Move return to else block (TRY300) - Break long Field descriptions to stay under 100 chars (E501) * docs: add v4.1.0 changelog entry * docs: set v4.1.0 release date * fix: ruff format types.py
1 parent da0709b commit bb97a80

9 files changed

Lines changed: 465 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ 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+
## [4.1.0] - 2026-03-14
9+
10+
### Added
11+
12+
- `audit_tool_call()` — record non-LLM tool calls (API, MCP, function) in the audit trail. Returns audit ID, status, and timestamp. Requires Platform v5.1.0+
13+
- `get_audit_logs_by_tenant()` — retrieve audit logs for a tenant with optional pagination
14+
- `search_audit_logs()` — search audit logs with filters (client ID, request type, limit)
15+
16+
### Fixed
17+
18+
- Telemetry pings now suppressed for localhost/127.0.0.1/::1 endpoints unless `telemetry_enabled` is explicitly set to `True`. Prevents telemetry noise during local development.
19+
20+
---
21+
822
## [4.0.0] - 2026-03-09
923

1024
### Breaking Changes

axonflow/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@
9292
FEATAssessment,
9393
FEATAssessmentStatus,
9494
FEATPillar,
95+
Finding,
96+
FindingSeverity,
97+
FindingStatus,
9598
KillSwitch,
9699
KillSwitchEvent,
97100
KillSwitchEventType,
@@ -128,7 +131,14 @@
128131
CATEGORY_MEDIA_DOCUMENT,
129132
CATEGORY_MEDIA_PII,
130133
CATEGORY_MEDIA_SAFETY,
134+
AuditLogEntry,
135+
AuditQueryOptions,
131136
AuditResult,
137+
AuditSearchRequest,
138+
AuditSearchResponse,
139+
AuditToolCallRequest,
140+
AuditToolCallResponse,
141+
AxonFlowConfig,
132142
Budget,
133143
BudgetAlert,
134144
BudgetAlertsResponse,
@@ -145,15 +155,20 @@
145155
ClientRequest,
146156
ClientResponse,
147157
CodeArtifact,
158+
ConnectorHealthStatus,
148159
ConnectorInstallRequest,
149160
ConnectorMetadata,
161+
ConnectorPolicyInfo,
150162
ConnectorResponse,
151163
CreateBudgetRequest,
164+
DynamicPolicyInfo,
165+
DynamicPolicyMatch,
152166
ExecutionDetail,
153167
ExecutionExportOptions,
154168
ExecutionMode,
155169
ExecutionSnapshot,
156170
ExecutionSummary,
171+
ExfiltrationCheckInfo,
157172
ListBudgetsOptions,
158173
ListExecutionsOptions,
159174
ListExecutionsResponse,
@@ -178,6 +193,7 @@
178193
PolicyApprovalResult,
179194
PolicyEvaluationInfo,
180195
PolicyEvaluationResult,
196+
PolicyMatchInfo,
181197
PricingInfo,
182198
PricingListResponse,
183199
RateLimitInfo,
@@ -232,6 +248,7 @@
232248
"SDKCompatibility",
233249
"HealthResponse",
234250
# Configuration
251+
"AxonFlowConfig",
235252
"Mode",
236253
"RetryConfig",
237254
"CacheConfig",
@@ -257,6 +274,8 @@
257274
"ConnectorMetadata",
258275
"ConnectorInstallRequest",
259276
"ConnectorResponse",
277+
"ConnectorHealthStatus",
278+
"ConnectorPolicyInfo",
260279
# MCP Policy Check types
261280
"MCPCheckInputRequest",
262281
"MCPCheckInputResponse",
@@ -279,8 +298,20 @@
279298
# Gateway Mode types
280299
"RateLimitInfo",
281300
"PolicyApprovalResult",
301+
"PolicyMatchInfo",
302+
"ExfiltrationCheckInfo",
303+
"DynamicPolicyMatch",
304+
"DynamicPolicyInfo",
282305
"TokenUsage",
283306
"AuditResult",
307+
# Audit Log Read types (Issue #878)
308+
"AuditSearchRequest",
309+
"AuditSearchResponse",
310+
"AuditLogEntry",
311+
"AuditQueryOptions",
312+
# Audit Tool Call types (Issue #1260)
313+
"AuditToolCallRequest",
314+
"AuditToolCallResponse",
284315
# Execution Replay types
285316
"ExecutionSummary",
286317
"ExecutionSnapshot",
@@ -393,6 +424,9 @@
393424
"WebhookSubscription",
394425
"ListWebhooksResponse",
395426
# MAS FEAT Compliance types (Enterprise)
427+
"FindingSeverity",
428+
"FindingStatus",
429+
"Finding",
396430
"MaterialityClassification",
397431
"SystemStatus",
398432
"FEATAssessmentStatus",

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__ = "4.0.0"
3+
__version__ = "4.1.0"

axonflow/client.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@
129129
AuditResult,
130130
AuditSearchRequest,
131131
AuditSearchResponse,
132+
AuditToolCallRequest,
133+
AuditToolCallResponse,
132134
AxonFlowConfig,
133135
Budget,
134136
BudgetAlertsResponse,
@@ -1783,6 +1785,71 @@ async def audit_llm_call(
17831785
audit_id=response["audit_id"],
17841786
)
17851787

1788+
async def audit_tool_call(
1789+
self,
1790+
request: AuditToolCallRequest,
1791+
) -> AuditToolCallResponse:
1792+
"""Record a non-LLM tool call in the audit trail.
1793+
1794+
Use this to audit tool invocations (MCP tools, API calls, function
1795+
calls) that are not LLM calls but should still appear in the audit
1796+
trail for governance and compliance.
1797+
1798+
Args:
1799+
request: Tool call details including tool name, type, input/output,
1800+
and associated workflow/step information.
1801+
1802+
Returns:
1803+
AuditToolCallResponse confirming the audit entry was recorded.
1804+
1805+
Raises:
1806+
ValueError: If tool_name is empty.
1807+
AxonFlowError: If audit recording fails.
1808+
1809+
Example:
1810+
>>> from axonflow.types import AuditToolCallRequest
1811+
>>> result = await client.audit_tool_call(
1812+
... AuditToolCallRequest(
1813+
... tool_name="getUserInfo",
1814+
... tool_type="mcp",
1815+
... workflow_id="wf_abc123",
1816+
... success=True,
1817+
... duration_ms=45,
1818+
... )
1819+
... )
1820+
>>> print(result.audit_id)
1821+
"""
1822+
if not request.tool_name or not request.tool_name.strip():
1823+
msg = "tool_name is required and cannot be empty"
1824+
raise ValueError(msg)
1825+
1826+
request_body = request.model_dump(by_alias=True, exclude_none=True)
1827+
1828+
if self._config.debug:
1829+
self._logger.debug(
1830+
"Audit tool call request",
1831+
tool_name=request.tool_name,
1832+
tool_type=request.tool_type,
1833+
)
1834+
1835+
response = await self._request(
1836+
"POST",
1837+
"/api/v1/audit/tool-call",
1838+
json_data=request_body,
1839+
)
1840+
1841+
if self._config.debug:
1842+
self._logger.debug(
1843+
"Audit tool call complete",
1844+
audit_id=response.get("audit_id"),
1845+
)
1846+
1847+
return AuditToolCallResponse(
1848+
audit_id=response["audit_id"],
1849+
status=response["status"],
1850+
timestamp=response["timestamp"],
1851+
)
1852+
17861853
# =========================================================================
17871854
# Audit Log Read Methods
17881855
# =========================================================================
@@ -6197,6 +6264,13 @@ def audit_llm_call(
61976264
)
61986265
)
61996266

6267+
def audit_tool_call(
6268+
self,
6269+
request: AuditToolCallRequest,
6270+
) -> AuditToolCallResponse:
6271+
"""Record a non-LLM tool call in the audit trail."""
6272+
return self._run_sync(self._async_client.audit_tool_call(request))
6273+
62006274
# Policy CRUD sync wrappers
62016275

62026276
def list_static_policies(

axonflow/telemetry.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,29 +57,69 @@ def _is_telemetry_enabled(
5757
return mode != "sandbox"
5858

5959

60-
def _build_payload(mode: str) -> dict[str, object]:
60+
def _detect_platform_version(endpoint: str) -> str | None:
61+
"""Detect platform version by calling the agent's /health endpoint.
62+
63+
Returns the version string or None on any failure.
64+
"""
65+
try:
66+
resp = httpx.get(f"{endpoint}/health", timeout=2)
67+
if resp.status_code == _HTTP_OK:
68+
body = resp.json()
69+
version = body.get("version")
70+
if isinstance(version, str) and version:
71+
return version
72+
except (httpx.HTTPError, OSError, ValueError, KeyError, TypeError, AttributeError):
73+
pass
74+
return None
75+
76+
77+
def _is_localhost(endpoint: str) -> bool:
78+
"""Check whether the endpoint is a localhost address."""
79+
try:
80+
from urllib.parse import urlparse # noqa: PLC0415
81+
82+
host = urlparse(endpoint).hostname or ""
83+
except ValueError:
84+
return False
85+
else:
86+
return host in ("localhost", "127.0.0.1", "::1")
87+
88+
89+
def _normalize_arch(arch: str) -> str:
90+
"""Normalize architecture names to match other SDKs."""
91+
if arch == "aarch64":
92+
return "arm64"
93+
if arch == "x86_64":
94+
return "x64"
95+
return arch
96+
97+
98+
def _build_payload(mode: str, platform_version: str | None = None) -> dict[str, object]:
6199
"""Build the JSON payload for the checkpoint ping."""
62100
return {
63101
"sdk": "python",
64102
"sdk_version": _SDK_VERSION,
65-
"platform_version": None,
66-
"os": platform.system(),
67-
"arch": platform.machine(),
103+
"platform_version": platform_version,
104+
"os": platform.system().lower(),
105+
"arch": _normalize_arch(platform.machine()),
68106
"runtime_version": platform.python_version(),
69107
"deployment_mode": mode,
70108
"features": [],
71109
"instance_id": str(uuid.uuid4()),
72110
}
73111

74112

75-
def _do_ping(url: str, payload: dict[str, object], debug: bool) -> None:
113+
def _do_ping(url: str, mode: str, endpoint: str, debug: bool) -> None:
76114
"""Execute the HTTP POST (runs inside a daemon thread)."""
77115
try:
116+
platform_version = _detect_platform_version(endpoint) if endpoint else None
117+
payload = _build_payload(mode, platform_version)
78118
resp = httpx.post(url, json=payload, timeout=_TIMEOUT_SECONDS)
79119
if resp.status_code == _HTTP_OK:
80120
try:
81121
body = resp.json()
82-
except (ValueError, KeyError):
122+
except (ValueError, KeyError, TypeError, AttributeError):
83123
return
84124
latest = body.get("latest_version")
85125
if latest and latest != _SDK_VERSION:
@@ -91,15 +131,15 @@ def _do_ping(url: str, payload: dict[str, object], debug: bool) -> None:
91131
)
92132
if debug:
93133
logger.debug("Telemetry ping successful: %s", body)
94-
except (httpx.HTTPError, OSError, ValueError):
134+
except (httpx.HTTPError, OSError, ValueError, TypeError, AttributeError):
95135
# Silent failure -- never disrupt the caller.
96136
if debug:
97137
logger.debug("Telemetry ping failed (non-fatal)", exc_info=True)
98138

99139

100140
def send_telemetry_ping(
101141
mode: str,
102-
endpoint: str, # noqa: ARG001 kept for future platform_version detection
142+
endpoint: str,
103143
telemetry_enabled: bool | None,
104144
has_credentials: bool = False,
105145
debug: bool = False,
@@ -108,8 +148,8 @@ def send_telemetry_ping(
108148
109149
Args:
110150
mode: SDK operation mode (``"production"`` or ``"sandbox"``).
111-
endpoint: The AxonFlow agent endpoint (reserved for future
112-
platform_version detection).
151+
endpoint: The AxonFlow agent endpoint, used to detect the platform
152+
version via ``/health``.
113153
telemetry_enabled: Explicit config override. ``None`` means use the
114154
mode-based default.
115155
has_credentials: Whether the client was initialized with credentials
@@ -120,13 +160,16 @@ def send_telemetry_ping(
120160
if not _is_telemetry_enabled(mode, telemetry_enabled, has_credentials):
121161
return
122162

163+
# Suppress telemetry for localhost endpoints unless explicitly enabled.
164+
if telemetry_enabled is not True and _is_localhost(endpoint):
165+
return
166+
123167
logger.info(
124168
"AxonFlow: anonymous telemetry enabled. "
125169
"Opt out: AXONFLOW_TELEMETRY=off | https://docs.getaxonflow.com/telemetry"
126170
)
127171

128172
url = os.environ.get("AXONFLOW_CHECKPOINT_URL", "").strip() or _DEFAULT_CHECKPOINT_URL
129-
payload = _build_payload(mode)
130173

131-
t = threading.Thread(target=_do_ping, args=(url, payload, debug), daemon=True)
174+
t = threading.Thread(target=_do_ping, args=(url, mode, endpoint, debug), daemon=True)
132175
t.start()

axonflow/types.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,3 +1127,44 @@ class UpdateMediaGovernanceConfigRequest(BaseModel):
11271127
CATEGORY_MEDIA_BIOMETRIC: str = "media-biometric"
11281128
CATEGORY_MEDIA_DOCUMENT: str = "media-document"
11291129
CATEGORY_MEDIA_PII: str = "media-pii"
1130+
1131+
1132+
# =========================================================================
1133+
# Audit Tool Call Types
1134+
# =========================================================================
1135+
1136+
1137+
class AuditToolCallRequest(BaseModel):
1138+
"""Request to record a non-LLM tool call in the audit trail."""
1139+
1140+
model_config = ConfigDict(populate_by_name=True)
1141+
1142+
tool_name: str = Field(description="Name of the tool that was called")
1143+
tool_type: str | None = Field(
1144+
default=None, description="Type of tool (e.g., mcp, api, function)"
1145+
)
1146+
input: dict[str, Any] | None = Field(default=None, alias="input", description="Tool input data")
1147+
output: dict[str, Any] | None = Field(
1148+
default=None, alias="output", description="Tool output data"
1149+
)
1150+
workflow_id: str | None = Field(default=None, description="Associated workflow ID")
1151+
step_id: str | None = Field(default=None, description="Associated step ID")
1152+
user_id: str | None = Field(default=None, description="User who triggered the tool call")
1153+
duration_ms: int | None = Field(
1154+
default=None, description="Duration of the tool call in milliseconds"
1155+
)
1156+
policies_applied: list[str] | None = Field(
1157+
default=None, description="List of policies applied to this tool call"
1158+
)
1159+
success: bool | None = Field(default=None, description="Whether the tool call succeeded")
1160+
error_message: str | None = Field(
1161+
default=None, description="Error message if the tool call failed"
1162+
)
1163+
1164+
1165+
class AuditToolCallResponse(BaseModel):
1166+
"""Response from recording a tool call audit entry."""
1167+
1168+
audit_id: str = Field(description="Unique ID for the audit entry")
1169+
status: str = Field(description="Recording status (e.g., recorded)")
1170+
timestamp: str = Field(description="Timestamp when the audit entry was recorded")

0 commit comments

Comments
 (0)