Monitor API integrations with logs and alerts

Learn to log each API call as structured data without secrets, track error rates, latency and credit burn, set alerts, and find a failed call with support.

  • 40 min
  • Advanced

An integration that works is invisible. The first time anyone looks closely is when it stops working, and at that point the questions are always the same: which calls failed, since when, with what status, how slow were they before they failed, how much did they spend, and can the provider see the same request on their side? If your logs cannot answer those in a minute, you are debugging by guesswork during an outage.

This lesson builds the instrumentation that answers them: one structured log record per API call, redaction so those records are safe to keep, metrics and SLOs derived from the records, alerts that fire on the failures that need a human, and the identifiers that connect your log line to APIVerve's.

One structured record per call

Free-text logs like API call failed: 429 are readable by a person and useless to a query. A structured log is one JSON object per event with consistent field names, so "error rate per API over the last hour" is a filter and a count rather than a regular expression.

For an API call, the record worth writing contains:

FieldSourceWhy
apiYour call siteGroup and alert per endpoint
method, url (redacted)Your call siteReproduce the call
statusHTTP statusThe coarse outcome
outcomeYour classifierThe class you alert on (below)
latency_msYour clock, around the callSLOs and timeouts
credits_remaining, credits_used, credits_maxx-api-remaining-credits, x-api-credits-used, x-api-max-creditsBurn rate and runway
cf_raycf-ray headerThe edge request id; present on every response
tracex-cloud-trace-context headerThe origin request id, when the call reached the API
attemptYour retry loopSeparate first tries from retries
errorThe envelope's errorWhat went wrong, in the API's words

Two details from the live API shape that code. First, x-api-credits-used is the credits spent so far in the billing cycle, not the cost of this call; x-api-remaining-credits is what is left, and x-api-max-credits is the cycle's allowance. Second, the envelope's error is not always a string. A missing required parameter comes back as a 400 whose error is an object:

{ "status": "error", "error": { "missing": ["country"] }, "data": null }

A logger that does error.toLowerCase() or concatenates it into a message throws or prints [object Object] at exactly the moment you need it. Serialise whatever is there.

The outcome class is the field that makes alerting possible. Collapse statuses into the handful of situations that need different responses:

  • ok: 2xx with status: "ok".
  • bad_input: 400, or a 404. On lookup APIs a 404 usually means nothing was found for that input; a 404 on every call means the API id itself is wrong. Either way, your request, not an outage.
  • auth: 401. The credential is missing, unrecognised, deleted, expired or suspended.
  • forbidden: 403. A key restriction refused the call (blocked API, IP allow-list).
  • rate_limited: 429 with credits remaining.
  • out_of_credits: 429 with x-api-remaining-credits equal to 0. Check for null first, because Number(null) is 0.
  • server: 5xx.
  • network: no response at all (DNS, reset, timeout).

Here is a wrapper that writes the record. It keeps your existing call logic and adds only observation:

observed-call.mjs
// Node 18+, ESM.
import { redactUrl } from './redact.mjs';

const BASE = 'https://api.apiverve.com/v1';

function classify(status, body, remaining) {
  if (status === 0) return 'network';
  if (status >= 200 && status < 300 && body?.status === 'ok') return 'ok';
  if (status === 400 || status === 404) return 'bad_input';
  if (status === 401) return 'auth';
  if (status === 403) return 'forbidden';
  if (status === 429) return remaining !== null && Number(remaining) === 0 ? 'out_of_credits' : 'rate_limited';
  if (status >= 500) return 'server';
  return 'other';
}

const num = (h) => (h === null ? null : Number(h));
const asText = (e) => (typeof e === 'string' ? e : JSON.stringify(e)); // error can be an object

export async function observedCall(api, { method = 'GET', params = {}, key, attempt = 1, timeoutMs = 10_000, log = console.log } = {}) {
  const url = new URL(`${BASE}/${api}`);
  const init = { method, headers: { 'x-api-key': key }, signal: AbortSignal.timeout(timeoutMs) };
  if (method === 'GET') {
    for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  } else {
    init.headers['content-type'] = 'application/json';
    init.body = JSON.stringify(params);
  }

  const started = performance.now();
  let res = null;
  let body = null;
  let networkError = null;
  try {
    res = await fetch(url, init);
    body = await res.json().catch(() => null);
  } catch (err) {
    networkError = err.name === 'TimeoutError' ? 'timeout' : err.message;
  }

  const h = (name) => res?.headers.get(name) ?? null;
  const status = res?.status ?? 0;
  const record = {
    event: 'api_call',
    api,
    method,
    url: redactUrl(url.toString()),
    status,
    outcome: classify(status, body, h('x-api-remaining-credits')),
    latency_ms: Math.round(performance.now() - started),
    credits_remaining: num(h('x-api-remaining-credits')),
    credits_used: num(h('x-api-credits-used')),
    credits_max: num(h('x-api-max-credits')),
    cf_ray: h('cf-ray'),
    trace: h('x-cloud-trace-context'),
    attempt,
    error: networkError ?? (body?.status === 'ok' ? null : asText(body?.error ?? `HTTP ${status}`)),
  };
  log(JSON.stringify(record));
  return { record, body };
}

Notice what is not in the record: the request headers (which hold the key), the request body (which may hold personal data), and the response data (large, and often personal). Log the shape of the call, not its contents. When you need contents to debug a specific case, sample a small, redacted fraction into a separate, short-retention store.

Check yourself

A dashboard groups failures by the status field alone. Overnight, 429s jump from 20 to 4,000. What can you conclude without more data?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales