@apicity/har
Dependency-free Node.js HAR 1.2 serialization, secret scrubbing and opt-in
request/response capture. createHarCapture provides an injectable fetch,
call scopes, optional global installation, and scrubbed files with a JSONL index.
See the capture guide for setup and retention details.
import { exchangeToEntry, scrubHar, pollyEntryId } from "@apicity/har";
declare const request: Request; // the request you sent
declare const response: Response; // its response, body unread
const entry = await exchangeToEntry(request, response, {
startedAt: new Date(),
durationMs: 120,
});
entry._id = pollyEntryId(entry);
createHarCapture({ dir, mode: "all" | "failures", providers?, maxBodyBytes?, maxSummarizeBytes?, sseTailBytes?, context?, onError?, clock?, idFactory? })returns{ fetch, call, install, flush, close }. Wrap provider work incall({ provider, endpoint, input, context?, estimate? }, fn)and awaitclose()before exit; a call'scontextis merged shallowly over the capture'scontextfor that call only.estimate(usd,source,rateAsOf,warnings,breakdown,status) is written tolog._apicity.estimateand asestimateUsdinindex.jsonl.inputis written with each data URL or base64 field over 1024 characters, and each byte array, replaced by a placeholder of its hash, decoded size and media type. Capture is disabled after a write error without changing the provider result. A body that fails mid-read does not disable it: the exchange is saved withbodyTruncated, andresponseCutfor a response, and the call's outcome isnetwork_error.exchangeToEntry(request: Request, responseOrError: Response | Error, timing, options?)reads clones and returns a scrubbed entry. Timing hasstartedAt,durationMsand optional HARtimings. Options aremaxBodyBytes(262144 by default),maxSummarizeBytes(16 MiB),sseTailBytes(64 KiB),streamedandrole. Binary and oversized bodies become SHA-256, byte-count and MIME-type placeholders, except a JSON request up tomaxSummarizeByteswhose text fits the cap once its media is summarized. Multipart files become name/type/size summaries. Inline data URLs and long base64 are summarized. A text stream over the cap keeps its firstmaxBodyBytesand lastsseTailBytes, in whole lines joined by an: apicity-gap <n> bytes droppedcomment;_apicity.streamedandbodyTruncateddescribe them. A body that fails mid-read keeps what arrived: a stream's whole lines, or a placeholder for anything that does not parse as a whole JSON document. A network error produces status 0; call-level error metadata belongs inlog._apicity.streamIncompletemarks a wire ending without a newline or without final usage in a recognized Anthropic, OpenAI or Google stream; it also marks a whole retained JSON response that does not parse, even when the transport reports clean EOF. Such JSON is saved only as a hash/size placeholder. It setsbodyTruncatedand keeps cost evidence unsettled. Unclassifiable frames or lines over 1 MiB in a recognized protocol are incomplete. This is independent of retention caps andresponseCut(transport failure): complete final usage followed by a transport failure can still settle. Legacy captures withoutstreamIncompleteretain their existing cost semantics. Bodies must be unused and streams must eventually finish; an aborted request cancels its capture copy so it cannot hold up flushing.scrubEntry(entry)andscrubHar(har)mutate and return their argument. Scrubbing covers provider credentials in headers, URLs, JSON and form-encoded bodies, cookies, response account metadata and_apicityinput/estimate/context/error fields. It preserves prompts and ordinary payloads. It is not a general personal-data anonymizer.pollyEntryId(entry, options?)hashes the identifying fields using the repository's default harness matching rules. Custom matching requiresexcludeHeaders,headers: false, orbody: falseas appropriate. The URL is normalized as Polly's url-parse and qs normalize it: keys are sorted and RFC 3986 escaped, repeated, valueless and bracket keys are kept, and the fragment is dropped. Summarized bodies cannot reproduce the identity of their original raw bytes, nor can a query part with a malformed percent escape once aRequesthas re-encoded it. A recorded URL with an unclosed[or more than five bracket levels in a key hashes as a new request to that URL would, not as recorded: Polly's re-serialization is not stable there.pollyRecordingDir(name)implements Polly's per-segment slug and FNV-1a hash.- HAR types:
Har,HarLog,HarEntry,HarRequest,HarResponse,HarContent,HarPostData,HarNameValue,HarCookie,HarPage,HarCacheEntry,HarTimings,ApicityCallMetadata, andApicityEntryMetadata. - Options:
ExchangeTiming,ExchangeOptions,PollyIdentityOptions.
Compatibility exports for the recording tools: redactPersistedHarSecrets,
persistSafeTextRequestBody, summarizeJsonRequestBodyMedia,
summarizeMultipartFormData, summarizeMultipartRequestBody,
redactedRequestId, PersistedHarRecording, REDACTED_HAR_VALUE,
HarHeaderLike, HarCookieLike, HarRequestLike, HarResponseLike,
HarRecordingLike, isSensitiveRequestHeaderName,
isSensitiveResponseHeaderName, scrubSensitiveRequestHeaders,
scrubSensitiveResponseHeaders, scrubRequestCookies, scrubResponseCookies,
scrubSensitiveResponse, scrubPolymarketAuthArtifacts,
scrubSensitiveRecording, PERSISTED_REQUEST_HEADER_PLACEHOLDERS,
persistedRequestHeaderPlaceholder, redactFalCheckpoints,
findUnredactedRequestSecrets, and findLiveFalCheckpoints.
Repository integration
The package owns the rules. tests/harness.ts and tests/har-scrub.ts are
compatibility exports; the Vitest source alias runs against this package.
Standalone HAR viewer/report/Telegram tools import the same source through
har-data.ts and har-scrub.ts using a relative path, so tsx also works
from outside the repository without a package build or a Vitest alias.
gen:shared copies src/har-secrets.mjs to scripts/lib/har-secrets.mjs
(the shared-source manifest pins it). This committed, generated copy lets
gen:examples:check, lint:recordings and doc-gen:check run on a clean checkout
before any package build. gen:shared:check prevents drift. No runtime package
dependency or build-on-import is needed by those scripts.
Cost evidence
entryToProviderExchange turns one HAR entry into the exchange shape
extractActual in @apicity/cost reads: lower-cased header names, parsed JSON or
SSE data frames, and a null requestBody for a captured placeholder. This package
does not import @apicity/cost; the shapes match structurally.
import { entryToProviderExchange, type HarEntry } from "@apicity/har";
declare const entry: HarEntry;
const exchange = entryToProviderExchange(entry, {
provider: "openai",
dotPath: "v1.responses",
});
exchange.responseHeaders["content-type"];
provider falls back to the one that owns the request's host.
Exchange analysis helpers
exchangeSucceeded(provider, entry) requires HTTP 2xx and rejects KIE and xAI
failure envelopes. responseObject(entry) decodes JSON object responses,
including base64 HAR content.
findMatchingRow(entry, rows, {provider}) first matches full URLs, then uses a
provider-scoped path match, then matches full URLs again with fal's
{endpoint_id} spanning whole path segments (fal-ai/flux/dev). Rows carry
provider, method, dotPath and fullUrl. matchHarEntryStrict,
matchHarEntryLenient, matchHarEntrySpanning and compileTsvUrlPattern
expose the individual matching operations. These are the canonical
implementations also synced to the repository scripts.