Skip to content

Commit 3d6aee8

Browse files
committed
Add complete audit log downloads
1 parent ab818cb commit 3d6aee8

4 files changed

Lines changed: 372 additions & 0 deletions

File tree

‎src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ export { type Uploadable, toFile } from './core/uploads';
66
export { APIPromise } from './core/api-promise';
77
export { Kernel, type ClientOptions } from './client';
88
export { type BrowserFetchInit } from './lib/browser-fetch';
9+
export {
10+
AuditLogDownloadError,
11+
type AuditLogDownloadDestination,
12+
type AuditLogDownloadOptions,
13+
type AuditLogDownloadParams,
14+
type AuditLogDownloadProgress,
15+
type AuditLogDownloadResult,
16+
type AuditLogDownloadWriteResult,
17+
} from './lib/audit-log-download';
918
export { BrowserRouteCache, type BrowserRoute } from './lib/browser-routing';
1019
export { PagePromise } from './core/pagination';
1120
export {

‎src/lib/audit-log-download.ts‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { APIConnectionError, APIError, APIUserAbortError, KernelError } from '../core/error';
2+
import type { RequestOptions } from '../internal/request-options';
3+
import type { AuditLogExportChunkParams } from '../resources/audit-logs';
4+
5+
const DOWNLOAD_ATTEMPTS = 7;
6+
const MAX_RETRY_DELAY_MS = 8_000;
7+
8+
export class AuditLogDownloadError extends KernelError {}
9+
10+
export type AuditLogDownloadParams = Omit<AuditLogExportChunkParams, 'cursor'>;
11+
12+
export interface AuditLogDownloadResult {
13+
bytesWritten: number;
14+
chunks: number;
15+
rows: number;
16+
}
17+
18+
export interface AuditLogDownloadProgress extends AuditLogDownloadResult {
19+
chunkRows: number;
20+
}
21+
22+
export type AuditLogDownloadWriteResult = void | number | { bytesWritten: number };
23+
24+
export interface AuditLogDownloadDestination {
25+
write(chunk: Uint8Array): AuditLogDownloadWriteResult | Promise<AuditLogDownloadWriteResult>;
26+
}
27+
28+
export interface AuditLogDownloadOptions
29+
extends Omit<
30+
RequestOptions,
31+
'method' | 'path' | 'query' | 'body' | 'maxRetries' | 'stream' | '__binaryResponse' | '__streamClass'
32+
> {
33+
onProgress?(progress: AuditLogDownloadProgress): void | Promise<void>;
34+
}
35+
36+
type FetchChunk = (query: AuditLogExportChunkParams, options?: RequestOptions) => Promise<Response>;
37+
38+
export async function downloadAuditLogs(
39+
fetchChunk: FetchChunk,
40+
query: AuditLogDownloadParams,
41+
destination: AuditLogDownloadDestination,
42+
options: AuditLogDownloadOptions = {},
43+
): Promise<AuditLogDownloadResult> {
44+
if (!destination || typeof destination.write !== 'function') {
45+
throw new TypeError('audit log download destination must provide write()');
46+
}
47+
48+
const { onProgress, ...requestOptions } = options;
49+
let cursor: string | undefined;
50+
const result: AuditLogDownloadResult = { bytesWritten: 0, chunks: 0, rows: 0 };
51+
52+
while (true) {
53+
const chunk = await fetchVerifiedChunk(fetchChunk, cursor ? { ...query, cursor } : query, {
54+
...requestOptions,
55+
maxRetries: 0,
56+
});
57+
const { nextCursor, hasMore, rows } = parseChunkHeaders(chunk.headers, cursor);
58+
await writeChunk(destination, chunk.body);
59+
60+
cursor = nextCursor;
61+
result.bytesWritten += chunk.body.byteLength;
62+
result.chunks += 1;
63+
result.rows += rows;
64+
if (onProgress) {
65+
await onProgress({ ...result, chunkRows: rows });
66+
}
67+
if (!hasMore) {
68+
return result;
69+
}
70+
}
71+
}
72+
73+
async function fetchVerifiedChunk(
74+
fetchChunk: FetchChunk,
75+
query: AuditLogExportChunkParams,
76+
options: RequestOptions,
77+
): Promise<{ body: Uint8Array; headers: Headers }> {
78+
for (let attempt = 1; ; attempt += 1) {
79+
try {
80+
const response = await fetchChunk(query, options);
81+
const body = new Uint8Array(await response.arrayBuffer());
82+
const expected = response.headers.get('x-content-sha256');
83+
if (!expected) {
84+
throw new AuditLogDownloadError('response missing X-Content-Sha256 header');
85+
}
86+
const actual = await sha256Hex(body);
87+
if (actual !== expected) {
88+
throw new AuditLogDownloadError(
89+
`audit log chunk checksum mismatch (got ${actual}, want ${expected})`,
90+
);
91+
}
92+
return { body, headers: response.headers };
93+
} catch (error) {
94+
if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error, options.signal)) {
95+
throw error;
96+
}
97+
await retryDelay(attempt, options.signal);
98+
}
99+
}
100+
}
101+
102+
function parseChunkHeaders(
103+
headers: Headers,
104+
currentCursor: string | undefined,
105+
): { rows: number; nextCursor: string | undefined; hasMore: boolean } {
106+
const hasMoreValue = headers.get('x-has-more');
107+
if (hasMoreValue !== 'true' && hasMoreValue !== 'false') {
108+
throw new AuditLogDownloadError('response missing or invalid X-Has-More header');
109+
}
110+
const hasMore = hasMoreValue === 'true';
111+
112+
const rowCount = headers.get('x-row-count');
113+
const rows = rowCount === null ? NaN : Number(rowCount);
114+
if (!Number.isSafeInteger(rows) || rows < 0) {
115+
throw new AuditLogDownloadError('response missing or invalid X-Row-Count header');
116+
}
117+
118+
const nextCursor = headers.get('x-next-cursor') || undefined;
119+
if (hasMore && (!nextCursor || nextCursor === currentCursor)) {
120+
throw new AuditLogDownloadError('response has invalid X-Next-Cursor header');
121+
}
122+
if (!hasMore && nextCursor) {
123+
throw new AuditLogDownloadError('response returned a cursor after the final chunk');
124+
}
125+
return { rows, nextCursor, hasMore };
126+
}
127+
128+
async function sha256Hex(body: Uint8Array): Promise<string> {
129+
const subtle = globalThis.crypto?.subtle ?? (await import('node:crypto')).webcrypto.subtle;
130+
const digest = await subtle.digest('SHA-256', body);
131+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
132+
}
133+
134+
function isRetryable(error: unknown, signal: AbortSignal | null | undefined): boolean {
135+
if (signal?.aborted || error instanceof APIUserAbortError) {
136+
return false;
137+
}
138+
if (error instanceof APIError && !(error instanceof APIConnectionError)) {
139+
return error.status === 429 || (error.status !== undefined && error.status >= 500);
140+
}
141+
return true;
142+
}
143+
144+
async function retryDelay(attempt: number, signal: AbortSignal | null | undefined): Promise<void> {
145+
const delay = Math.min(1_000 * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS);
146+
await new Promise<void>((resolve, reject) => {
147+
if (signal?.aborted) {
148+
reject(new APIUserAbortError());
149+
return;
150+
}
151+
const onAbort = () => {
152+
clearTimeout(timer);
153+
reject(new APIUserAbortError());
154+
};
155+
const timer = setTimeout(() => {
156+
signal?.removeEventListener('abort', onAbort);
157+
resolve();
158+
}, delay);
159+
signal?.addEventListener('abort', onAbort, { once: true });
160+
});
161+
}
162+
163+
async function writeChunk(destination: AuditLogDownloadDestination, body: Uint8Array): Promise<void> {
164+
let offset = 0;
165+
while (offset < body.byteLength) {
166+
const result = await destination.write(offset === 0 ? body : body.subarray(offset));
167+
if (typeof result === 'number') {
168+
if (result <= 0 || result > body.byteLength - offset) {
169+
throw new AuditLogDownloadError('audit log download destination performed a short write');
170+
}
171+
offset += result;
172+
continue;
173+
}
174+
if (result && typeof result === 'object' && 'bytesWritten' in result) {
175+
const bytesWritten = (result as { bytesWritten: number }).bytesWritten;
176+
if (bytesWritten <= 0 || bytesWritten > body.byteLength - offset) {
177+
throw new AuditLogDownloadError('audit log download destination performed a short write');
178+
}
179+
offset += bytesWritten;
180+
continue;
181+
}
182+
return;
183+
}
184+
}

‎src/resources/audit-logs.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ import { APIPromise } from '../core/api-promise';
55
import { PagePromise, PageTokenPagination, type PageTokenPaginationParams } from '../core/pagination';
66
import { buildHeaders } from '../internal/headers';
77
import { RequestOptions } from '../internal/request-options';
8+
import {
9+
downloadAuditLogs,
10+
type AuditLogDownloadDestination,
11+
type AuditLogDownloadOptions,
12+
type AuditLogDownloadParams,
13+
type AuditLogDownloadProgress,
14+
type AuditLogDownloadResult,
15+
} from '../lib/audit-log-download';
16+
17+
export type {
18+
AuditLogDownloadDestination,
19+
AuditLogDownloadOptions,
20+
AuditLogDownloadParams,
21+
AuditLogDownloadProgress,
22+
AuditLogDownloadResult,
23+
} from '../lib/audit-log-download';
824

925
/**
1026
* Read audit log records for the authenticated organization.
@@ -34,6 +50,24 @@ export class AuditLogs extends APIResource {
3450
__binaryResponse: true,
3551
});
3652
}
53+
54+
/**
55+
* Download a complete audit log export to a writable destination. The SDK
56+
* verifies every chunk and retries transient transfer failures. It does not
57+
* close the destination.
58+
*/
59+
download(
60+
query: AuditLogDownloadParams,
61+
destination: AuditLogDownloadDestination,
62+
options?: AuditLogDownloadOptions,
63+
): Promise<AuditLogDownloadResult> {
64+
return downloadAuditLogs(
65+
(chunkQuery, chunkOptions) => this.exportChunk(chunkQuery, chunkOptions),
66+
query,
67+
destination,
68+
options,
69+
);
70+
}
3771
}
3872

3973
export type AuditLogEntriesPageTokenPagination = PageTokenPagination<AuditLogEntry>;
@@ -205,5 +239,10 @@ export declare namespace AuditLogs {
205239
type AuditLogEntriesPageTokenPagination as AuditLogEntriesPageTokenPagination,
206240
type AuditLogListParams as AuditLogListParams,
207241
type AuditLogExportChunkParams as AuditLogExportChunkParams,
242+
type AuditLogDownloadDestination as AuditLogDownloadDestination,
243+
type AuditLogDownloadOptions as AuditLogDownloadOptions,
244+
type AuditLogDownloadParams as AuditLogDownloadParams,
245+
type AuditLogDownloadProgress as AuditLogDownloadProgress,
246+
type AuditLogDownloadResult as AuditLogDownloadResult,
208247
};
209248
}

0 commit comments

Comments
 (0)