@lunora/fingerprint is Lunora's deterministic error-grouping core: it folds
every occurrence of "the same" error onto one stable 16-character hash (an
Issue), no matter where the error was observed. A live observability sink
event and a request-log row persisted hours earlier compute the same hash, so
the local Studio Issues panel and a Lunora Cloud incident describe the same
object.
The core algorithm is vendored from Superlog Labs' @superlog/fingerprint
(Apache-2.0, see the package NOTICE), with the Node-only crypto backend
replaced by a portable synchronous SHA-256. The package is zero-dependency
and runs unchanged in the browser (Studio), the Cloudflare Workers (workerd)
runtime, and Node.
import { fingerprintError } from "@lunora/fingerprint";
const fp = fingerprintError({
functionPath: "messages:list",
message: "User 12345 not found",
code: "NOT_FOUND", // metadata only — never part of the hash
});
// → { hash: "168d714cba85f1c8", title: "User 12345 not found",
// culprit: "messages:list", bucket: "user <n> not found", code: "NOT_FOUND" }The hash contract
fingerprintError({ functionPath, message, code? }) hashes over exactly
sha256("lunora::" + functionPath + "::" + bucket(message))truncated to 16 hex characters. Two things follow from that:
functionPath+ normalized message is the whole identity. That pair is the one thing every Lunora error source can supply: an in-flight observability event carries{ code, message, status }, while the durable request log (__lunora_reqlog__) persists onlyoutcome+error_message, with no stored code. Hashing anything more would split a live Issue from one recomputed from history.codeis display metadata, never hashed. It rides along on the result (fp.code) for badges and filtering, but changing it does not create a new Issue.
The result also carries a title (first message line, capped at 120 chars),
the culprit (the function path, or container:<name> for container
lifecycle errors, which flow through the same seam), and the bucket the hash
was built from, which is what you inspect when two errors did or didn't group.
fingerprintError is pure and synchronous, so it is safe to call per row when
grouping a request-log page client-side.
The message bucketer
Raw messages embed per-occurrence noise: a route scanner probing 5,000 paths
or a per-user id in the message must not explode one bug into 5,000 Issues.
messageBucketFor(message) normalizes the message before hashing by
stripping/tokenizing:
- URLs and request paths
- UUIDs, long ids, and hex blobs
- IP addresses and timestamps
- bare numbers (
"User 12345 not found"→"user <n> not found")
so a sweep of GET /wp-admin/... 404s folds into a single Issue. The bucketer
(and normalizeMessage, its heavier sibling) are exported for inspection and
tests.
Stack-aware variants
For OTLP-sourced telemetry that carries a real exception.stacktrace, the
stack-aware fingerprint / fingerprintLog exports add top-5 user-frame
normalization on top of the message bucket, the vendored superlog behavior.
Inside a Lunora deployment you almost always want fingerprintError: Lunora
errors carry no stack frames on the wire.
Where it feeds
@lunora/docomputes fingerprints over the bounded request-log readout behind thegetIssuesadmin RPC.- Studio renders the grouped result in the Issues panel: one row per
hash, with occurrence counts, first/last seen, and the
codebadge. - Lunora Cloud computes the same hash over durable OTLP telemetry, so a local Issue and a cloud incident share an identity.
You normally don't install this package directly; it arrives as a dependency
of @lunora/do and the Studio. Reach for it yourself only to group errors in
your own tooling with the same identity Lunora uses.