Ship structured logs from any backend to Axiom, grep them with APL (SQL-like), keep 30 days on the free tier. Sentry catches exceptions; Axiom catches everything else you printed.
A dataset is the bucket your logs land in. Dashboard β Datasets β New dataset. Name it after the service: api-prod, web-prod, worker-prod. Separate datasets per service make queries cheaper and retention configurable per service.
Don't dump everything into one logs dataset β once you have 10M events you'll regret it.
Settings β API tokens β New API token. Scope: Ingest only (for the app). For the read side (queries from a dashboard), create a separate Query token.
Add to .env:
AXIOM_TOKEN=xaat-β¦
AXIOM_DATASET=api-prod
AXIOM_ORG_ID=your-org-id # under Settings β Organization
Install the SDK:
npm install @axiomhq/js
Wire it up β preferably behind pino so your code stays portable:
import pino from "pino";
import { AxiomJSTransport } from "@axiomhq/pino";
import { Axiom } from "@axiomhq/js";
const axiom = new Axiom({ token: process.env.AXIOM_TOKEN });
const logger = pino(
{ level: "info" },
new AxiomJSTransport({
axiom,
dataset: process.env.AXIOM_DATASET,
}),
);
// Usage
logger.info({ userId: user.id, action: "signup" }, "user signed up");
logger.error({ err, requestId: req.id }, "stripe webhook failed");
The transport batches every 1s or 1MB, so per-log overhead is near-zero. Crash-safe: a synchronous flushSync() on shutdown ships the tail.
Docs: Send data from Node.js.
You don't need the SDK β install the integration. Native log drain, no code change.
console.log + request log streams to your dataset.APL = Axiom Processing Language. SQL-shaped, optimized for time-series. Open Datasets β api-prod β Query:
// Last hour of errors
['api-prod']
| where _time > ago(1h) and level == "error"
| project _time, msg, requestId, userId
// Top 10 endpoints by error rate
['api-prod']
| where _time > ago(24h)
| summarize errors=countif(level == "error"), total=count() by route
| extend errorRate = round(100.0 * errors / total, 2)
| top 10 by errorRate
// Drill into one user's session
['api-prod']
| where userId == "u_abc123" and _time > ago(7d)
| order by _time asc
Save useful queries as starred queries (kept in the sidebar) and turn high-value ones into Monitors (next step).
APL reference: axiom.co/docs/apl/introduction.
Monitors β New monitor. Pick a query, a threshold, a window. Examples:
countif(level == "error") > 50 over 5 min β page oncallcountif(msg contains "OOM") > 0 β Slack #infracountif(path == "/api/checkout" and status >= 500) > 5 β page foundersNotification channels: Slack, PagerDuty, Discord, email, webhook. Wire Opsgenie or PagerDuty via webhook if you have on-call rotations.
logger.info("user signed up")) ships a single msg field. Pass an object first: logger.info({ userId }, "user signed up"). Fields become queryable columns.redact: ["req.headers.authorization", "*.email", "*.password"].flushInterval to 500ms or use the dashboard's live mode (it polls every 2s)._time from the payload. If your host's clock is off by hours, logs appear in the wrong bucket. Run chrony or timesyncd.Pricing page: axiom.co/pricing.