fact0-sdk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auditlog/__init__.py +17 -0
- auditlog/client.py +53 -0
- fact0/__init__.py +101 -0
- fact0/_http.py +125 -0
- fact0/audit/__init__.py +0 -0
- fact0/audit/async_client.py +129 -0
- fact0/audit/client.py +220 -0
- fact0/audit/models.py +57 -0
- fact0/audit/transport.py +35 -0
- fact0/exceptions.py +23 -0
- fact0/integrations/__init__.py +0 -0
- fact0/integrations/fastapi.py +45 -0
- fact0/integrations/langchain.py +96 -0
- fact0/py.typed +1 -0
- fact0/telemetry/__init__.py +0 -0
- fact0/telemetry/client.py +78 -0
- fact0/telemetry/context.py +102 -0
- fact0_sdk-1.0.0.dist-info/METADATA +59 -0
- fact0_sdk-1.0.0.dist-info/RECORD +21 -0
- fact0_sdk-1.0.0.dist-info/WHEEL +5 -0
- fact0_sdk-1.0.0.dist-info/top_level.txt +2 -0
auditlog/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Deprecated auditlog package - re-exports from fact0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
from .client import (AuditLogError, Client, Transport, TransportError,
|
|
8
|
+
ValidationError)
|
|
9
|
+
|
|
10
|
+
warnings.warn(
|
|
11
|
+
"The auditlog package is deprecated; use `pip install fact0-sdk` and `import fact0`.",
|
|
12
|
+
DeprecationWarning,
|
|
13
|
+
stacklevel=2,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = ["Client", "Transport", "TransportError", "ValidationError", "AuditLogError"]
|
|
17
|
+
__all__ = ["Client", "Transport", "TransportError", "ValidationError", "AuditLogError"]
|
auditlog/client.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Deprecated auditlog.Client - thin wrapper around fact0.audit.client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fact0._http import SyncHTTP, env_base_url
|
|
9
|
+
from fact0.audit.client import AuditClient
|
|
10
|
+
from fact0.exceptions import AuditLogError, TransportError, ValidationError
|
|
11
|
+
|
|
12
|
+
warnings.warn(
|
|
13
|
+
"The auditlog package is deprecated; use `pip install fact0-sdk` and `import fact0`.",
|
|
14
|
+
DeprecationWarning,
|
|
15
|
+
stacklevel=2,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Client:
|
|
20
|
+
"""Back-compat audit-only client (api_key + base_url constructor)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: str,
|
|
25
|
+
*,
|
|
26
|
+
base_url: str | None = None,
|
|
27
|
+
sync: bool = False,
|
|
28
|
+
transport: Any | None = None,
|
|
29
|
+
**audit_kwargs: Any,
|
|
30
|
+
):
|
|
31
|
+
resolved_base = (base_url or env_base_url()).rstrip("/")
|
|
32
|
+
if transport is not None and isinstance(transport, SyncHTTP):
|
|
33
|
+
http = transport
|
|
34
|
+
else:
|
|
35
|
+
http = SyncHTTP(resolved_base, api_key, sync_ingest=sync)
|
|
36
|
+
if transport is not None:
|
|
37
|
+
audit_kwargs.setdefault("transport", transport)
|
|
38
|
+
self._audit = AuditClient(http, **audit_kwargs)
|
|
39
|
+
|
|
40
|
+
def log(self, **kwargs: Any) -> None:
|
|
41
|
+
self._audit.log(**kwargs)
|
|
42
|
+
|
|
43
|
+
def flush(self) -> None:
|
|
44
|
+
self._audit.flush()
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._audit.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Legacy alias: auditlog.Transport was SyncHTTP with positional url/key args.
|
|
51
|
+
Transport = SyncHTTP
|
|
52
|
+
|
|
53
|
+
__all__ = ["Client", "Transport", "TransportError", "ValidationError", "AuditLogError"]
|
fact0/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Fact0 Python SDK - audit log and execution telemetry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._http import SyncHTTP, env_api_key, env_base_url
|
|
8
|
+
from .audit.async_client import AsyncAuditClient
|
|
9
|
+
from .audit.client import AuditClient
|
|
10
|
+
from .audit.models import Actor, ActorType, Outcome, Resource
|
|
11
|
+
from .audit.transport import AuditTransport
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
AuditLogError,
|
|
14
|
+
Fact0Error,
|
|
15
|
+
TransportError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .telemetry.client import TelemetryClient
|
|
19
|
+
|
|
20
|
+
__version__ = "1.0.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Client",
|
|
24
|
+
"AsyncClient",
|
|
25
|
+
"AuditClient",
|
|
26
|
+
"AsyncAuditClient",
|
|
27
|
+
"TelemetryClient",
|
|
28
|
+
"Actor",
|
|
29
|
+
"Resource",
|
|
30
|
+
"Outcome",
|
|
31
|
+
"ActorType",
|
|
32
|
+
"Fact0Error",
|
|
33
|
+
"AuditLogError",
|
|
34
|
+
"ValidationError",
|
|
35
|
+
"TransportError",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Client:
|
|
41
|
+
"""Unified Fact0 client with audit and telemetry modules."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
sync: bool = False,
|
|
49
|
+
**audit_kwargs,
|
|
50
|
+
):
|
|
51
|
+
resolved_key = api_key or env_api_key() or ""
|
|
52
|
+
resolved_base = (base_url or env_base_url()).rstrip("/")
|
|
53
|
+
custom_transport = audit_kwargs.pop("transport", None)
|
|
54
|
+
if custom_transport is not None and isinstance(custom_transport, SyncHTTP):
|
|
55
|
+
http = custom_transport
|
|
56
|
+
else:
|
|
57
|
+
http = SyncHTTP(resolved_base, resolved_key or None, sync_ingest=sync)
|
|
58
|
+
if custom_transport is not None:
|
|
59
|
+
audit_kwargs["transport"] = custom_transport
|
|
60
|
+
self._http = http
|
|
61
|
+
self.audit = AuditClient(http, **audit_kwargs)
|
|
62
|
+
self.telemetry = TelemetryClient(http)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
self.audit.close()
|
|
66
|
+
|
|
67
|
+
def log(self, **kwargs) -> None:
|
|
68
|
+
self.audit.log(**kwargs)
|
|
69
|
+
|
|
70
|
+
def flush(self) -> None:
|
|
71
|
+
self.audit.flush()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AsyncClient:
|
|
75
|
+
"""Async Fact0 client."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
api_key: str | None = None,
|
|
80
|
+
*,
|
|
81
|
+
base_url: str | None = None,
|
|
82
|
+
sync: bool = False,
|
|
83
|
+
):
|
|
84
|
+
resolved_key = api_key or env_api_key() or ""
|
|
85
|
+
if not resolved_key:
|
|
86
|
+
raise ValueError("api_key is required for AsyncClient audit operations")
|
|
87
|
+
resolved_base = (base_url or env_base_url()).rstrip("/")
|
|
88
|
+
self.audit = AsyncAuditClient(resolved_base, resolved_key, sync=sync)
|
|
89
|
+
self._base_url = resolved_base
|
|
90
|
+
self.telemetry = TelemetryClient(
|
|
91
|
+
SyncHTTP(resolved_base, resolved_key, sync_ingest=sync)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def close(self) -> None:
|
|
95
|
+
await self.audit.close()
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> AsyncClient:
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
async def __aexit__(self, *args) -> None:
|
|
101
|
+
await self.close()
|
fact0/_http.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Shared HTTP utilities with retries and auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .exceptions import TransportError
|
|
13
|
+
|
|
14
|
+
_log = logging.getLogger("fact0")
|
|
15
|
+
|
|
16
|
+
_RETRYABLE = {429, 500, 502, 503, 504}
|
|
17
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
18
|
+
USER_AGENT = "fact0-python/1.0.0"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def env_base_url(default: str = "https://api.fact0.io") -> str:
|
|
22
|
+
return (
|
|
23
|
+
os.environ.get("FACT0_BASE_URL")
|
|
24
|
+
or os.environ.get("FACT0_BASE_URL")
|
|
25
|
+
or default
|
|
26
|
+
).rstrip("/")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def env_api_key() -> str | None:
|
|
30
|
+
return (
|
|
31
|
+
os.environ.get("FACT0_API_KEY")
|
|
32
|
+
or os.environ.get("FACT0_API_KEY")
|
|
33
|
+
or os.environ.get("AUDITLOG_API_KEY")
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SyncHTTP:
|
|
38
|
+
"""Synchronous HTTP client with bounded retries."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
base_url: str,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
46
|
+
max_retries: int = 3,
|
|
47
|
+
backoff_base_s: float = 0.2,
|
|
48
|
+
sync_ingest: bool = False,
|
|
49
|
+
session: requests.Session | None = None,
|
|
50
|
+
):
|
|
51
|
+
self.base_url = base_url.rstrip("/")
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self.timeout_s = timeout_s
|
|
54
|
+
self.max_retries = max_retries
|
|
55
|
+
self.backoff_base_s = backoff_base_s
|
|
56
|
+
self.sync_ingest = sync_ingest
|
|
57
|
+
self.session = session or requests.Session()
|
|
58
|
+
|
|
59
|
+
def _headers(self, *, auth: bool = True, sync: bool | None = None) -> dict[str, str]:
|
|
60
|
+
headers = {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"User-Agent": USER_AGENT,
|
|
63
|
+
}
|
|
64
|
+
if auth and self.api_key:
|
|
65
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
66
|
+
use_sync = self.sync_ingest if sync is None else sync
|
|
67
|
+
if use_sync:
|
|
68
|
+
headers["X-Fact0-Sync"] = "true"
|
|
69
|
+
return headers
|
|
70
|
+
|
|
71
|
+
def request(
|
|
72
|
+
self,
|
|
73
|
+
method: str,
|
|
74
|
+
path: str,
|
|
75
|
+
*,
|
|
76
|
+
json_body: Any | None = None,
|
|
77
|
+
params: Mapping[str, Any] | None = None,
|
|
78
|
+
auth: bool = True,
|
|
79
|
+
sync: bool | None = None,
|
|
80
|
+
expect_json: bool = True,
|
|
81
|
+
) -> Any:
|
|
82
|
+
url = f"{self.base_url}{path}"
|
|
83
|
+
last_err: Exception | None = None
|
|
84
|
+
for attempt in range(self.max_retries + 1):
|
|
85
|
+
try:
|
|
86
|
+
resp = self.session.request(
|
|
87
|
+
method,
|
|
88
|
+
url,
|
|
89
|
+
json=json_body,
|
|
90
|
+
params=params,
|
|
91
|
+
headers=self._headers(auth=auth, sync=sync),
|
|
92
|
+
timeout=self.timeout_s,
|
|
93
|
+
)
|
|
94
|
+
except requests.RequestException as exc:
|
|
95
|
+
last_err = exc
|
|
96
|
+
_log.warning("network error (attempt %d): %s", attempt + 1, exc)
|
|
97
|
+
else:
|
|
98
|
+
retry_after = resp.headers.get("Retry-After")
|
|
99
|
+
if resp.status_code < 300:
|
|
100
|
+
if not expect_json:
|
|
101
|
+
return resp.content
|
|
102
|
+
try:
|
|
103
|
+
return resp.json()
|
|
104
|
+
except ValueError:
|
|
105
|
+
return {}
|
|
106
|
+
if resp.status_code not in _RETRYABLE:
|
|
107
|
+
raise TransportError(
|
|
108
|
+
f"{method} {path} returned {resp.status_code}: {resp.text[:200]}",
|
|
109
|
+
status_code=resp.status_code,
|
|
110
|
+
)
|
|
111
|
+
last_err = TransportError(
|
|
112
|
+
f"{method} {path} returned {resp.status_code}",
|
|
113
|
+
status_code=resp.status_code,
|
|
114
|
+
)
|
|
115
|
+
if retry_after:
|
|
116
|
+
try:
|
|
117
|
+
time.sleep(float(retry_after))
|
|
118
|
+
continue
|
|
119
|
+
except ValueError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
if attempt < self.max_retries:
|
|
123
|
+
time.sleep(self.backoff_base_s * (2**attempt))
|
|
124
|
+
|
|
125
|
+
raise TransportError(f"giving up after {self.max_retries + 1} attempts: {last_err}")
|
fact0/audit/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Async audit client using httpx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
14
|
+
|
|
15
|
+
from .._http import DEFAULT_TIMEOUT_S, USER_AGENT
|
|
16
|
+
from ..exceptions import TransportError, ValidationError
|
|
17
|
+
from .models import Event
|
|
18
|
+
|
|
19
|
+
_log = logging.getLogger("fact0")
|
|
20
|
+
|
|
21
|
+
_RETRYABLE = {429, 500, 502, 503, 504}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncAuditClient:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
base_url: str,
|
|
28
|
+
api_key: str,
|
|
29
|
+
*,
|
|
30
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
31
|
+
sync: bool = False,
|
|
32
|
+
client: httpx.AsyncClient | None = None,
|
|
33
|
+
):
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.sync = sync
|
|
37
|
+
self._client = client
|
|
38
|
+
self._owned = client is None
|
|
39
|
+
self.timeout_s = timeout_s
|
|
40
|
+
self._buf: list[dict[str, Any]] = []
|
|
41
|
+
|
|
42
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
43
|
+
if self._client is None:
|
|
44
|
+
self._client = httpx.AsyncClient(timeout=self.timeout_s)
|
|
45
|
+
return self._client
|
|
46
|
+
|
|
47
|
+
def _headers(self) -> dict[str, str]:
|
|
48
|
+
headers = {
|
|
49
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"User-Agent": USER_AGENT,
|
|
52
|
+
}
|
|
53
|
+
if self.sync:
|
|
54
|
+
headers["X-Fact0-Sync"] = "true"
|
|
55
|
+
return headers
|
|
56
|
+
|
|
57
|
+
async def _request(
|
|
58
|
+
self,
|
|
59
|
+
method: str,
|
|
60
|
+
path: str,
|
|
61
|
+
*,
|
|
62
|
+
json_body: Any | None = None,
|
|
63
|
+
params: dict[str, Any] | None = None,
|
|
64
|
+
expect_json: bool = True,
|
|
65
|
+
) -> Any:
|
|
66
|
+
client = await self._get_client()
|
|
67
|
+
url = f"{self.base_url}{path}"
|
|
68
|
+
resp = await client.request(method, url, json=json_body, params=params, headers=self._headers())
|
|
69
|
+
if resp.status_code >= 300:
|
|
70
|
+
raise TransportError(f"{method} {path} -> {resp.status_code}", status_code=resp.status_code)
|
|
71
|
+
if not expect_json:
|
|
72
|
+
return resp.content
|
|
73
|
+
try:
|
|
74
|
+
return resp.json()
|
|
75
|
+
except ValueError:
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
async def log(self, **fields: Any) -> None:
|
|
79
|
+
wire = self._validate(**fields)
|
|
80
|
+
self._buf.append(wire)
|
|
81
|
+
|
|
82
|
+
async def flush(self) -> None:
|
|
83
|
+
if not self._buf:
|
|
84
|
+
return
|
|
85
|
+
chunk, self._buf = self._buf, []
|
|
86
|
+
await self._request("POST", "/v1/events/batch", json_body={"events": chunk})
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
await self.flush()
|
|
90
|
+
if self._owned and self._client is not None:
|
|
91
|
+
await self._client.aclose()
|
|
92
|
+
self._client = None
|
|
93
|
+
|
|
94
|
+
async def get_event(self, event_id: str) -> dict[str, Any]:
|
|
95
|
+
return await self._request("GET", f"/v1/events/{event_id}")
|
|
96
|
+
|
|
97
|
+
async def list_events(self, **filters: Any) -> dict[str, Any]:
|
|
98
|
+
return await self._request("GET", "/v1/events", params={k: v for k, v in filters.items() if v is not None})
|
|
99
|
+
|
|
100
|
+
async def verify(self, **params: Any) -> dict[str, Any]:
|
|
101
|
+
return await self._request("GET", "/v1/verify", params={k: v for k, v in params.items() if v is not None})
|
|
102
|
+
|
|
103
|
+
async def verify_event(self, event_id: str) -> dict[str, Any]:
|
|
104
|
+
return await self._request("GET", f"/v1/events/{event_id}/verify")
|
|
105
|
+
|
|
106
|
+
async def get_receipt(self, receipt_id: str) -> dict[str, Any]:
|
|
107
|
+
return await self._request("GET", f"/v1/receipts/{receipt_id}")
|
|
108
|
+
|
|
109
|
+
async def wait_for_receipt(self, receipt_id: str, *, timeout_s: float = 30.0) -> dict[str, Any]:
|
|
110
|
+
import time
|
|
111
|
+
|
|
112
|
+
deadline = time.monotonic() + timeout_s
|
|
113
|
+
while time.monotonic() < deadline:
|
|
114
|
+
body = await self.get_receipt(receipt_id)
|
|
115
|
+
if body.get("status") in ("committed", "failed"):
|
|
116
|
+
return body
|
|
117
|
+
await asyncio.sleep(0.2)
|
|
118
|
+
raise TransportError(f"receipt {receipt_id} not settled within {timeout_s}s")
|
|
119
|
+
|
|
120
|
+
def _validate(self, **kwargs: Any) -> dict[str, Any]:
|
|
121
|
+
if kwargs.get("event_id"):
|
|
122
|
+
kwargs["id"] = kwargs.pop("event_id")
|
|
123
|
+
elif "id" not in kwargs:
|
|
124
|
+
kwargs["id"] = str(uuid.uuid4())
|
|
125
|
+
try:
|
|
126
|
+
evt = Event(**kwargs)
|
|
127
|
+
except PydanticValidationError as exc:
|
|
128
|
+
raise ValidationError(str(exc)) from exc
|
|
129
|
+
return evt.model_dump(exclude_none=True, mode="json")
|
fact0/audit/client.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Synchronous audit log client with background batching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterator, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
15
|
+
|
|
16
|
+
from .._http import DEFAULT_TIMEOUT_S, SyncHTTP
|
|
17
|
+
from ..exceptions import AuditLogError, TransportError, ValidationError
|
|
18
|
+
from .models import Event
|
|
19
|
+
from .transport import AuditTransport
|
|
20
|
+
|
|
21
|
+
_log = logging.getLogger("fact0")
|
|
22
|
+
|
|
23
|
+
DEFAULT_BATCH_MAX_SIZE = 100
|
|
24
|
+
DEFAULT_BATCH_MAX_WAIT_MS = 500
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuditClient:
|
|
28
|
+
"""Buffered, thread-safe audit log client."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
http: SyncHTTP,
|
|
33
|
+
*,
|
|
34
|
+
batch_max_size: int = DEFAULT_BATCH_MAX_SIZE,
|
|
35
|
+
batch_max_wait_ms: int = DEFAULT_BATCH_MAX_WAIT_MS,
|
|
36
|
+
raise_on_error: bool = False,
|
|
37
|
+
dead_letter_path: Optional[str] = None,
|
|
38
|
+
poll_receipts: bool = True,
|
|
39
|
+
transport: Any | None = None,
|
|
40
|
+
):
|
|
41
|
+
self._http = http
|
|
42
|
+
if transport is not None and hasattr(transport, "_audit"):
|
|
43
|
+
self._transport = transport._audit
|
|
44
|
+
elif isinstance(transport, SyncHTTP):
|
|
45
|
+
self._http = transport
|
|
46
|
+
self._transport = AuditTransport(transport)
|
|
47
|
+
elif transport is not None:
|
|
48
|
+
self._transport = transport
|
|
49
|
+
else:
|
|
50
|
+
self._transport = AuditTransport(http)
|
|
51
|
+
self._batch_max_size = batch_max_size
|
|
52
|
+
self._batch_max_wait_s = batch_max_wait_ms / 1000.0
|
|
53
|
+
self._raise_on_error = raise_on_error
|
|
54
|
+
self._dead_letter_path = dead_letter_path
|
|
55
|
+
self._poll_receipts = poll_receipts
|
|
56
|
+
|
|
57
|
+
self._buf: list[dict[str, Any]] = []
|
|
58
|
+
self._cond = threading.Condition()
|
|
59
|
+
self._stopped = False
|
|
60
|
+
self._flusher = threading.Thread(target=self._run, daemon=True, name="fact0-audit-flush")
|
|
61
|
+
self._flusher.start()
|
|
62
|
+
atexit.register(self.close)
|
|
63
|
+
|
|
64
|
+
def log(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
actor: dict[str, Any],
|
|
68
|
+
action: str,
|
|
69
|
+
resource: dict[str, Any],
|
|
70
|
+
outcome: str,
|
|
71
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
72
|
+
timestamp: Optional[datetime] = None,
|
|
73
|
+
event_id: Optional[str] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
wire = self._validate_event(
|
|
76
|
+
actor=actor,
|
|
77
|
+
action=action,
|
|
78
|
+
resource=resource,
|
|
79
|
+
outcome=outcome,
|
|
80
|
+
metadata=metadata,
|
|
81
|
+
timestamp=timestamp,
|
|
82
|
+
event_id=event_id,
|
|
83
|
+
)
|
|
84
|
+
with self._cond:
|
|
85
|
+
was_empty = not self._buf
|
|
86
|
+
self._buf.append(wire)
|
|
87
|
+
if was_empty or len(self._buf) >= self._batch_max_size:
|
|
88
|
+
self._cond.notify_all()
|
|
89
|
+
|
|
90
|
+
def log_batch(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
91
|
+
validated = [self._validate_wire(e) for e in events]
|
|
92
|
+
return self._send(validated)
|
|
93
|
+
|
|
94
|
+
def flush(self) -> None:
|
|
95
|
+
while True:
|
|
96
|
+
with self._cond:
|
|
97
|
+
if not self._buf:
|
|
98
|
+
return
|
|
99
|
+
chunk = self._buf[: self._batch_max_size]
|
|
100
|
+
del self._buf[: self._batch_max_size]
|
|
101
|
+
self._send(chunk)
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
with self._cond:
|
|
105
|
+
if self._stopped:
|
|
106
|
+
return
|
|
107
|
+
self._stopped = True
|
|
108
|
+
self._cond.notify_all()
|
|
109
|
+
self._flusher.join(timeout=5.0)
|
|
110
|
+
self.flush()
|
|
111
|
+
|
|
112
|
+
def get_event(self, event_id: str) -> dict[str, Any]:
|
|
113
|
+
return self._http.request("GET", f"/v1/events/{event_id}")
|
|
114
|
+
|
|
115
|
+
def list_events(self, **filters: Any) -> dict[str, Any]:
|
|
116
|
+
params = {k: v for k, v in filters.items() if v is not None}
|
|
117
|
+
return self._http.request("GET", "/v1/events", params=params)
|
|
118
|
+
|
|
119
|
+
def get_receipt(self, receipt_id: str) -> dict[str, Any]:
|
|
120
|
+
return self._transport.get_receipt(receipt_id)
|
|
121
|
+
|
|
122
|
+
def wait_for_receipt(self, receipt_id: str, *, timeout_s: float = 30.0) -> dict[str, Any]:
|
|
123
|
+
return self._transport.poll_receipt(receipt_id, timeout_s=timeout_s)
|
|
124
|
+
|
|
125
|
+
def verify(self, **params: Any) -> dict[str, Any]:
|
|
126
|
+
return self._http.request("GET", "/v1/verify", params={k: v for k, v in params.items() if v is not None})
|
|
127
|
+
|
|
128
|
+
def verify_event(self, event_id: str) -> dict[str, Any]:
|
|
129
|
+
return self._http.request("GET", f"/v1/events/{event_id}/verify")
|
|
130
|
+
|
|
131
|
+
def export_pdf(self, **params: Any) -> bytes:
|
|
132
|
+
return self._http.request(
|
|
133
|
+
"GET", "/v1/export/pdf", params={k: v for k, v in params.items() if v is not None}, expect_json=False
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def export_evidence_pack(self, **params: Any) -> bytes:
|
|
137
|
+
return self._http.request(
|
|
138
|
+
"GET",
|
|
139
|
+
"/v1/export/evidence-pack",
|
|
140
|
+
params={k: v for k, v in params.items() if v is not None},
|
|
141
|
+
expect_json=False,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def stream_events(self) -> Iterator[dict[str, Any]]:
|
|
145
|
+
import requests
|
|
146
|
+
|
|
147
|
+
url = f"{self._http.base_url}/v1/events/stream"
|
|
148
|
+
headers = self._http._headers()
|
|
149
|
+
headers["Accept"] = "text/event-stream"
|
|
150
|
+
with requests.get(url, headers=headers, stream=True, timeout=self._http.timeout_s) as resp:
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
data_lines: list[str] = []
|
|
153
|
+
for raw in resp.iter_lines(decode_unicode=True):
|
|
154
|
+
if raw is None:
|
|
155
|
+
continue
|
|
156
|
+
if raw.startswith("data:"):
|
|
157
|
+
data_lines.append(raw[5:].strip())
|
|
158
|
+
elif raw == "" and data_lines:
|
|
159
|
+
payload = "".join(data_lines)
|
|
160
|
+
data_lines.clear()
|
|
161
|
+
if payload and payload != "{}":
|
|
162
|
+
yield json.loads(payload)
|
|
163
|
+
|
|
164
|
+
def _validate_event(self, **kwargs: Any) -> dict[str, Any]:
|
|
165
|
+
kwargs.pop("event_id", None)
|
|
166
|
+
if kwargs.get("metadata") is None:
|
|
167
|
+
kwargs.pop("metadata", None)
|
|
168
|
+
if "event_id" in kwargs and kwargs["event_id"]:
|
|
169
|
+
kwargs["id"] = kwargs.pop("event_id")
|
|
170
|
+
elif "id" not in kwargs:
|
|
171
|
+
kwargs["id"] = str(uuid.uuid4())
|
|
172
|
+
return self._validate_wire(kwargs)
|
|
173
|
+
|
|
174
|
+
def _validate_wire(self, fields: dict[str, Any]) -> dict[str, Any]:
|
|
175
|
+
try:
|
|
176
|
+
evt = Event(**fields)
|
|
177
|
+
except PydanticValidationError as exc:
|
|
178
|
+
raise ValidationError(str(exc)) from exc
|
|
179
|
+
return evt.model_dump(exclude_none=True, mode="json")
|
|
180
|
+
|
|
181
|
+
def _run(self) -> None:
|
|
182
|
+
while True:
|
|
183
|
+
with self._cond:
|
|
184
|
+
if self._stopped:
|
|
185
|
+
return
|
|
186
|
+
if not self._buf:
|
|
187
|
+
self._cond.wait()
|
|
188
|
+
if self._stopped:
|
|
189
|
+
return
|
|
190
|
+
if len(self._buf) < self._batch_max_size:
|
|
191
|
+
self._cond.wait(timeout=self._batch_max_wait_s)
|
|
192
|
+
if self._stopped:
|
|
193
|
+
return
|
|
194
|
+
chunk = self._buf[: self._batch_max_size]
|
|
195
|
+
del self._buf[: self._batch_max_size]
|
|
196
|
+
if chunk:
|
|
197
|
+
self._send(chunk)
|
|
198
|
+
|
|
199
|
+
def _send(self, buf: list[dict[str, Any]]) -> dict[str, Any]:
|
|
200
|
+
try:
|
|
201
|
+
result = self._transport.post_batch(buf)
|
|
202
|
+
receipt_id = result.get("receipt_id")
|
|
203
|
+
if receipt_id and self._poll_receipts and result.get("status") == "queued":
|
|
204
|
+
self._transport.poll_receipt(receipt_id)
|
|
205
|
+
return result
|
|
206
|
+
except (TransportError, AuditLogError) as exc:
|
|
207
|
+
self._dead_letter(buf, exc)
|
|
208
|
+
if self._raise_on_error:
|
|
209
|
+
raise
|
|
210
|
+
_log.warning("audit flush dropped %d events: %s", len(buf), exc)
|
|
211
|
+
return {}
|
|
212
|
+
|
|
213
|
+
def _dead_letter(self, buf: list[dict[str, Any]], exc: Exception) -> None:
|
|
214
|
+
if not self._dead_letter_path:
|
|
215
|
+
return
|
|
216
|
+
path = Path(self._dead_letter_path)
|
|
217
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
with path.open("a", encoding="utf-8") as f:
|
|
219
|
+
for evt in buf:
|
|
220
|
+
f.write(json.dumps({"error": str(exc), "event": evt}) + "\n")
|
fact0/audit/models.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Pydantic models for audit events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ActorType(str, Enum):
|
|
13
|
+
HUMAN = "human"
|
|
14
|
+
AGENT = "agent"
|
|
15
|
+
SYSTEM = "system"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Outcome(str, Enum):
|
|
19
|
+
SUCCESS = "success"
|
|
20
|
+
FAILURE = "failure"
|
|
21
|
+
ERROR = "error"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Actor(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
id: str = Field(min_length=1)
|
|
27
|
+
type: ActorType
|
|
28
|
+
email: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Resource(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
id: str = Field(min_length=1)
|
|
34
|
+
type: str = Field(min_length=1)
|
|
35
|
+
name: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Event(BaseModel):
|
|
39
|
+
"""Wire shape of an audit event."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
actor: Actor
|
|
44
|
+
action: str = Field(min_length=1)
|
|
45
|
+
resource: Resource
|
|
46
|
+
outcome: Outcome
|
|
47
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
id: Optional[str] = None
|
|
49
|
+
timestamp: Optional[datetime] = None
|
|
50
|
+
|
|
51
|
+
@field_serializer("timestamp")
|
|
52
|
+
def _serialize_ts(self, v: Optional[datetime]) -> Optional[str]:
|
|
53
|
+
if v is None:
|
|
54
|
+
return None
|
|
55
|
+
if v.tzinfo is None:
|
|
56
|
+
v = v.replace(tzinfo=timezone.utc)
|
|
57
|
+
return v.isoformat()
|
fact0/audit/transport.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Audit HTTP transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._http import SyncHTTP
|
|
9
|
+
from ..exceptions import TransportError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditTransport:
|
|
13
|
+
def __init__(self, http: SyncHTTP):
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def post_batch(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
17
|
+
return self._http.request("POST", "/v1/events/batch", json_body={"events": events})
|
|
18
|
+
|
|
19
|
+
def get_receipt(self, receipt_id: str) -> dict[str, Any]:
|
|
20
|
+
return self._http.request("GET", f"/v1/receipts/{receipt_id}")
|
|
21
|
+
|
|
22
|
+
def poll_receipt(
|
|
23
|
+
self,
|
|
24
|
+
receipt_id: str,
|
|
25
|
+
*,
|
|
26
|
+
timeout_s: float = 30.0,
|
|
27
|
+
interval_s: float = 0.2,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
deadline = time.monotonic() + timeout_s
|
|
30
|
+
while time.monotonic() < deadline:
|
|
31
|
+
body = self.get_receipt(receipt_id)
|
|
32
|
+
if body.get("status") in ("committed", "failed"):
|
|
33
|
+
return body
|
|
34
|
+
time.sleep(interval_s)
|
|
35
|
+
raise TransportError(f"receipt {receipt_id} not settled within {timeout_s}s")
|
fact0/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Public exception types raised by the Fact0 SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Fact0Error(Exception):
|
|
7
|
+
"""Base class for SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Back-compat aliases
|
|
11
|
+
AuditLogError = Fact0Error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationError(Fact0Error):
|
|
15
|
+
"""Invalid event fields or types."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TransportError(Fact0Error):
|
|
19
|
+
"""HTTP failure or unreachable server."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, status_code: int | None = None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""FastAPI middleware for request-level audit logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
from fact0.audit.client import AuditClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuditMiddleware(BaseHTTPMiddleware):
|
|
16
|
+
"""Log each HTTP request as an audit event."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
app,
|
|
21
|
+
client_factory: Callable[[], AuditClient],
|
|
22
|
+
*,
|
|
23
|
+
action_prefix: str = "api",
|
|
24
|
+
):
|
|
25
|
+
super().__init__(app)
|
|
26
|
+
self._client_factory = client_factory
|
|
27
|
+
self._action_prefix = action_prefix
|
|
28
|
+
|
|
29
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
30
|
+
start = time.perf_counter()
|
|
31
|
+
response = await call_next(request)
|
|
32
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
33
|
+
client = self._client_factory()
|
|
34
|
+
client.log(
|
|
35
|
+
actor={"id": "api", "type": "system"},
|
|
36
|
+
action=f"{self._action_prefix}.request",
|
|
37
|
+
resource={"id": request.url.path, "type": "http.route"},
|
|
38
|
+
outcome="success" if response.status_code < 400 else "failure",
|
|
39
|
+
metadata={
|
|
40
|
+
"method": request.method,
|
|
41
|
+
"status": response.status_code,
|
|
42
|
+
"duration_ms": duration_ms,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
return response
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""LangChain callback handler for Fact0 telemetry and audit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from fact0 import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Fact0CallbackHandler:
|
|
12
|
+
"""Record LangChain runs to Fact0 telemetry (and optional audit events)."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
client: Client,
|
|
17
|
+
*,
|
|
18
|
+
agent_id: str,
|
|
19
|
+
audit_sensitive_actions: bool = False,
|
|
20
|
+
execution_metadata: dict[str, str] | None = None,
|
|
21
|
+
):
|
|
22
|
+
self._client = client
|
|
23
|
+
self._agent_id = agent_id
|
|
24
|
+
self._audit_sensitive = audit_sensitive_actions
|
|
25
|
+
self._metadata = execution_metadata or {}
|
|
26
|
+
self._execution_ctx = None
|
|
27
|
+
self._spans: dict[UUID, Any] = {}
|
|
28
|
+
|
|
29
|
+
def _ensure_execution(self):
|
|
30
|
+
if self._execution_ctx is None:
|
|
31
|
+
self._execution_ctx = self._client.telemetry.execution(
|
|
32
|
+
agent_id=self._agent_id,
|
|
33
|
+
metadata=self._metadata,
|
|
34
|
+
).__enter__()
|
|
35
|
+
if self._audit_sensitive:
|
|
36
|
+
self._client.audit.log(
|
|
37
|
+
actor={"id": self._agent_id, "type": "agent"},
|
|
38
|
+
action="agent.run.start",
|
|
39
|
+
resource={"id": self._execution_ctx.id, "type": "agent.execution"},
|
|
40
|
+
outcome="success",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# LangChain BaseCallbackHandler-style hooks (duck typed)
|
|
44
|
+
def on_chain_start(self, serialized: dict, inputs: dict, **kwargs: Any) -> None:
|
|
45
|
+
self._ensure_execution()
|
|
46
|
+
|
|
47
|
+
def on_chain_end(self, outputs: dict, **kwargs: Any) -> None:
|
|
48
|
+
if self._execution_ctx and self._audit_sensitive:
|
|
49
|
+
self._client.audit.log(
|
|
50
|
+
actor={"id": self._agent_id, "type": "agent"},
|
|
51
|
+
action="agent.run.end",
|
|
52
|
+
resource={"id": self._execution_ctx.id, "type": "agent.execution"},
|
|
53
|
+
outcome="success",
|
|
54
|
+
)
|
|
55
|
+
if self._execution_ctx:
|
|
56
|
+
self._client.telemetry.end_execution(self._execution_ctx.id, "COMPLETED")
|
|
57
|
+
self._execution_ctx = None
|
|
58
|
+
|
|
59
|
+
def on_llm_start(self, serialized: dict, prompts: List[str], **kwargs: Any) -> None:
|
|
60
|
+
self._ensure_execution()
|
|
61
|
+
run_id = kwargs.get("run_id")
|
|
62
|
+
if self._execution_ctx and run_id:
|
|
63
|
+
span = self._execution_ctx.span(
|
|
64
|
+
serialized.get("name", "llm"), span_type="MODEL_INVOCATION"
|
|
65
|
+
)
|
|
66
|
+
span.__enter__()
|
|
67
|
+
self._spans[run_id] = span
|
|
68
|
+
|
|
69
|
+
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
|
|
70
|
+
run_id = kwargs.get("run_id")
|
|
71
|
+
span = self._spans.pop(run_id, None) if run_id else None
|
|
72
|
+
if span:
|
|
73
|
+
span.complete(output={"text": str(response)[:500]})
|
|
74
|
+
|
|
75
|
+
def on_tool_start(self, serialized: dict, input_str: str, **kwargs: Any) -> None:
|
|
76
|
+
self._ensure_execution()
|
|
77
|
+
run_id = kwargs.get("run_id")
|
|
78
|
+
name = serialized.get("name", "tool")
|
|
79
|
+
if self._execution_ctx and run_id:
|
|
80
|
+
span = self._execution_ctx.span(name, span_type="TOOL_CALL")
|
|
81
|
+
span.__enter__()
|
|
82
|
+
self._spans[run_id] = span
|
|
83
|
+
if self._audit_sensitive:
|
|
84
|
+
self._client.audit.log(
|
|
85
|
+
actor={"id": self._agent_id, "type": "agent"},
|
|
86
|
+
action="agent.tool.call",
|
|
87
|
+
resource={"id": name, "type": "tool"},
|
|
88
|
+
outcome="success",
|
|
89
|
+
metadata={"input": input_str[:500]},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def on_tool_end(self, output: str, **kwargs: Any) -> None:
|
|
93
|
+
run_id = kwargs.get("run_id")
|
|
94
|
+
span = self._spans.pop(run_id, None) if run_id else None
|
|
95
|
+
if span:
|
|
96
|
+
span.complete(output={"result": output[:500]})
|
fact0/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py.typed
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Execution telemetry REST client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._http import SyncHTTP
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TelemetryClient:
|
|
11
|
+
def __init__(self, http: SyncHTTP):
|
|
12
|
+
self._http = http
|
|
13
|
+
|
|
14
|
+
def start_execution(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
agent_id: str,
|
|
18
|
+
agent_name: str = "",
|
|
19
|
+
trigger: str = "",
|
|
20
|
+
metadata: dict[str, str] | None = None,
|
|
21
|
+
idempotency_key: str = "",
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
body: dict[str, Any] = {"agent_id": agent_id}
|
|
24
|
+
if agent_name:
|
|
25
|
+
body["agent_name"] = agent_name
|
|
26
|
+
if trigger:
|
|
27
|
+
body["trigger"] = trigger
|
|
28
|
+
if metadata:
|
|
29
|
+
body["metadata"] = metadata
|
|
30
|
+
if idempotency_key:
|
|
31
|
+
body["idempotency_key"] = idempotency_key
|
|
32
|
+
return self._http.request("POST", "/api/v1/executions", json_body=body)
|
|
33
|
+
|
|
34
|
+
def ingest_spans(self, execution_id: str, spans: list[dict[str, Any]]) -> dict[str, Any]:
|
|
35
|
+
return self._http.request(
|
|
36
|
+
"POST",
|
|
37
|
+
f"/api/v1/executions/{execution_id}/spans",
|
|
38
|
+
json_body={"spans": spans},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def ingest_events(self, execution_id: str, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
42
|
+
return self._http.request(
|
|
43
|
+
"POST",
|
|
44
|
+
f"/api/v1/executions/{execution_id}/events",
|
|
45
|
+
json_body={"events": events},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def end_execution(self, execution_id: str, status: str) -> dict[str, Any]:
|
|
49
|
+
return self._http.request(
|
|
50
|
+
"PUT",
|
|
51
|
+
f"/api/v1/executions/{execution_id}/end",
|
|
52
|
+
json_body={"status": status},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def list_executions(self, **params: Any) -> dict[str, Any]:
|
|
56
|
+
return self._http.request("GET", "/api/v1/executions", params=params)
|
|
57
|
+
|
|
58
|
+
def get_execution(self, execution_id: str) -> dict[str, Any]:
|
|
59
|
+
return self._http.request("GET", f"/api/v1/executions/{execution_id}")
|
|
60
|
+
|
|
61
|
+
def get_spans(self, execution_id: str) -> dict[str, Any]:
|
|
62
|
+
return self._http.request("GET", f"/api/v1/executions/{execution_id}/spans")
|
|
63
|
+
|
|
64
|
+
def get_dag(self, execution_id: str) -> dict[str, Any]:
|
|
65
|
+
return self._http.request("GET", f"/api/v1/executions/{execution_id}/dag")
|
|
66
|
+
|
|
67
|
+
def replay(self, execution_id: str, **params: Any) -> dict[str, Any]:
|
|
68
|
+
return self._http.request(
|
|
69
|
+
"GET", f"/api/v1/executions/{execution_id}/replay", params=params
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def get_span(self, span_id: str) -> dict[str, Any]:
|
|
73
|
+
return self._http.request("GET", f"/api/v1/spans/{span_id}")
|
|
74
|
+
|
|
75
|
+
def execution(self, **kwargs: Any):
|
|
76
|
+
from .context import ExecutionContext
|
|
77
|
+
|
|
78
|
+
return ExecutionContext(self, **kwargs)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Execution and span context managers for telemetry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .client import TelemetryClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _utcnow() -> datetime:
|
|
13
|
+
return datetime.now(timezone.utc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpanContext:
|
|
17
|
+
def __init__(self, tel: TelemetryClient, execution_id: str, name: str, span_type: str):
|
|
18
|
+
self._tel = tel
|
|
19
|
+
self._execution_id = execution_id
|
|
20
|
+
self._span_id = f"span_{uuid.uuid4().hex[:16]}"
|
|
21
|
+
self._name = name
|
|
22
|
+
self._span_type = span_type
|
|
23
|
+
self._started = _utcnow()
|
|
24
|
+
self._ended = False
|
|
25
|
+
|
|
26
|
+
def log_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
27
|
+
self._tel.ingest_events(
|
|
28
|
+
self._execution_id,
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
"id": f"evt_{uuid.uuid4().hex[:16]}",
|
|
32
|
+
"execution_id": self._execution_id,
|
|
33
|
+
"span_id": self._span_id,
|
|
34
|
+
"event_type": event_type,
|
|
35
|
+
"timestamp": _utcnow().isoformat(),
|
|
36
|
+
"payload": payload,
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def complete(self, *, output: dict[str, Any] | None = None, status: str = "COMPLETED") -> None:
|
|
42
|
+
if self._ended:
|
|
43
|
+
return
|
|
44
|
+
self._ended = True
|
|
45
|
+
span: dict[str, Any] = {
|
|
46
|
+
"id": self._span_id,
|
|
47
|
+
"execution_id": self._execution_id,
|
|
48
|
+
"span_type": self._span_type,
|
|
49
|
+
"name": self._name,
|
|
50
|
+
"status": status,
|
|
51
|
+
"started_at": self._started.isoformat(),
|
|
52
|
+
"ended_at": _utcnow().isoformat(),
|
|
53
|
+
}
|
|
54
|
+
if output:
|
|
55
|
+
span["metadata"] = {"output": str(output)}
|
|
56
|
+
self._tel.ingest_spans(self._execution_id, [span])
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> SpanContext:
|
|
59
|
+
self._tel.ingest_spans(
|
|
60
|
+
self._execution_id,
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
"id": self._span_id,
|
|
64
|
+
"execution_id": self._execution_id,
|
|
65
|
+
"span_type": self._span_type,
|
|
66
|
+
"name": self._name,
|
|
67
|
+
"status": "RUNNING",
|
|
68
|
+
"started_at": self._started.isoformat(),
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
75
|
+
if not self._ended:
|
|
76
|
+
status = "FAILED" if exc_type else "COMPLETED"
|
|
77
|
+
self.complete(status=status)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ExecutionContext:
|
|
81
|
+
def __init__(self, tel: TelemetryClient, **kwargs: Any):
|
|
82
|
+
self._tel = tel
|
|
83
|
+
self._kwargs = kwargs
|
|
84
|
+
self._execution: dict[str, Any] | None = None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def id(self) -> str:
|
|
88
|
+
if not self._execution:
|
|
89
|
+
raise RuntimeError("execution not started")
|
|
90
|
+
return self._execution["id"]
|
|
91
|
+
|
|
92
|
+
def span(self, name: str, *, span_type: str = "CUSTOM") -> SpanContext:
|
|
93
|
+
return SpanContext(self._tel, self.id, name, span_type)
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> ExecutionContext:
|
|
96
|
+
self._execution = self._tel.start_execution(**self._kwargs)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
100
|
+
if self._execution:
|
|
101
|
+
status = "FAILED" if exc_type else "COMPLETED"
|
|
102
|
+
self._tel.end_execution(self._execution["id"], status)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fact0-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fact0 SDK - audit log and execution telemetry for AI agents
|
|
5
|
+
Author: Fact0
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://fact0.io
|
|
8
|
+
Project-URL: Documentation, https://docs.fact0.io
|
|
9
|
+
Project-URL: Repository, https://github.com/fact0-ai/fact0
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pydantic<3,>=2.6
|
|
13
|
+
Requires-Dist: requests<3,>=2.31
|
|
14
|
+
Requires-Dist: httpx<1,>=0.27
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
19
|
+
Provides-Extra: langchain
|
|
20
|
+
Requires-Dist: langchain-core>=0.2; extra == "langchain"
|
|
21
|
+
Provides-Extra: fastapi
|
|
22
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
23
|
+
Requires-Dist: starlette>=0.36; extra == "fastapi"
|
|
24
|
+
|
|
25
|
+
# fact0
|
|
26
|
+
|
|
27
|
+
Python SDK for [Fact0](https://fact0.io) - tamper-evident audit logs and execution telemetry for AI agents.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install fact0-sdk
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import fact0
|
|
35
|
+
|
|
36
|
+
client = fact0.Client(api_key="alk_live_...")
|
|
37
|
+
|
|
38
|
+
client.audit.log(
|
|
39
|
+
actor={"id": "user_123", "type": "human"},
|
|
40
|
+
action="document.delete",
|
|
41
|
+
resource={"id": "doc_456", "type": "document"},
|
|
42
|
+
outcome="success",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
with client.telemetry.execution(agent_id="bot-1") as ex:
|
|
46
|
+
with ex.span("tool.search", span_type="TOOL_CALL") as span:
|
|
47
|
+
span.complete(output={"hits": 3})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Docs: [docs.fact0.io](https://docs.fact0.io)
|
|
51
|
+
|
|
52
|
+
## Legacy imports
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import fact0 # deprecated shim → fact0
|
|
56
|
+
import auditlog # deprecated shim → fact0.audit
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Both emit `DeprecationWarning` on import.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
auditlog/__init__.py,sha256=WzK9mg9iF5G6EjpSz5dfWlfeFCfs1a4QdMzgF_4B6kc,550
|
|
2
|
+
auditlog/client.py,sha256=pU271OxVjVkmn95s5iN3mbN_AgevbNPKbQkslUQb1KY,1553
|
|
3
|
+
fact0/__init__.py,sha256=aozmzvJn-bjP_ombgomYoPLTmJPanGam2ESWiOzDYOc,2784
|
|
4
|
+
fact0/_http.py,sha256=n859pBaFxluuiqxSwPi_JhQaoaNCwGf36Ea2zWp30uo,3951
|
|
5
|
+
fact0/exceptions.py,sha256=gQqotlJ81K_aItPFHiVghdivQgTf248QSo9dXuUh7Ao,516
|
|
6
|
+
fact0/py.typed,sha256=Enkyrr7t9bKZQk8ywVperZB_J3TbNaaRpp6qn8sI34s,9
|
|
7
|
+
fact0/audit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
fact0/audit/async_client.py,sha256=lUslD98S1bUWhM6UbbvYVWN9v_XxLltNWetc_m7KwMI,4367
|
|
9
|
+
fact0/audit/client.py,sha256=-kMTyy7ZKCi5RXKW2x3E2RgoLCcvGMrjvTM7W4Ktl9E,8025
|
|
10
|
+
fact0/audit/models.py,sha256=ZpZjKHCl0kJYRrxbx2HVBzCzXNjh_26BLlEYQZ93m-U,1361
|
|
11
|
+
fact0/audit/transport.py,sha256=NnyEu1ekM55JUWtxZ5Zj507UNrEF8SvPTjCcqh28hzg,1075
|
|
12
|
+
fact0/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
fact0/integrations/fastapi.py,sha256=z3p5hWKfD9RiQF0Re8q-dPwfTAzwhaucf9O3tq2I5RU,1415
|
|
14
|
+
fact0/integrations/langchain.py,sha256=m6NozNymff-NaDTkwd70cC29D7_Sg9nSarqlCtlZwi0,3693
|
|
15
|
+
fact0/telemetry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
fact0/telemetry/client.py,sha256=P0qJJW9pLG-Hnf45x2B3UhuWNHy8b42vs6SBq1W6Kwg,2659
|
|
17
|
+
fact0/telemetry/context.py,sha256=jFrkVTN64g9F7tXVAp1oXvWhQvaWxx9c2oMl30SVn0M,3292
|
|
18
|
+
fact0_sdk-1.0.0.dist-info/METADATA,sha256=Uc4ZTfFnixdPK-Lj_Iul6YJ6AD1ERlUjF4v56X0BJu8,1612
|
|
19
|
+
fact0_sdk-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
fact0_sdk-1.0.0.dist-info/top_level.txt,sha256=dQi_O4dq47qwK2ohMu5UyVz7arTzaGZG8P9D8nCkKBw,15
|
|
21
|
+
fact0_sdk-1.0.0.dist-info/RECORD,,
|