Skip to content

Commit c5182d0

Browse files
authored
Merge pull request #144 from kernel/hypeship/audit-log-download-helper
feat: add complete audit log download helper
2 parents ab818cb + ec64cbd commit c5182d0

4 files changed

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

‎src/resources/audit-logs.ts‎

Lines changed: 42 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,27 @@ export class AuditLogs extends APIResource {
3450
__binaryResponse: true,
3551
});
3652
}
53+
54+
/**
55+
* Download a complete gzip-compressed JSON Lines audit log export to a writable
56+
* destination. The SDK verifies every chunk and retries transient transfer
57+
* failures. It does not close the destination. If the download fails, the
58+
* destination may contain a partial export; use a temporary file and atomic
59+
* rename when the completed export must be published atomically.
60+
*/
61+
download(
62+
query: AuditLogDownloadParams,
63+
destination: AuditLogDownloadDestination,
64+
options?: AuditLogDownloadOptions,
65+
): Promise<AuditLogDownloadResult> {
66+
return downloadAuditLogs(
67+
(chunkQuery, chunkOptions) => this.exportChunk(chunkQuery, chunkOptions),
68+
query,
69+
destination,
70+
this._client.timeout,
71+
options,
72+
);
73+
}
3774
}
3875

3976
export type AuditLogEntriesPageTokenPagination = PageTokenPagination<AuditLogEntry>;
@@ -205,5 +242,10 @@ export declare namespace AuditLogs {
205242
type AuditLogEntriesPageTokenPagination as AuditLogEntriesPageTokenPagination,
206243
type AuditLogListParams as AuditLogListParams,
207244
type AuditLogExportChunkParams as AuditLogExportChunkParams,
245+
type AuditLogDownloadDestination as AuditLogDownloadDestination,
246+
type AuditLogDownloadOptions as AuditLogDownloadOptions,
247+
type AuditLogDownloadParams as AuditLogDownloadParams,
248+
type AuditLogDownloadProgress as AuditLogDownloadProgress,
249+
type AuditLogDownloadResult as AuditLogDownloadResult,
208250
};
209251
}

0 commit comments

Comments
 (0)