Error event metadata: user_id, release, request id and the PII line
Which fields belong on an error event, which ones to hash or drop before it leaves your process, and how Infrai's capture and group routes store them.
Attach four things to every error event and you can answer almost any incident question: an environment, a release, a stable fingerprint, and an opaque reference to the affected user. Everything else is a tag. Infrai takes the first three as top-level fields on POST /v1/errors/capture, so the aggregated group can later tell you which release started the bleeding and whether one customer is hitting it or all of them.
The expensive mistake isn’t attaching too little. It’s attaching the whole request object “just in case” — headers, body, session — and finding out eleven months later that a bearer token and a customer’s email address have been sitting in an error store that half the company can read. Article 5 of the GDPR calls that a data minimisation problem, and an erasure request won’t care that it was accidental.
Decide the field list once, put it behind one function, and the rest is mechanical.
The field list, with the PII column filled in
| Field | What it buys you | PII risk | Where it goes |
|---|---|---|---|
environment | Keeps staging noise out of the production group counts | none | top-level field |
release | Answers “did the fix ship?” and “is this a regression?“ | none | top-level field |
fingerprint | Controls grouping — turns 400 events into 3 problems | low, unless you put an address in it | top-level field |
| user reference | Separates “one unlucky tenant” from “everyone” | high if it’s an email or a name | user_id, hashed |
| request / trace id | Joins the error back to the request log | none when you generate it yourself | a tag |
| route and method | Tells you which endpoint is broken | none | a tag |
| raw headers, cookies, body | Occasionally useful, permanently radioactive | severe | leave it out |
The user reference is the only genuinely hard call in that table. You want it, because user_count on a group is the difference between a P1 and a Tuesday, and you don’t want the raw identifier, because then your error store becomes a system of record for personal data. A keyed hash settles both: HMAC the internal user id with a pepper your error pipeline holds, truncate it, and send that. Support can still map a complaint to a hash by running the same function; an attacker with a database dump can’t enumerate users from a rainbow table because the pepper isn’t in the dump.
What a capture call carries
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"message": "TypeError: Cannot read properties of undefined (reading \"id\")\n at applyDiscount (/app/src/checkout.js:42:17)",
"exception": "TypeError",
"fingerprint": "checkout:applyDiscount:TypeError",
"environment": "production",
"release": "2026.07.4"
}'
The response is the part worth designing around:
{
"ok": true,
"data": {
"event_id": "evt_err_n9yibA8CLwU90UPX18yKZJwe",
"fingerprint": "a3ce50dffa080384f612f21c2c75496dc817c4aceaac873df80d5f375a5b2838",
"error_group_id": "errgrp_ytofEmy7HQhSEwOvvGsld220",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_n9yibA8CLwU90UPX18yKZJwe"
},
"metadata": { "request_id": "req_afae89d470fc4e8886578579", "latency_ms": 25 }
}
Two details there earn their keep. The fingerprint that comes back is a SHA-256 digest of the string you sent, not the string itself — so grouping keys stay stable without being readable. And is_new_group is a boolean regression alarm: true on a release you shipped an hour ago is the signal you actually want paging someone, as opposed to the four hundredth occurrence of a known problem.
Every response also carries metadata.request_id. Log it next to your own request id and the two systems are joinable without a correlation service.
Sending the tags and the user reference
curl -sS -X POST "https://api.infrai.cc/v1/errors/message" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"text": "checkout: discount lookup returned null for an active promo",
"level": "warning",
"environment": "production",
"release": "2026.07.4",
"user_id": "u_9f13c2a4b8e15d07",
"tags": {"route": "POST /checkout", "request_id": "req_9f2c1", "region": "eu-central"}
}'
POST /v1/errors/message is the message-level sibling of capture — free, rate-limited, and it takes the same metadata envelope (tags, user_id, level, environment, release) that the capture route documents. Use it for the near-misses you want counted but not paged on.
Tag values need to be bounded. The error index publishes an ERROR_TAGS_HIGH_CARDINALITY code, which is the API’s way of saying that a tag whose value is a full URL with an order id in it is a cardinality bomb, not a tag. Put the id in the message; put route: "POST /checkout" in the tag.
Redact in the process, not in the dashboard
import { createHmac } from "node:crypto";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const PEPPER = process.env.ERROR_USER_PEPPER;
if (!KEY || !PEPPER) throw new Error("INFRAI_API_KEY and ERROR_USER_PEPPER must be set");
const EMAIL = /[\w.+-]+@[\w-]+\.[\w.]+/g;
const BEARER = /\bBearer\s+[A-Za-z0-9._-]+/g;
const CARD = /\b\d{13,19}\b/g;
export function scrub(text) {
return String(text).replace(EMAIL, "[email]").replace(BEARER, "Bearer [redacted]").replace(CARD, "[number]");
}
export function pseudonym(userId) {
return "u_" + createHmac("sha256", PEPPER).update(String(userId)).digest("hex").slice(0, 16);
}
export async function report(err, { route, release, environment = "production" }) {
const res = await fetch(`${API}/v1/errors/capture`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
message: scrub(err.stack ?? err.message),
exception: err.name ?? "Error",
fingerprint: `${route}:${err.name}`,
environment,
release,
}),
});
if (!res.ok) {
console.error("error capture failed", res.status, await res.text());
return null;
}
const { data } = await res.json();
if (data.is_new_group) console.warn("new error group on this release:", data.dashboard_url);
return data;
}
Scrubbing belongs here, on the way out, because it’s the only place that still knows what the values mean. A regex over err.stack is crude, and it’s also the difference between an incident and a notifiable breach — in our testing the three patterns above catch the overwhelming majority of what leaks into a Node stack trace, since the usual culprit is an interpolated email or token inside an error message somebody wrote by hand.
The pepper lives in your environment, never in the payload.
What the group view hands back
curl -sS "https://api.infrai.cc/v1/errors/group_detail/errgrp_ytofEmy7HQhSEwOvvGsld220" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"error_group_id": "errgrp_ytofEmy7HQhSEwOvvGsld220",
"title": "TypeError: Cannot read properties of undefined (reading 'id')",
"count": 2,
"user_count": 1,
"first_seen_at": "2026-07-25T15:51:03.867078Z",
"last_seen_at": "2026-07-25T15:53:57.588640Z",
"environments": ["staging", "production"],
"releases": ["2026.07.4", "2026.07.5"],
"environment_distribution": {"staging": 1, "production": 1},
"release_distribution": {"2026.07.4": 1, "2026.07.5": 1}
}
That’s the payoff for the discipline. release_distribution tells you the version the problem entered on, environment_distribution tells you whether it survives outside staging, user_count sizes the blast radius, and none of it required storing a name. A group whose releases array contains exactly one version is usually a fresh regression; a group spread evenly across five is an old friend.
Check what actually got stored — the field you never verified is the field that ships a customer’s email to a vendor:
curl -sS "https://api.infrai.cc/v1/errors/get/evt_err_n9yibA8CLwU90UPX18yKZJwe" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | python3 -m json.tool
Retention, residency and erasure
Now the limitations, because a page that only sells doesn’t help you pass a review.
The errors namespace has no delete or redact route — it’s capture, message, list, search, get, groups, group_detail, events and resolve. If your data protection process has to reach individual events on request, that’s a real gap, and the workable design is the pseudonym above: rotate the pepper and the historical hashes stop resolving to anyone. Capture is served from both western and China regions, so where the data lands follows the key you use; if you have a contractual EU-only residency clause, confirm that before you route production traffic.
If what you need is the vendor doing the redaction for you, stick with a platform built around it. Sentry’s server-side scrubbing runs by default and takes configurable rules, and Datadog’s sensitive data scanner works across its logs and error products. Rollbar sits in the same category. Those are better answers than “we regex it ourselves” when an auditor is asking who guarantees the redaction.
What the metadata costs
Captured events are billable at $0.00005 per call — verified 2026-07-25 — and every read route (list, search, get, groups, group_detail, events) is free and rate-limited. New accounts start with $2 free credit, which is roughly 39,999 captures before you’ve paid anything. Get today’s number rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Rates here move down, not up, and discount campaigns run — so what you read may well be cheaper than what’s printed above. The structural fact is the durable one: writes are metered per event, reads aren’t, and adding tags doesn’t change the price of a capture. Nothing about your metadata policy is a cost decision.
| Requirement | Sentry | Datadog | Infrai errors |
|---|---|---|---|
| Vendor-side scrubbing rules | Built in, on by default | Sensitive data scanner | You scrub before sending |
| Stack frames rendered with source maps | Yes | Yes | Not today — the message field holds the text |
| Grouping key you control | Yes | Yes | Yes, via fingerprint |
| Per-event deletion API | Yes | Yes | No |
| Same key also does logs, queue, cron, email | No | Partly | Yes |
That last row is the honest reason to be here. If error tracking with deep frame-level tooling is the only thing you’re buying, Sentry is the better product and you should buy it. If the error store is one surface among the queue, the cron job and the transactional email that the same feature needs, then one credential and one bill start to matter more than the dashboard.