What you use when you fix a bug
Think about the last few errors you fixed and what you looked at. The exception type told you what kind of failure it was, and the stack trace told you where. The release told you when it started. How often it happened, and to how many users, told you whether to drop everything, and the route told you which part of the product was affected.
The message helped too, but mostly in two ways: to tell this error apart from others of the same type, and as a hint about the value that failed. The first doesn't need the text, and the second is exactly where personal data comes from. Everything else is structure a developer wrote, not data a user typed:
- The exception class, and the classes of any errors it wraps
- Stack frames: file, line, column, function, and whether it's your own code
- Release, environment and server
- The route or controller action, and the trace id that links it to other services
- Timestamps, counts, and an id for the affected user
- The names of the database tables or procedures involved
Grouping without the message
An error tracker groups occurrences into issues by a fingerprint of the error. In ForgeOps that's the exception class, up to ten of your own stack frames (file and function, without line numbers, so an unrelated edit doesn't split the issue) and the message with its numbers and ids taken out. The message is what keeps "invoice not found" and "card declined" apart when both are a RuntimeError raised from the same method.
Grouping doesn't need the text itself, only something that's equal when two messages match and different when they don't. So with strict privacy on, the SDK normalizes the message in your process and sends the first 32 hex characters of its SHA-256 instead. The steps, in order, each applied to the result of the one before:
- Replace every 0x followed by four or more hex digits with 0x
- Replace every UUID that isn't part of a longer word with <uuid>
- Replace every run of digits that isn't part of a longer word with <n>
- Hash the result's UTF-8 bytes with SHA-256 and keep the first 32 lowercase hex characters
Ruby
require "digest"
WORD = "A-Za-z0-9_"
HEX_OBJECT_ID = /0x[0-9a-fA-F]{4,}/
UUID = /(?<![#{WORD}])\h{8}-\h{4}-\h{4}-\h{4}-\h{12}(?![#{WORD}])/
NUMBER = /(?<![#{WORD}])[0-9]+(?![#{WORD}])/
def message_fingerprint(message)
normalized = message.to_s.gsub(HEX_OBJECT_ID, "0x").gsub(UUID, "<uuid>").gsub(NUMBER, "<n>")
Digest::SHA256.hexdigest(normalized.encode("UTF-8"))[0, 32]
end
puts message_fingerprint("Couldn't find Invoice with id=8821")
puts message_fingerprint("Couldn't find Invoice with id=9004")
Python
import hashlib
import re
WORD = "A-Za-z0-9_"
HEX_OBJECT_ID = re.compile(r"0x[0-9a-fA-F]{4,}")
UUID = re.compile(
rf"(?<![{WORD}])[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-"
rf"[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}(?![{WORD}])"
)
NUMBER = re.compile(rf"(?<![{WORD}])[0-9]+(?![{WORD}])")
def message_fingerprint(message):
normalized = HEX_OBJECT_ID.sub("0x", message or "")
normalized = UUID.sub("<uuid>", normalized)
normalized = NUMBER.sub("<n>", normalized)
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32]
print(message_fingerprint("Couldn't find Invoice with id=8821"))
print(message_fingerprint("Couldn't find Invoice with id=9004"))
JavaScript, Node (fingerprint.mjs)
import { createHash } from "node:crypto";
const W = "A-Za-z0-9_";
const HEX_OBJECT_ID = /0x[0-9a-fA-F]{4,}/g;
const UUID = new RegExp(
`(?<![${W}])[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?![${W}])`,
"g",
);
const NUMBER = new RegExp(`(?<![${W}])[0-9]+(?![${W}])`, "g");
export function messageFingerprint(message) {
const normalized = String(message ?? "")
.replace(HEX_OBJECT_ID, "0x")
.replace(UUID, "<uuid>")
.replace(NUMBER, "<n>");
return createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 32);
}
console.log(messageFingerprint("Couldn't find Invoice with id=8821"));
console.log(messageFingerprint("Couldn't find Invoice with id=9004"));
All three print b6959809369b97323c7852eaeb67d4ef twice: id=8821 and id=9004 normalize to the same text, so both errors land in one issue. A "word" character means an ASCII letter, digit or underscore in every language, so a digit inside an identifier such as v2 or order_42 is left alone, and every SDK gets the same answer for the same message.
What a fingerprint does and doesn't hide
The hash is one-way: nothing about the message can be read back from it. It isn't encryption, though. Someone who already knows or guesses the exact text can hash it and compare, which is why the numbers and ids are taken out before hashing rather than relied on to stay secret, and why a message with a name in it is still worth fixing.
A name in a message hurts grouping as well. Numbers are normalized away and names aren't, so "no coverage for patient 8812" groups with every other patient, while "no coverage for Jane Doe" opens a new issue for every person it happens to. Put ids in messages, not names, and the fingerprint does what you want.
Issue titles without text
An issue still needs a name in a list. Without a message, ForgeOps titles it by its type and where it happened: the route or controller action when there is one (ActiveRecord::RecordNotFound in PatientsController#show), and otherwise the top frame of your own code, as file, line and function. That's often a better title than the message anyway, because it says what failed and where, which is what you scan a list of issues for.
Route-level paths: the template, not the URL
The URL is the other place personal data hides in an otherwise structural report. /patients/jane-doe/12 names someone; /patients/:id/notes is a developer's words with a placeholder. A route template says everything a path does about which code ran, without saying whose record it was. So the rule under strict privacy is to keep a route template whole and almost nothing of a raw path:
- A path is a route template when any segment is a parameter: it starts with : (:id) or * (*path), or is wrapped in {} ({id}), <> (<int:id>) or [] ([slug]). It's kept exactly as it is.
- Otherwise only the first segment is kept, and the rest becomes /:path. A first segment that holds a digit, or is longer than 40 characters, becomes :id.
- The query string and fragment are always dropped.
Python: the path rule, runnable on its own
import re
ROUTE_PARAMETER = re.compile(r"\A(?::.+|\{.+\}|<.+>|\[.+\]|\*.*)\Z")
def strict_path(path):
path = re.split(r"[?#]", path or "", maxsplit=1)[0]
if any(ROUTE_PARAMETER.match(segment) for segment in path.split("/")):
return path # a route template: kept as it is
segments = [segment for segment in path.split("/") if segment]
if not segments:
return "/"
first = segments[0]
if re.search(r"[0-9]", first) or len(first) > 40:
first = ":id"
leading = "/" if path.startswith("/") else ""
rest = "/:path" if len(segments) > 1 else ""
return leading + first + rest
for example in [
"/patients/:id/notes",
"/patients/{id}",
"users/<int:id>/",
"/patients/jane-doe/12?tab=meds",
"/checkout",
"/42/edit",
]:
print(f"{example!r:36} {strict_path(example)}")
Why keep only the first segment of a raw path, rather than replace the parts that look like ids? Because no rule can tell a name from a word: /patients/janedoe looks just like /settings/security. The first segment is nearly always a fixed word from your routes, and the rest could be anything.
In practice you rarely see the fallback, because framework integrations name a request by its route: Rails by its controller action (PatientsController#show), Express, Fastify, Django, Flask and Gin by their route template, and plain Go net/http by the template you pass to SetRequestRoute. The fallback is for a request no route matched, such as a 404 for a made-up address.
Hosts, not URLs, for outbound calls
Traces have the same problem one level down: an outbound call to a lab or payer API is usually named by its full URL. Under strict privacy a call is named by its method and host alone, so GET https://api.example.com/patients/42?x=1 becomes GET api.example.com, with any credentials and port dropped. Span data keeps only the masked SQL statement and which database ran it.
A page address in a browser performance measurement keeps its scheme and host and goes through the same path rule: https://clinic.example.com/patients/jane-doe-1961/chart?tab=2 becomes https://clinic.example.com/patients/:path.
The few fields you decide to keep
Some context is worth having and safe to send: your own error code, which feature failed, a plan tier. Strict privacy keeps the context and tag keys you name, at the top level and with plain values only (text, numbers, true or false). Name them in the SDK and in the project's settings, since each one drops what it doesn't list.
JavaScript, Node
import * as forgeOpsTracker from "@forge-ops/tracker";
forgeOpsTracker.init({
dsn: process.env.FORGE_OPS_DSN,
strictPrivacy: true,
privacyAllowedFields: ["error_code", "feature"],
});
export async function submit(claim) {
try {
await sendToClearinghouse(claim);
} catch (err) {
forgeOpsTracker.captureException(
err,
{ error_code: "CLAIM_REJECTED", feature: "claims", member_name: claim.memberName },
{ id: claim.userId, email: claim.userEmail },
);
// Sent: error_code, feature and the user's id. Never sent: member_name,
// the email and err.message.
throw err;
}
}
What you give up
Be clear about the cost before turning it on:
- No message to read on the issue. You debug from the type, the stack trace and your code, and reproduce with your own test data.
- No breadcrumbs, so no automatic trail of queries, log lines and clicks before the error.
- No SQL statement on a database error (the table and procedure names stay), and no source lines next to each frame.
- No session replays.
- No AI summaries or AI search for the project, since both work from the text.
- A request that matched no route is named by the first segment of its path only.
Everything built from structure keeps working: grouping, regressions, deploy correlation, likely cause, incident detection, customers affected and timelines.
Turning it on in ForgeOps
Strict privacy is off by default. Turn it on for a project in its settings, and in each SDK: strict_privacy in Ruby, Python, Elixir, Perl, Rust, C and C++, strictPrivacy in JavaScript, PHP, Java, Kotlin, Swift, Objective-C and Dart, StrictPrivacy in .NET, Unity and Go, or FORGE_OPS_STRICT_PRIVACY=true in the environment. All 20 SDKs have it from their October 20, 2026 releases, such as forge_ops_tracker 0.19.0 for Ruby.
The SDK removes the text before anything leaves your process, and the project setting applies the same rules again on arrival, so one app with an older SDK can't undo it. Errors stored before you turn it on are kept as they were.
Read more: