Skip to content

Commit 29163ad

Browse files
committed
Add complete audit log downloads
1 parent 0a54788 commit 29163ad

4 files changed

Lines changed: 454 additions & 1 deletion

File tree

src/kernel/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
app_registry,
5252
export_registry,
5353
)
54+
from .lib.audit_log_download import (
55+
AuditLogDownloadError,
56+
AuditLogDownloadResult,
57+
AuditLogDownloadProgress,
58+
)
5459

5560
__all__ = [
5661
"types",
@@ -105,6 +110,9 @@
105110
"App",
106111
"app_registry",
107112
"export_registry",
113+
"AuditLogDownloadError",
114+
"AuditLogDownloadProgress",
115+
"AuditLogDownloadResult",
108116
]
109117

110118
if not _t.TYPE_CHECKING:
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
from __future__ import annotations
2+
3+
import time
4+
import hashlib
5+
from typing import BinaryIO, Callable, Optional, Protocol, Awaitable
6+
from dataclasses import dataclass
7+
8+
import anyio
9+
import httpx
10+
11+
from .._exceptions import KernelError, APIStatusError
12+
13+
_DOWNLOAD_ATTEMPTS = 7
14+
_MAX_RETRY_DELAY = 8.0
15+
16+
17+
class AuditLogDownloadError(KernelError):
18+
pass
19+
20+
21+
@dataclass(frozen=True)
22+
class AuditLogDownloadResult:
23+
bytes_written: int
24+
chunks: int
25+
rows: int
26+
27+
28+
@dataclass(frozen=True)
29+
class AuditLogDownloadProgress(AuditLogDownloadResult):
30+
chunk_rows: int
31+
32+
33+
class _SyncChunkResponse(Protocol):
34+
@property
35+
def headers(self) -> httpx.Headers: ...
36+
37+
def read(self) -> bytes: ...
38+
39+
def close(self) -> None: ...
40+
41+
42+
class _AsyncChunkResponse(Protocol):
43+
@property
44+
def headers(self) -> httpx.Headers: ...
45+
46+
async def read(self) -> bytes: ...
47+
48+
async def close(self) -> None: ...
49+
50+
51+
SyncFetchChunk = Callable[[Optional[str]], _SyncChunkResponse]
52+
AsyncFetchChunk = Callable[[Optional[str]], Awaitable[_AsyncChunkResponse]]
53+
ProgressCallback = Callable[[AuditLogDownloadProgress], None]
54+
55+
56+
def download_audit_logs(
57+
fetch_chunk: SyncFetchChunk,
58+
destination: BinaryIO,
59+
*,
60+
on_progress: ProgressCallback | None = None,
61+
) -> AuditLogDownloadResult:
62+
if not callable(getattr(destination, "write", None)):
63+
raise TypeError("audit log download destination must provide write()")
64+
65+
cursor: str | None = None
66+
result = AuditLogDownloadResult(bytes_written=0, chunks=0, rows=0)
67+
while True:
68+
body, headers = _fetch_verified_chunk(fetch_chunk, cursor)
69+
chunk_rows, next_cursor, has_more = _parse_chunk_headers(headers, cursor)
70+
_write_chunk(destination, body)
71+
72+
cursor = next_cursor
73+
result = AuditLogDownloadResult(
74+
bytes_written=result.bytes_written + len(body),
75+
chunks=result.chunks + 1,
76+
rows=result.rows + chunk_rows,
77+
)
78+
if on_progress is not None:
79+
on_progress(
80+
AuditLogDownloadProgress(
81+
bytes_written=result.bytes_written,
82+
chunks=result.chunks,
83+
rows=result.rows,
84+
chunk_rows=chunk_rows,
85+
)
86+
)
87+
if not has_more:
88+
return result
89+
90+
91+
async def async_download_audit_logs(
92+
fetch_chunk: AsyncFetchChunk,
93+
destination: BinaryIO,
94+
*,
95+
on_progress: ProgressCallback | None = None,
96+
) -> AuditLogDownloadResult:
97+
if not callable(getattr(destination, "write", None)):
98+
raise TypeError("audit log download destination must provide write()")
99+
100+
cursor: str | None = None
101+
result = AuditLogDownloadResult(bytes_written=0, chunks=0, rows=0)
102+
while True:
103+
body, headers = await _async_fetch_verified_chunk(fetch_chunk, cursor)
104+
chunk_rows, next_cursor, has_more = _parse_chunk_headers(headers, cursor)
105+
_write_chunk(destination, body)
106+
107+
cursor = next_cursor
108+
result = AuditLogDownloadResult(
109+
bytes_written=result.bytes_written + len(body),
110+
chunks=result.chunks + 1,
111+
rows=result.rows + chunk_rows,
112+
)
113+
if on_progress is not None:
114+
on_progress(
115+
AuditLogDownloadProgress(
116+
bytes_written=result.bytes_written,
117+
chunks=result.chunks,
118+
rows=result.rows,
119+
chunk_rows=chunk_rows,
120+
)
121+
)
122+
if not has_more:
123+
return result
124+
125+
126+
def _fetch_verified_chunk(fetch_chunk: SyncFetchChunk, cursor: str | None) -> tuple[bytes, httpx.Headers]:
127+
for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
128+
try:
129+
response = fetch_chunk(cursor)
130+
try:
131+
body = response.read()
132+
headers = httpx.Headers(response.headers)
133+
finally:
134+
response.close()
135+
_verify_checksum(body, headers)
136+
return body, headers
137+
except Exception as error:
138+
if attempt == _DOWNLOAD_ATTEMPTS or not _is_retryable(error):
139+
raise
140+
time.sleep(min(2 ** (attempt - 1), _MAX_RETRY_DELAY))
141+
raise AssertionError("unreachable")
142+
143+
144+
async def _async_fetch_verified_chunk(fetch_chunk: AsyncFetchChunk, cursor: str | None) -> tuple[bytes, httpx.Headers]:
145+
for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
146+
try:
147+
response = await fetch_chunk(cursor)
148+
try:
149+
body = await response.read()
150+
headers = httpx.Headers(response.headers)
151+
finally:
152+
await response.close()
153+
_verify_checksum(body, headers)
154+
return body, headers
155+
except Exception as error:
156+
if attempt == _DOWNLOAD_ATTEMPTS or not _is_retryable(error):
157+
raise
158+
await anyio.sleep(min(2 ** (attempt - 1), _MAX_RETRY_DELAY))
159+
raise AssertionError("unreachable")
160+
161+
162+
def _verify_checksum(body: bytes, headers: httpx.Headers) -> None:
163+
expected = headers.get("x-content-sha256")
164+
if not expected:
165+
raise AuditLogDownloadError("response missing X-Content-Sha256 header")
166+
actual = hashlib.sha256(body).hexdigest()
167+
if actual != expected:
168+
raise AuditLogDownloadError(f"audit log chunk checksum mismatch (got {actual}, want {expected})")
169+
170+
171+
def _parse_chunk_headers(headers: httpx.Headers, current_cursor: str | None) -> tuple[int, str | None, bool]:
172+
has_more_value = headers.get("x-has-more")
173+
if has_more_value not in {"true", "false"}:
174+
raise AuditLogDownloadError("response missing or invalid X-Has-More header")
175+
has_more = has_more_value == "true"
176+
177+
row_count = headers.get("x-row-count")
178+
try:
179+
rows = int(row_count) if row_count is not None else -1
180+
except ValueError as error:
181+
raise AuditLogDownloadError("response missing or invalid X-Row-Count header") from error
182+
if rows < 0:
183+
raise AuditLogDownloadError("response missing or invalid X-Row-Count header")
184+
185+
next_cursor = headers.get("x-next-cursor") or None
186+
if has_more and (not next_cursor or next_cursor == current_cursor):
187+
raise AuditLogDownloadError("response has invalid X-Next-Cursor header")
188+
if not has_more and next_cursor:
189+
raise AuditLogDownloadError("response returned a cursor after the final chunk")
190+
return rows, next_cursor, has_more
191+
192+
193+
def _write_chunk(destination: BinaryIO, body: bytes) -> None:
194+
remaining = memoryview(body)
195+
while remaining:
196+
written = destination.write(remaining)
197+
if written <= 0 or written > len(remaining):
198+
raise AuditLogDownloadError("audit log download destination performed a short write")
199+
remaining = remaining[written:]
200+
201+
202+
def _is_retryable(error: Exception) -> bool:
203+
if isinstance(error, APIStatusError):
204+
return error.status_code == 429 or error.status_code >= 500
205+
return True

src/kernel/resources/audit_logs.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import Union
5+
from typing import Union, BinaryIO
66
from datetime import datetime
77
from typing_extensions import Literal
88

@@ -30,6 +30,12 @@
3030
from ..pagination import SyncPageTokenPagination, AsyncPageTokenPagination
3131
from .._base_client import AsyncPaginator, make_request_options
3232
from ..types.audit_log_entry import AuditLogEntry
33+
from ..lib.audit_log_download import (
34+
ProgressCallback,
35+
AuditLogDownloadResult,
36+
download_audit_logs,
37+
async_download_audit_logs,
38+
)
3339

3440
__all__ = ["AuditLogsResource", "AsyncAuditLogsResource"]
3541

@@ -222,6 +228,55 @@ def export_chunk(
222228
cast_to=BinaryAPIResponse,
223229
)
224230

231+
def download(
232+
self,
233+
*,
234+
to: BinaryIO,
235+
end: Union[str, datetime],
236+
start: Union[str, datetime],
237+
auth_strategy: str | Omit = omit,
238+
exclude_method: SequenceNotStr[str] | Omit = omit,
239+
format: Literal["jsonl", "jsonl.gz"] | Omit = omit,
240+
limit: int | Omit = omit,
241+
method: str | Omit = omit,
242+
search: str | Omit = omit,
243+
search_user_id: SequenceNotStr[str] | Omit = omit,
244+
service: str | Omit = omit,
245+
on_progress: ProgressCallback | None = None,
246+
extra_headers: Headers | None = None,
247+
extra_query: Query | None = None,
248+
extra_body: Body | None = None,
249+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
250+
) -> AuditLogDownloadResult:
251+
"""Download a complete audit log export to a writable binary destination.
252+
253+
The SDK verifies every chunk and retries transient transfer failures. It
254+
does not close the destination.
255+
"""
256+
257+
resource = self._client.with_options(max_retries=0).audit_logs
258+
259+
def fetch_chunk(cursor: str | None) -> BinaryAPIResponse:
260+
return resource.export_chunk(
261+
end=end,
262+
start=start,
263+
auth_strategy=auth_strategy,
264+
cursor=cursor if cursor is not None else omit,
265+
exclude_method=exclude_method,
266+
format=format,
267+
limit=limit,
268+
method=method,
269+
search=search,
270+
search_user_id=search_user_id,
271+
service=service,
272+
extra_headers=extra_headers,
273+
extra_query=extra_query,
274+
extra_body=extra_body,
275+
timeout=timeout,
276+
)
277+
278+
return download_audit_logs(fetch_chunk, to, on_progress=on_progress)
279+
225280

226281
class AsyncAuditLogsResource(AsyncAPIResource):
227282
"""Read audit log records for the authenticated organization."""
@@ -411,6 +466,55 @@ async def export_chunk(
411466
cast_to=AsyncBinaryAPIResponse,
412467
)
413468

469+
async def download(
470+
self,
471+
*,
472+
to: BinaryIO,
473+
end: Union[str, datetime],
474+
start: Union[str, datetime],
475+
auth_strategy: str | Omit = omit,
476+
exclude_method: SequenceNotStr[str] | Omit = omit,
477+
format: Literal["jsonl", "jsonl.gz"] | Omit = omit,
478+
limit: int | Omit = omit,
479+
method: str | Omit = omit,
480+
search: str | Omit = omit,
481+
search_user_id: SequenceNotStr[str] | Omit = omit,
482+
service: str | Omit = omit,
483+
on_progress: ProgressCallback | None = None,
484+
extra_headers: Headers | None = None,
485+
extra_query: Query | None = None,
486+
extra_body: Body | None = None,
487+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
488+
) -> AuditLogDownloadResult:
489+
"""Download a complete audit log export to a writable binary destination.
490+
491+
The SDK verifies every chunk and retries transient transfer failures. It
492+
does not close the destination.
493+
"""
494+
495+
resource = self._client.with_options(max_retries=0).audit_logs
496+
497+
async def fetch_chunk(cursor: str | None) -> AsyncBinaryAPIResponse:
498+
return await resource.export_chunk(
499+
end=end,
500+
start=start,
501+
auth_strategy=auth_strategy,
502+
cursor=cursor if cursor is not None else omit,
503+
exclude_method=exclude_method,
504+
format=format,
505+
limit=limit,
506+
method=method,
507+
search=search,
508+
search_user_id=search_user_id,
509+
service=service,
510+
extra_headers=extra_headers,
511+
extra_query=extra_query,
512+
extra_body=extra_body,
513+
timeout=timeout,
514+
)
515+
516+
return await async_download_audit_logs(fetch_chunk, to, on_progress=on_progress)
517+
414518

415519
class AuditLogsResourceWithRawResponse:
416520
def __init__(self, audit_logs: AuditLogsResource) -> None:

0 commit comments

Comments
 (0)