Blog
One structured log line per enrichment call, so the bill and the bug stay answerable weeks later
Every enrichment response carries its cost and age in the headers, then it is gone. Log one line per call so the bill, a failure, and freshness stay answerable.
Every answered lookup bills one credit at $0.005 and hands you an X-Credits-Charged header that says so. The JSON body you parse does not carry that number. It does not carry the age of the data either, or which of the two endpoints you hit, or how close you were to the rate limit. Log only the parsed fields and a month later you cannot answer why a run cost what it cost, whether a row was a fresh read or a week-old copy, or which call was the one that failed.
The fix is one structured line per call, written at the moment of the request. It is a few fields, it is cheap, and it is the difference between reconciling a bill in a query and reconstructing it from memory.
The line
Wrap the call once and log the response metadata every time. None of these fields live in the body, so if you do not capture them here they are gone when the Response is garbage collected.
const BASE = 'https://api.triguna.ai/v1';
async function enrich(path: string, params: Record<string, string>) {
const url = `${BASE}${path}?${new URLSearchParams(params)}`;
const res = await fetch(url, { headers: { ApiKey: process.env.TRIGUNA_API_KEY! } });
// One line per call. Everything here describes the retrieval, not the record.
log.info('enrichment_call', {
path, // /people/profile or /companies/details
status: res.status, // 200, 404, 429, 402, ...
credits: Number(res.headers.get('X-Credits-Charged')), // 1 when billed, 0 when free
source: res.headers.get('X-Data-Source'), // live, store, or stale_fallback
age_seconds: Number(res.headers.get('X-Data-Age-Seconds')),
remaining: Number(res.headers.get('X-RateLimit-Remaining')),
key: params.entity_urn ?? params.company_id, // exactly what you looked up
});
return res;
}
Seven fields. They answer three questions that always come up later, and each one is unanswerable from the parsed record alone.
The bill
X-Credits-Charged is on every response, billed or free. Sum it across a run and you have the exact cost, not an estimate, without waiting for an invoice:
const billed = calls.reduce((n, c) => n + c.credits, 0);
console.log(`run spent ${billed} credits, about $${(billed * 0.005).toFixed(2)}`);
Then reconcile against the balance the server reports. A GET /v1/credits before and after a batch should differ by exactly the sum you logged. If it does not, the gap is calls you made but did not log, which is the thing you most want to find.
The reconciliation only works if your line is honest about what bills. A 404 charges one credit, because searching for an identifier that turns out to have no record is the same retrieval as finding one. A cache hit charges one credit too. What is free is a fixed set of status codes: 400, 401, 402, 429, 502, and 503. Your logged credits field will read 0 for those and 1 for everything answered, which is exactly the split you want to see in a group by status. The pricing page has the full billing contract.
The bug
status plus credits turns a vague “enrichment was flaky yesterday” into a query. Group by status code and the shape of the failure names itself.
A run of 404s on identifiers you expected to resolve is not a retry problem, it is an input problem, and every one of those 404s cost you a credit. Fix the lookup, do not repeat it. See why profile and company lookups return 404 for the identifier traps that cause it. A cluster of 429, 502, or 503 is the opposite: those are free and worth retrying with backoff, and only those. Retrying a 404 or a 402 just spends another credit or hits the same wall. Which codes to retry, and which cost you when you do, is laid out in which enrichment errors to retry.
Quote the real headers when you file the bug, not a paraphrase. A rate limit and a missing record look nothing alike on the wire:
HTTP/1.1 429 Too Many Requests
X-Credits-Charged: 0
X-RateLimit-Remaining: 0
Retry-After: 2
HTTP/1.1 404 Not Found
X-Credits-Charged: 1
The first is free and tells you to wait two seconds. The second billed you and tells you the identifier was wrong. A log line that captured status and credits already told you which one you were looking at.
The staleness
X-Data-Age-Seconds and X-Data-Source are how you judge whether a row you served was fresh. source is one of live, store, or stale_fallback. The first two are the normal path and cost you nothing in freshness inside the 24 hour window. The third is the API answering with a stored copy instead of erroring during an outage, and it can be older than your refresh window.
Log it and a stale_fallback becomes a row you can find and re-read once the source recovers, instead of a silent stale value sitting in a segmentation rule. What each header means, and the decision each one should drive in real time, is in provenance lives in the headers; this line is how you keep the answer after the response is gone.
What not to put in the line
Three fields do not belong in it.
- The
ApiKey. It is a credential, not telemetry. Logging request headers wholesale is the usual way it leaks into a log aggregator. Log the response metadata, never the request auth. - The full body. If you already keep raw responses, and you should, the log line is not the place for a second copy. Log the
keyand let the payload live where you store the raw payload. The line stays small enough to keep forever. - A
sourcefield read from the body. There is not one. Provenance is a header, so readingbody.sourcegets youundefinedand a wrong conclusion.
Where the line pays off
Two places lean on this directly. A nightly backfill that logs every call can prove it stayed under its credit ceiling by summing one column, rather than trusting that it did. And an enrich on signup path, which runs one call in the request hot path, needs the status and age_seconds line to answer “was that signup enriched, was it fresh, and did it cost us a credit” without adding a database read to the flow.
It is also what we mean when we say the surface is small enough to instrument fully. Two endpoints, one auth header, one log line: the team behind the API built it so the whole integration fits in your head, and the observability fits in one function.
Next
- Provenance lives in the headers: what each field in the line means, and the real-time decision it drives.
- Which enrichment errors to retry: the free codes, the billed ones, and the backoff.
- Nightly backfill: pacing and ceilings the logged
creditscolumn lets you verify. - Caching and provenance reference: the source values and the freshness window in full.
- Start with five free credits at app.triguna.ai/signup, no card.