Bridge Watch derives price, supply, and attestation data from third-party sources (CoinGecko, CoinMarketCap, Circle, Horizon, EVM RPC providers). When a derived value is later disputed — a price spike, a reserve mismatch, a reconciliation break — the raw upstream response is normally gone, and all that survives is the parsed number we stored.
The External Source Response Archive captures that raw response: which source and endpoint, the request shape, the transport outcome, and the body itself (hashed, size-capped, and with obvious secrets redacted). Operators query it to trace a value back to exactly what the source returned at collection time.
Collectors call externalSourceResponseArchiveService.record() after every
upstream call. Archival is best-effort: record() never throws into the
collector and returns null on failure.
| Field | Type | Notes |
|---|---|---|
sourceKey |
string | Logical source, e.g. coingecko |
endpoint |
string | Operation within the source, e.g. simple/price |
method |
string | Request method, default GET |
requestParams |
object | Query/body params; credential-looking keys are redacted before storage |
statusCode |
number | null | HTTP status, if a response was received |
latencyMs |
number | null | Round-trip time |
errorKind |
"timeout" | "transport" | null |
Set when no HTTP response came back |
errorMessage |
string | null | Truncated to 1000 chars |
responseBody |
string | null | Archived body (see size cap below) |
contentType |
string | null | |
collectionRunId |
string | null | Correlates to the job/run that made the call |
subject |
string | null | Asset/entity the request was about (lookup key) |
retentionDays |
number | null | Overrides the source default; null = legal hold (never expires) |
collectedAt |
Date | Defaults to now |
outcome is a coarse classification derived from the transport result:
outcome |
Condition |
|---|---|
ok |
status 2xx/3xx |
client_error |
status 4xx |
server_error |
status 5xx |
timeout |
errorKind: "timeout" |
transport_error |
errorKind: "transport", or no status at all |
Body handling:
- The full body is hashed (
sha256) before truncation, so two captures remain comparable even when both are truncated.body_hashandbody_bytes(original size) are always stored. - Bodies larger than
EXTERNAL_SOURCE_ARCHIVE_MAX_BODY_BYTES(default 256 KiB) are clipped on a UTF-8 boundary and flagged withbody_truncated = true.
- Additive migration; no existing table is altered. The feature is inert until
a collector calls
record(). EXTERNAL_SOURCE_ARCHIVE_ENABLED=falsemakesrecord()a no-op without disabling the read API, so an operator can still inspect what was already captured.- The API lives under the existing
/api/v1/sourcestree and reuses the existingarchive:read/admin:configscopes — no new scope to provision.
Mounted at /api/v1/sources/response-archive.
| Method & path | Scope | Purpose |
|---|---|---|
GET / |
archive:read |
List responses. Filters: sourceKey, subject, outcome, collectionRunId, from, to. Paging: limit (≤200), cursor (opaque collectedAt). Returns { items, nextCursor }. |
GET /stats |
archive:read |
Aggregate counts by source and outcome, oldest row, and expiredPending. Optional ?sourceKey=. |
GET /:id |
archive:read |
One response, metadata only (body omitted — it can be large). |
GET /:id/body |
archive:read |
The archived body plus contentType, bodyHash, bodyBytes, bodyTruncated. |
PATCH /:id/retention |
admin:config |
{ "retentionDays": null } places a legal hold; { "retentionDays": 30 } restores a horizon relative to collectedAt. |
POST /prune |
admin:config |
Run a retention sweep immediately. Returns { deleted }. |
- Unauthenticated →
401; wrong scope →403. - Invalid query/body (bad
outcome, out-of-rangeretentionDays, unparseable dates) →400with a Zoddetailspayload. - Unknown
:id→404.
Table external_source_responses (migration
20260828110000_external_source_response_archive.ts):
- Indexes:
(source_key, subject, collected_at)for value lookup,(source_key, outcome, collected_at)for failure triage,(collection_run_id)for run tracing,(expires_at)for the retention sweep. - CHECK constraints pin
outcometo the enum and forbid negativelatency_ms/body_bytes. expires_at IS NULLmeans "keep indefinitely" (legal hold).
Custom metrics (via getMetricsService()):
| Metric | Labels | Meaning |
|---|---|---|
external_source_responses_archived_total |
source, outcome |
Captures written |
external_source_responses_pruned_total |
— | Rows deleted by retention |
record() failures log at error with sourceKey / endpoint. Prune runs log
the deleted count at info.
| Env var | Default | Effect |
|---|---|---|
EXTERNAL_SOURCE_ARCHIVE_ENABLED |
true |
Master switch for record() |
EXTERNAL_SOURCE_ARCHIVE_RETENTION_DAYS |
30 |
Default retention horizon |
EXTERNAL_SOURCE_ARCHIVE_MAX_BODY_BYTES |
262144 |
Stored body cap |
EXTERNAL_SOURCE_ARCHIVE_PRUNE_BATCH |
500 |
Rows deleted per prune batch (keeps locks short) |
Scheduled job external-source-archive-retention runs daily at 02:30 UTC
(registered in src/workers/index.ts). It deletes only rows past expires_at;
legal holds are never touched.
- Deploy. The migration runs automatically via
npm run migrate. - Confirm
GET /api/v1/sources/response-archive/statsreturns{"total":0,...}with anarchive:readkey. - Leave
EXTERNAL_SOURCE_ARCHIVE_ENABLED=true. Collectors begin populating the archive as they are updated to callrecord(); until then the table stays empty and the API simply returns nothing. - Verify the retention job appears in the scheduler logs
(
Scheduled job system initialized) and that a manualPOST /prunereturns{"deleted":0}.
- Disable without data loss: set
EXTERNAL_SOURCE_ARCHIVE_ENABLED=falseand redeploy.record()becomes a no-op; existing rows remain queryable. - Full rollback:
npm run migrate:downdropsexternal_source_responses(one migration batch). Remove theexternal-source-archive-retentionentry if running a pinned worker build. No other table is affected.
- Open Admin → External Source Response Archive
(
/admin/external-source-archive), or call the API directly. - Filter by
sourceKeyandsubject(the asset code), narrowing withfrom/toaround the time the disputed value was recorded. - Click a row to see the sanitised request params and fetch the raw body.
Check
body_truncated/body_hashif the body looks incomplete. - If the response is evidence for an open incident, use Place legal hold
(
PATCH /:id/retentionwithretentionDays: null) so the retention job cannot delete it. Release hold restores the normal horizon once the incident is closed.