LCOV - code coverage report
Current view: top level - dashql-app/src/platform/http - http_request_log.ts Coverage Total Hit
Test: DashQL 915828b Lines: 94.3 % 194 183
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import { stringifyError } from '../logger/logger.js';
       2              : import { ClientOptions, HttpClient, HttpFetchResult } from './http_client.js';
       3              : 
       4              : /// The number of requests that the log keeps, older requests are dropped
       5              : export const HTTP_REQUEST_LOG_LIMIT = 500;
       6              : /// The bytes of a response body that the log keeps
       7            1 : export const HTTP_RESPONSE_PREVIEW_BYTES = 16 * 1024;
       8              : /// The bytes of a request body that the log keeps, curl commands need the whole body
       9            1 : export const HTTP_REQUEST_BODY_BYTES = 1024 * 1024;
      10              : 
      11              : /// The headers whose values are masked in the viewer, copy as curl keeps them
      12            1 : const SENSITIVE_HEADERS = new Set([
      13            1 :     'authorization',
      14            1 :     'proxy-authorization',
      15            1 :     'cookie',
      16            1 :     'set-cookie',
      17            1 :     'x-trino-extra-credential',
      18            1 : ]);
      19              : 
      20              : /// A body of a request or a response
      21              : export type HttpBodyRecord =
      22              :     /// A text body, `byteLength` is the size of the whole body
      23              :     | { kind: 'text'; text: string; byteLength: number; truncated: boolean }
      24              :     /// A binary body
      25              :     | { kind: 'binary'; byteLength: number }
      26              :     /// A JSON body that the caller parsed, the log does not keep it
      27              :     | { kind: 'parsed' }
      28              :     /// A body that the log cannot read without consuming it
      29              :     | { kind: 'opaque' };
      30              : 
      31              : /// A recorded http request
      32              : export interface HttpRequestEntry {
      33              :     /// The id of the request
      34              :     requestId: number;
      35              :     /// The method
      36              :     method: string;
      37              :     /// The url
      38              :     url: string;
      39              :     /// The request headers
      40              :     requestHeaders: [string, string][];
      41              :     /// The request body
      42              :     requestBody: HttpBodyRecord | null;
      43              :     /// When the request was sent
      44              :     sentAt: Date;
      45              :     /// When the response headers arrived
      46              :     respondedAt: Date | null;
      47              :     /// When the response body was read
      48              :     bodyReceivedAt: Date | null;
      49              :     /// When the request failed
      50              :     failedAt: Date | null;
      51              :     /// The response status
      52              :     status: number | null;
      53              :     /// The response status text
      54              :     statusText: string | null;
      55              :     /// The response headers
      56              :     responseHeaders: [string, string][] | null;
      57              :     /// The start of the response body, read when the caller reads it, error bodies are read right away
      58              :     responseBody: HttpBodyRecord | null;
      59              :     /// The error of a failed request
      60              :     error: string | null;
      61              : }
      62              : 
      63              : /// The outgoing http requests of the app
      64            1 : export class HttpRequestLog {
      65            1 :     private readonly entries = new Map<number, HttpRequestEntry>();
      66            1 :     private snapshot: readonly HttpRequestEntry[] = [];
      67            1 :     private readonly listeners = new Set<() => void>();
      68            1 :     private nextRequestId = 1;
      69            1 :     private readonly limit: number;
      70              : 
      71            1 :     constructor(limit: number = HTTP_REQUEST_LOG_LIMIT) {
      72            1 :         this.limit = limit;
      73            1 :     }
      74              : 
      75              :     /// The requests, oldest first
      76            1 :     readonly getSnapshot = (): readonly HttpRequestEntry[] => this.snapshot;
      77              : 
      78              :     /// Subscribe to changes
      79            1 :     subscribe = (listener: () => void): (() => void) => {
      80            1 :         this.listeners.add(listener);
      81            1 :         return () => this.listeners.delete(listener);
      82            1 :     };
      83              : 
      84              :     /// Record a sent request
      85            1 :     add(entry: Omit<HttpRequestEntry, 'requestId'>): number {
      86            1 :         const requestId = this.nextRequestId++;
      87            1 :         this.entries.set(requestId, { ...entry, requestId });
      88              :         // Maps iterate in insertion order, the first key is the oldest request
      89            1 :         while (this.entries.size > this.limit) {
      90            1 :             this.entries.delete(this.entries.keys().next().value!);
      91              :         }
      92            1 :         this.publish();
      93            1 :         return requestId;
      94            1 :     }
      95              : 
      96              :     /// Update a request, a dropped request is ignored
      97            1 :     update(requestId: number, patch: Partial<Omit<HttpRequestEntry, 'requestId'>>): void {
      98            1 :         const entry = this.entries.get(requestId);
      99            1 :         if (entry == null) return;
     100            1 :         this.entries.set(requestId, { ...entry, ...patch });
     101            1 :         this.publish();
     102            1 :     }
     103              : 
     104              :     /// Drop all requests
     105            1 :     clear(): void {
     106            1 :         this.entries.clear();
     107            1 :         this.publish();
     108            1 :     }
     109              : 
     110            1 :     private publish(): void {
     111            1 :         this.snapshot = [...this.entries.values()];
     112            1 :         for (const listener of this.listeners) listener();
     113            1 :     }
     114            1 : }
     115              : 
     116              : /// An http client that records its requests in a log
     117            1 : export class RecordingHttpClient implements HttpClient {
     118            1 :     constructor(private readonly inner: HttpClient, private readonly log: HttpRequestLog) { }
     119              : 
     120            1 :     async fetch(input: URL | Request | string, init?: RequestInit & ClientOptions): Promise<HttpFetchResult> {
     121            1 :         const request = input instanceof Request ? input : null;
     122            1 :         const headers = new Headers(request?.headers);
     123            1 :         new Headers(init?.headers).forEach((value, name) => headers.set(name, value));
     124              :         // The content type that fetch derives from the body
     125            1 :         const bodyContentType = init?.body != null ? implicitContentType(init.body) : null;
     126            1 :         if (bodyContentType != null && !headers.has('content-type')) headers.set('content-type', bodyContentType);
     127            1 :         const requestId = this.log.add({
     128            1 :             method: (init?.method ?? request?.method ?? 'GET').toUpperCase(),
     129            1 :             url: request?.url ?? input.toString(),
     130            1 :             requestHeaders: [...headers.entries()],
     131            1 :             requestBody: init?.body != null
     132            1 :                 ? recordRequestBody(init.body, headers.get('content-type'))
     133            1 :                 : request?.body != null ? { kind: 'opaque' } : null,
     134            1 :             sentAt: new Date(),
     135            1 :             respondedAt: null,
     136            1 :             bodyReceivedAt: null,
     137            1 :             failedAt: null,
     138            1 :             status: null,
     139            1 :             statusText: null,
     140            1 :             responseHeaders: null,
     141            1 :             responseBody: null,
     142            1 :             error: null,
     143            1 :         });
     144            1 :         let response: HttpFetchResult;
     145            1 :         try {
     146            1 :             response = await this.inner.fetch(input, init);
     147            1 :         } catch (e: unknown) {
     148            1 :             this.log.update(requestId, { failedAt: new Date(), error: stringifyError(e) });
     149            1 :             throw e;
     150            1 :         }
     151            1 :         this.log.update(requestId, {
     152            1 :             respondedAt: new Date(),
     153            1 :             status: response.status,
     154            1 :             statusText: response.statusText,
     155            1 :             responseHeaders: [...response.headers.entries()],
     156            1 :         });
     157            1 :         return new RecordedHttpFetchResult(response, this.log, requestId);
     158            1 :     }
     159            1 : }
     160              : 
     161              : /// A response that records its body when the caller reads it
     162            1 : class RecordedHttpFetchResult implements HttpFetchResult {
     163              :     /// The body of an error response, read right away since callers often throw without reading it
     164            1 :     private readonly bufferedBody: Promise<ArrayBuffer> | null = null;
     165              : 
     166            1 :     constructor(private readonly inner: HttpFetchResult, private readonly log: HttpRequestLog, private readonly requestId: number) {
     167            1 :         if (inner.status >= 400) {
     168            1 :             this.bufferedBody = this.record(inner.arrayBuffer(), buffer => recordBytes(buffer, this.contentType, HTTP_RESPONSE_PREVIEW_BYTES));
     169              :             // The caller sees a failed read when it reads the body
     170            1 :             this.bufferedBody.catch(() => { });
     171            1 :         }
     172            1 :     }
     173              : 
     174            0 :     get headers(): Headers { return this.inner.headers; }
     175            1 :     get status(): number { return this.inner.status; }
     176            0 :     get statusText(): string { return this.inner.statusText; }
     177            1 :     private get contentType(): string | null { return this.inner.headers.get('content-type'); }
     178              : 
     179            1 :     arrayBuffer(): Promise<ArrayBuffer> {
     180            1 :         return this.bufferedBody ?? this.record(this.inner.arrayBuffer(), buffer => recordBytes(buffer, this.contentType, HTTP_RESPONSE_PREVIEW_BYTES));
     181            1 :     }
     182              : 
     183            1 :     async text(): Promise<string> {
     184            1 :         if (this.bufferedBody != null) return new TextDecoder().decode(await this.bufferedBody);
     185            1 :         return this.record(this.inner.text(), text => recordText(text, HTTP_RESPONSE_PREVIEW_BYTES));
     186            1 :     }
     187              : 
     188            1 :     async json(): Promise<any> {
     189            1 :         if (this.bufferedBody != null) {
     190            1 :             const text = new TextDecoder().decode(await this.bufferedBody);
     191            1 :             return text == '' ? {} : JSON.parse(text);
     192            1 :         }
     193              :         // Parsed bodies are not stringified again, they can be large
     194            0 :         return this.record(this.inner.json(), () => ({ kind: 'parsed' }));
     195            1 :     }
     196              : 
     197              :     /// Record the body once it is read
     198            1 :     private record<T>(body: Promise<T>, toRecord: (body: T) => HttpBodyRecord): Promise<T> {
     199            1 :         return body.then(
     200            1 :             value => {
     201            1 :                 this.log.update(this.requestId, { bodyReceivedAt: new Date(), responseBody: toRecord(value) });
     202            1 :                 return value;
     203            1 :             },
     204            0 :             (e: unknown) => {
     205            0 :                 this.log.update(this.requestId, { failedAt: new Date(), error: stringifyError(e) });
     206            0 :                 throw e;
     207            0 :             },
     208            1 :         );
     209            1 :     }
     210            1 : }
     211              : 
     212              : /// Is a body of this content type text?
     213            1 : function isTextContentType(contentType: string | null): boolean {
     214            1 :     if (contentType == null) return false;
     215            1 :     const mime = contentType.split(';')[0].trim().toLowerCase();
     216            1 :     return mime.startsWith('text/')
     217            1 :         || mime == 'application/json'
     218            1 :         || mime.endsWith('+json')
     219            1 :         || mime == 'application/xml'
     220            1 :         || mime.endsWith('+xml')
     221            1 :         || mime == 'application/x-www-form-urlencoded'
     222            1 :         || mime == 'application/javascript'
     223            1 :         || mime == 'application/graphql';
     224            1 : }
     225              : 
     226              : /// Record a text body, keep at most `limit` characters
     227            1 : function recordText(text: string, limit: number): HttpBodyRecord {
     228            1 :     const byteLength = new TextEncoder().encode(text).byteLength;
     229            1 :     return { kind: 'text', text: text.length > limit ? text.slice(0, limit) : text, byteLength, truncated: text.length > limit };
     230            1 : }
     231              : 
     232              : /// Record a byte body, it is text if the content type says so
     233            1 : function recordBytes(bytes: ArrayBuffer | ArrayBufferView, contentType: string | null, limit: number): HttpBodyRecord {
     234            1 :     const view = bytes instanceof ArrayBuffer
     235            1 :         ? new Uint8Array(bytes)
     236            1 :         : new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
     237              :     // An empty body without content type is no body
     238            1 :     if (!isTextContentType(contentType) && view.byteLength > 0) return { kind: 'binary', byteLength: view.byteLength };
     239            1 :     const truncated = view.byteLength > limit;
     240              :     // A cut through a multi-byte character decodes as a replacement character
     241            1 :     const text = new TextDecoder().decode(truncated ? view.subarray(0, limit) : view);
     242            1 :     return { kind: 'text', text, byteLength: view.byteLength, truncated };
     243            1 : }
     244              : 
     245              : /// Record a request body, a body is text unless it is binary data of a content type that is not text
     246            1 : function recordRequestBody(body: BodyInit, contentType: string | null): HttpBodyRecord {
     247            1 :     if (typeof body == 'string') return recordText(body, HTTP_REQUEST_BODY_BYTES);
     248            1 :     if (body instanceof URLSearchParams) return recordText(body.toString(), HTTP_REQUEST_BODY_BYTES);
     249            1 :     if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return recordBytes(body, contentType, HTTP_REQUEST_BODY_BYTES);
     250            0 :     if (body instanceof Blob) return { kind: 'binary', byteLength: body.size };
     251            0 :     return { kind: 'opaque' };
     252            1 : }
     253              : 
     254              : /// The content type that fetch sends for a body without content type header
     255            1 : function implicitContentType(body: BodyInit): string | null {
     256            1 :     if (typeof body == 'string') return 'text/plain;charset=UTF-8';
     257            1 :     if (body instanceof URLSearchParams) return 'application/x-www-form-urlencoded;charset=UTF-8';
     258            1 :     if (body instanceof Blob && body.type != '') return body.type;
     259            1 :     return null;
     260            1 : }
     261              : 
     262              : /// Is the value of a header masked in the viewer?
     263            1 : export function isSensitiveHeader(name: string): boolean {
     264            1 :     return SENSITIVE_HEADERS.has(name.toLowerCase());
     265            1 : }
     266              : 
     267              : /// Mask the values of sensitive headers
     268            1 : export function maskHeaders(headers: [string, string][]): [string, string][] {
     269            1 :     return headers.map(([name, value]) => [name, isSensitiveHeader(name) ? '••••••••' : value]);
     270            1 : }
     271              : 
     272              : /// Quote a word for POSIX shells
     273            1 : function shellQuote(word: string): string {
     274            1 :     return `'${word.replace(/'/g, `'\\''`)}'`;
     275            1 : }
     276              : 
     277              : /// Format a request as a curl command. The command includes the values of sensitive headers.
     278            1 : export function formatCurlCommand(entry: HttpRequestEntry): string {
     279            1 :     const notes: string[] = [];
     280            1 :     const args: string[] = [`curl ${shellQuote(entry.url)}`];
     281            1 :     const body = entry.requestBody;
     282              :     // curl sends a POST when it has data
     283            1 :     if (entry.method != 'GET' && !(entry.method == 'POST' && body?.kind == 'text')) {
     284            1 :         args.push(`-X ${entry.method}`);
     285              :     }
     286            1 :     for (const [name, value] of entry.requestHeaders) {
     287            1 :         args.push(`-H ${shellQuote(`${name}: ${value}`)}`);
     288              :     }
     289            1 :     switch (body?.kind) {
     290            1 :         case 'text':
     291            1 :             if (body.truncated) notes.push(`# The request body is cut to ${body.text.length} of ${body.byteLength} bytes`);
     292            1 :             args.push(`--data-raw ${shellQuote(body.text)}`);
     293            1 :             break;
     294            1 :         case 'binary':
     295            1 :             notes.push(`# The binary request body of ${body.byteLength} bytes is left out`);
     296            1 :             break;
     297            0 :         case 'opaque':
     298            0 :             notes.push('# The request body is left out, the log could not read it');
     299              :             break;
     300            1 :     }
     301            1 :     return [...notes, args.join(' \\\n  ')].join('\n');
     302            1 : }
        

Generated by: LCOV version 2.0-1