@benhowdle/norrin
v0.3.0
Published
Have we seen this shape before? Fingerprints for distributed-system event sequences, immune to ids, timestamps and jitter.
Maintainers
Readme
norrin
Have we seen this shape before?
norrin reduces a distributed trace to a short fingerprint of what happened, with every id, timestamp, hostname and millisecond of jitter stripped out first. The same kind of incident produces the same fingerprint, so the second time it happens, you know.

Those two files are the same incident, three days apart. Different trace ids, span ids, timestamps, pod names, customer ids, query-parameter order and sibling ordering, plus an extra retry span. Almost every byte differs. norrin says 93.8%, and points at that retry as the only thing that actually changed.
And it remembers

Name a shape once and every later occurrence arrives already carrying the name. That recall is the whole product. Today it lives in the head of whoever was on call in March.
Local-only: signatures are computed and stored on your machine. Nothing is sent anywhere, and there is no telemetry.
Integrating with an AI agent? See AGENTS.md.
Install
npm install @benhowdle/norrin # library
npm install -g @benhowdle/norrin # CLI, installed as `norrin`
npx @benhowdle/norrin --help # no install neededThe package is scoped, the command is not: once installed it is just norrin.
Node 20 or newer. The core engine has zero runtime dependencies and nothing
native.
Use it as a library
import { SignatureEngine } from '@benhowdle/norrin';
const engine = new SignatureEngine({ threshold: 0.9 });
engine.on('signature', ({ sig, match }) => {
if (!match) return console.log(`new shape ${sig}`);
console.log(
`${(match.similarity * 100).toFixed(1)}% match, seen ${match.record.count}×` +
`${match.record.labels.length ? `, ${match.record.labels.join(', ')}` : ''}`,
);
});
engine.ingestOtlp(otlpJson); // or engine.ingest(events) for anything else
await engine.flush(); // close open windows and emitThe match object is the product. Everything else exists to produce it.
Add it to a running app
If the app already uses the OpenTelemetry Node SDK, this is the whole integration. Add norrin's span processor alongside the existing ones, never instead of them: norrin only reads spans, and traces keep going wherever they already go.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NorrinSpanProcessor } from '@benhowdle/norrin/otel';
const sdk = new NodeSDK({
spanProcessors: [
new BatchSpanProcessor(exporter), // your existing pipeline, untouched
new NorrinSpanProcessor(), // norrin, alongside
],
});@opentelemetry/sdk-trace-base is an optional peer dependency. norrin imports
only types from it, so the subpath loads whether or not OTel is installed, and
the core engine keeps its zero runtime dependencies.
Attach a listener through the processor's engine:
const norrin = new NorrinSpanProcessor();
norrin.engine.on('signature', ({ sig, match }) => {
if (match) console.log(`${(match.similarity * 100).toFixed(1)}% match of ${match.sig}`);
});Not using the Node SDK? See the watch recipes below for the sidecar and
collector-fanout routes, or AGENTS.md for the full decision tree.
There is a runnable example in examples/express-otel/.
Why this is hard
Every team has one person who remembers. You page them at 2am, they squint at a trace for ten seconds and say "oh, this is the thing where the payments pool saturates and checkout times out, we saw it in March." That recall is the most valuable thing in the room, and it leaves when they do.
A machine struggles because no two incidents are literally identical. Fresh ids, fresh timestamps, fresh hostnames, jittered latencies, spans arriving in a different order. Exact matching finds nothing. Text similarity drowns, because the noise is most of the bytes.
norrin removes the noise first and fingerprints what is left: which services called which, in what order, succeeding or failing, fast or slow.
It is deliberately not machine learning. No model, no training, no embeddings, no inference cost. A normaliser, a hash and a Hamming distance, and you can read every rule it applies.
CLI
norrin compare <a.json> <b.json>
Fingerprint two OTLP/JSON trace files, print their similarity, then the shingles unique to each side. Exits 0 on a match, 1 otherwise, so it works as a CI gate:
norrin compare baseline.json "$(latest-trace)" --threshold 0.85 || echo "shape changed"| Flag | Meaning |
| --- | --- |
| --threshold <0..1> | similarity that counts as a match (default 0.9) |
| --quiet | print just the percentage |
| --full | print every differing shingle, not the first 12 |
norrin scan <dir>
Fingerprint every *.json trace in a directory against the store. NEW and
SEEN ×N refer to the exact signature. The ≈ line is a different signature that
is still within the threshold: the same kind of thing happening, not quite the same
way.
norrin watch [--port 4318]
An HTTP server that accepts OTLP/JSON POSTs at /v1/traces and streams
signatures. Three ways to feed it, best first.
a. Alongside, in-process. Prefer the span processor over any of this. It needs no extra port, no extra process, and cannot interfere with your existing exporter.
b. In the middle. norrin receives the traces and passes them on untouched:
norrin watch --port 4319 --forward https://collector.example:4318OTEL_EXPORTER_OTLP_PROTOCOL=http/json \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4319 \
node your-app.jsBodies are forwarded byte for byte with their original content type. Forwarding is fire-and-forget and answered after local ingest, so a collector that is down never turns into a failed export for your app; you get one dim warning per run of failures rather than one per request.
Without --forward, repointing OTEL_EXPORTER_OTLP_ENDPOINT at norrin
disconnects your real backend. norrin is not a tracing backend and will not
store your spans. Either use --forward, or use one of the other two recipes.
c. Collector fanout. If you already run an OpenTelemetry Collector, give it a second exporter and leave your existing pipeline alone:
exporters:
otlp/backend: # whatever you already had
endpoint: backend.example.com:4317
otlphttp/norrin:
endpoint: http://localhost:4319
encoding: json # norrin speaks OTLP/JSON only
service:
pipelines:
traces:
exporters: [otlp/backend, otlphttp/norrin]Note that 4318 is the standard OTLP/HTTP port, so a local collector will
already own it. The recipes above use 4319 for exactly that reason; pick any free
port with --port.
· new shape 1cbf36576032604a 10 events trace 9c8b7a6d5e4f3a2b
⚡ 93.8% match, seen 14×, last 3 Aug, label: incident-2026-03-14 1cbf36576032604aA trace's window closes once it has been quiet for --quiet-ms (default 5000).
| Flag | Meaning |
| --- | --- |
| --port <n> | port to listen on (default 4318) |
| --forward <url> | pass every body on to another OTLP/HTTP endpoint, unmodified |
| --json | NDJSON on stdout, status on stderr |
| --quiet-ms <n> | how long a trace must be silent before its window closes |
norrin demo
npx @benhowdle/norrin demoRuns the comparison at the top of this README against two traces bundled with the package. No clone, no arguments. Prints 93.8% and exits 0, which makes it a one-command check that an install is sound.
norrin label <sig> <label>
norrin label 1cbf36576032604a incident-2026-03-14A unique prefix is enough. All three store-backed commands take --store <dir>,
defaulting to .norrin.
How a signature is built
OTLP/JSON -> ingest -> window -> canonicalise -> shingle -> SimHash-64
| | | |
group by trace strip noise 3 views of 64 weighted
into stable the window bit votes
tokens -> a3f9c2...1. Canonicalise. Every rule is data, not code. Ids, hostnames, IPs, emails,
timestamps and path integers become <id>, <host>, <ip>, <email>, <ts>,
<n>. Query strings keep sorted key names and drop values. Durations are bucketed
onto a log scale, because precise latency is noise while order of magnitude is not.
Siblings are sorted by canonical name so concurrency jitter cannot move the result,
while parent to child links are preserved. A span comes out as one token:
server POST /checkout http.request.method=POST http.response.status_code=500 http.route=/checkout !error dur:<10s| Input | Token |
| --- | --- |
| GET /users/8f14e45f-ceea-467a-9f57-1b2c3d4e5f60 | GET /users/<id> |
| /users/123/orders/9 | /users/<n>/orders/<n> |
| pod checkout-7d9f8b6c5-x2k9p | pod <host> |
| https://api.example.com:8443/v1/carts/77 | https://<host>:<port>/v1/carts/<n> |
| /search?b=2&a=1 | /search?a&b |
| 240ms / 331ms | dur:<1s |
Dotted identifiers like pg.query are not treated as hostnames, since only a
:// proves a host. Numbers of three digits or fewer are left alone, so status
codes survive.
2. Shingle. Three overlapping views of the window, weighted:
| Shingle | Weight | What it captures |
| --- | --- | --- |
| n:token | 1 | what happened |
| e:parent>child | 2 | what caused what, the part that identifies a shape |
| s:prev\|next | 1 | what followed what |
Edges are worth double because structure is what makes a shape a shape.
3. SimHash-64. Each shingle is hashed with FNV-1a (64-bit, BigInt, no deps)
and votes ±weight on all 64 bits. The signature keeps the winning side of every
vote. That is what makes it degrade gracefully instead of avalanching: change a few
shingles and only the bits whose votes were close will flip.
Similarity is 1 - hamming(a, b) / 64. The 0.9 default means at most 6 of 64 bits
differ.
4. Match. The store scans linearly by Hamming distance, which is fine to roughly
100k signatures. It is append-only JSONL at .norrin/store.jsonl, replayed
last-write-wins on load and compacted on close.
Appends are queued in the background rather than awaited per write, so a store is
durable once close() has run. scan always calls it, and watch calls it on
SIGINT and SIGTERM. A hard kill (SIGKILL, power loss) can lose the most recent
appends that had not yet reached disk. Earlier signatures, counts and labels are
unaffected, since every line carries the record's full state.
Machine output
Every command takes --json. scan and watch emit NDJSON, one object per line;
compare and demo emit a single object. In JSON mode all startup and status
lines go to stderr, so stdout is safe to pipe straight into a parser, and no ANSI
codes are ever emitted.
These schemas are stable: fields may be added, but existing fields will not change meaning or disappear without a major version.
norrin scan ./traces --json | jq -c 'select(.status == "new")'scan --json, one line per window:
{
"file": "traces/checkout.json",
"sig": "1cbf36576032604a",
"eventCount": 10,
"status": "new",
"count": 1,
"lastSeen": "2026-08-10T12:00:00.000Z",
"labels": [],
"nearest": { "sig": "1cffb6776032204a", "similarity": 0.9375, "distance": 4, "labels": [] }
}status is new or seen, and refers to the exact signature; nearest is a
different signature that is still within the threshold, and is absent when there
is none.
watch --json, one line per signature:
{
"sig": "1cbf36576032604a",
"traceId": "9c8b7a6d5e4f3a2b1c0d9e8f7a6b5c4d",
"eventCount": 10,
"match": {
"sig": "1cbf36576032604a",
"similarity": 1,
"distance": 0,
"count": 14,
"lastSeen": "2026-08-10T12:00:00.000Z",
"labels": ["incident-2026-03-14"]
}
}compare --json, one object. Exit codes are unchanged: 0 on a match, 1 otherwise.
{
"a": { "file": "a.json", "sig": "1cbf36576032604a", "eventCount": 10 },
"b": { "file": "b.json", "sig": "1cffb6776032204a", "eventCount": 11 },
"similarity": 0.9375,
"distance": 4,
"matched": true,
"threshold": 0.9,
"onlyInA": [],
"onlyInB": ["s:client INSERT orders …"]
}Writing custom rulesets
A ruleset is a name and a list of rules. There are six kinds, all data:
import { SignatureEngine, rulesets, type Ruleset } from '@benhowdle/norrin';
const graphql: Ruleset = {
name: 'graphql',
rules: [
// Fold synonyms onto one key, first match wins.
{ kind: 'alias', field: 'graphql.operation.name', from: ['graphql.operation.name', 'gql.op'] },
// Drop fields entirely. `foo.*` is a prefix glob, `*` is everything.
{ kind: 'strip', field: 'graphql.document' },
// An allowlist. One `keep` anywhere makes attribute handling allowlist-only.
{ kind: 'keep', field: 'graphql.operation.type' },
{ kind: 'keep', field: 'graphql.operation.name' },
// Rewrite inside string values.
{ kind: 'replace', field: '*', pattern: /\bcursor:[\w=]+/g, token: 'cursor:<id>' },
// Sort query keys, drop query values.
{ kind: 'query', field: 'url.path' },
// Coarsen a duration. The last bucket rule to match wins.
{ kind: 'bucket', field: 'durationMs', edges: [50, 500], labels: ['fast', 'ok', 'slow'] },
],
};
const engine = new SignatureEngine({
rulesets: [rulesets.base, rulesets.http, graphql],
});Rules are applied in phases rather than in declaration order (alias, strip,
keep, query, replace, bucket), so a ruleset never behaves differently
depending on which file it was concatenated into. Within a phase, order is
preserved.
Two things worth knowing:
baseis safe alone,httpturns on the allowlist.baseonly rewrites noise.httpdeclareskeeprules, and onekeepanywhere means every unlisted attribute is dropped. If you write your ownkeeprules, list everything you need.- Canonicalisation must be idempotent. Running your rules over their own output has to be a no-op, or signatures stop being stable. There is a test for this. Add yours to it.
Not using OpenTelemetry?
engine.ingest() takes anything with a name. Job runs, webhook deliveries, audit
logs, state machine transitions:
engine.ingest([
{ id: 'j1', name: 'job.start', traceId: 'run-88', attributes: { queue: 'billing' } },
{ id: 'j2', parentId: 'j1', name: 'charge.attempt', status: 'error', durationMs: 4200 },
]);API
new SignatureEngine({
window: { by: 'trace' }, // v1 windows by trace id
rulesets: [rulesets.base, rulesets.http],
threshold: 0.9, // similarity that counts as a match
store: undefined, // defaults to JsonlStore('.norrin')
});| Member | Does |
| --- | --- |
| ingestOtlp(json) / ingest(events) / ingestEvents(events) | feed the engine |
| flush() | close every open window, emit, return the events |
| drain(now?) | close only windows that have gone quiet |
| close() | flush, then compact the store |
| label(sig, label) | tag a signature |
| on('signature', cb) | { sig, traceId, eventCount, tokens, record, match?, matches } |
| SignatureEngine.compare(a, b) | static, pure, 0..1 |
| SignatureEngine.signatureOf(events) | static, pure, no store involved |
Bring your own storage by implementing SignatureStore (upsert, nearest,
label, all). MemoryStore and JsonlStore both ship.
Roadmap
- Time-window mode,
{ by: 'time', spanMs }, for event streams with no trace id. TheWindowerinterface is already in place. Only the implementation is missing. - OTel Collector processor, so fingerprinting happens in the pipeline, before storage costs.
- Hosted signature index, opt-in, so a shape one team has already labelled is recognised by the next.
- Agent API, handing an incident responder "this is 94% the shape of
incident-2026-03-14, here are its five sample traces" as a tool call.
The name
In Fantastic Four: First Steps, Reed Richards tracks Galactus by working out that every world he consumed carried the same energy signature — once you can read the signature, a new reading isn't an anomaly, it's a match against something already seen. That's the mechanic this library implements. The name comes from the first Herald, Norrin Radd, who offered himself to Galactus to spare his own world and then flew ahead of him, finding the next one. A herald doesn't predict anything. His arrival is the information: by the time the Silver Surfer appears in your sky, what happens next is already known, because it has happened to a thousand worlds before yours.
Development
npm install
npm run build
npm test
npm run typecheck
vhs docs/compare.tape # regenerate the GIFs (needs charmbracelet/vhs)
vhs docs/scan.tapeThe four files in fixtures/ are the demo and most of the test surface: a healthy
checkout, the same checkout failing on a database timeout, that same failure three
days later with everything incidental changed, and an unrelated search fan-out.
License
MIT
