@mrzr/api-client

Logging & Monitoring

Log requests with @mrzr/api-client, report errors to Sentry with onError, track auth events and add tracing headers, without ever logging a token.

Request logging

Logging is per request, so debug output doesn't leak into production:

await api.get("/users", { log: true });

Each logged call produces one entry, on success and on failure. It goes to onLog, or to console.info("[api-client]", entry) if you didn't set one:

const api = createClient({
  onLog: (e) => console.log(`${e.method} ${e.url} → ${e.statusCode} (${e.durationMs}ms)`),
});
interface LogEntry {
  url: string;          // the final URL, params included
  method: HttpMethod;
  statusCode: number;
  status: boolean;
  message: string;
  durationMs: number;   // the whole call, including a refresh and a retry
  timestamp: string;    // ISO 8601
  error?: unknown;
}

durationMs covers the whole call. A slow entry may be two round trips (a 401, a refresh and a retry), not one slow server.

Finding slow endpoints

Group by route, with IDs replaced, or you get one bucket per user:

const stats = new Map<string, { calls: number; totalMs: number }>();

const api = createClient({
  onLog: (e) => {
    const route = `${e.method} ${new URL(e.url).pathname.replace(/\/\d+(?=\/|$)/g, "/:id")}`;
    const s = stats.get(route) ?? { calls: 0, totalMs: 0 };
    stats.set(route, { calls: s.calls + 1, totalMs: s.totalMs + e.durationMs });
  },
});

To log every request, set log: true in a small plugin:

const logAll: ApiPlugin = {
  name: "log-all",
  beforeRequest: (request) => ({ ...request, config: { ...request.config, log: true } }),
};

Error reporting

onError fires for every failed request, whether or not it throws. It never fires for a canceled request, and you can silence it for one call with hideErrorMessage: true.

const api = createClient({
  onError: (res) => {
    if (res.statusCode === 0) return;     // offline or CORS: not actionable
    if (res.statusCode === 401) return;   // the refresh flow handles it
    Sentry.captureMessage(res.message, {
      level: res.statusCode >= 500 ? "error" : "warning",
      extra: { statusCode: res.statusCode, errors: res.errors },
    });
  },
});

Use onError for cross-cutting things: reporting, toasts, an offline banner. Handling a specific failure still belongs in that call's catch. res.data is included, so scrub personal data before sending it anywhere.

Auth events

const api = createClient({
  onAuthStateChanged: (s) => analytics.setUser(s.isAuthenticated ? (s.user as User)?.id : null),
  onAuthFailure: () => analytics.track("session_expired"),
});

Neither ever receives a token, so these are safe to forward to third-party analytics.

Tracing headers

Add a correlation ID per request, or one for the whole session:

await api.get("/users", { headers: { "X-Request-Id": crypto.randomUUID() } });

const api = createClient({ baseUrl, headers: { "X-Session-Id": crypto.randomUUID() } });

For a header on every request that changes each time, like a W3C traceparent, use a beforeRequest plugin.

What never gets logged

Tokens, the Authorization header, and request and response bodies never reach a LogEntry. Tokens never appear in AuthState or in the messages between tabs either.

A debug setup

export const api = createClient({
  baseUrl,
  onLog: (e) => console.log("[api]", e),
  onError: (r) => console.error("[api error]", r),
  onAuthStateChanged: (s) => console.log("[auth]", s),
  onAuthFailure: () => console.warn("[auth] session ended"),
});

console.log("worker:", api.isWorker);
console.log("state:", await api.getAuthState());

On this page