{"_id":"@benhowdle/norrin","_rev":"4-210e0f9055e08a8a4158943e83d98685","name":"@benhowdle/norrin","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@benhowdle/norrin","version":"0.1.0","keywords":["opentelemetry","otlp","tracing","observability","simhash","signature","incident","fingerprint"],"author":{"name":"Ben Howdle"},"license":"MIT","_id":"@benhowdle/norrin@0.1.0","maintainers":[{"name":"benhowdle","email":"benhowdle89@gmail.com"}],"homepage":"https://github.com/benhowdle89/norrin#readme","bugs":{"url":"https://github.com/benhowdle89/norrin/issues"},"bin":{"norrin":"dist/cli/index.js"},"dist":{"shasum":"b6299584bc4d9e723c9c72ade9f8e0411803122c","tarball":"https://registry.npmjs.org/@benhowdle/norrin/-/norrin-0.1.0.tgz","fileCount":18,"integrity":"sha512-uT+BKOcSmc6DPmm+/lwrizlo+g4oOIIm1Hv5c5SBXSpaUSFDMdz2nbMWYMCTnQjISr9F2gSd4eouxlTW3tVBSg==","signatures":[{"sig":"MEYCIQDeXUeZXuWUd1/yMpNHPFbvLLnS56a3HW+bLgKxPdERjAIhAIRPcISVajWqMcy1zhksypNavFact80kU9VnDnU8JvM3","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":578222},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"85ec6bfeb40ac9d391c8056d75fe6bf01a39da10","scripts":{"demo":"sh scripts/demo.sh","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"benhowdle","email":"benhowdle89@gmail.com"},"repository":{"url":"git+https://github.com/benhowdle89/norrin.git","type":"git"},"_npmVersion":"11.19.0","description":"Signatures for distributed-system event sequences — have we seen this shape before?","directories":{},"sideEffects":false,"_nodeVersion":"20.20.1","dependencies":{"citty":"^0.1.6","picocolors":"^1.1.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vitest":"^2.1.8","typescript":"^5.7.2","@types/node":"^20.19.9"},"_npmOperationalInternal":{"tmp":"tmp/norrin_0.1.0_1786361941411_0.1954015793780295","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@benhowdle/norrin","version":"0.2.0","keywords":["opentelemetry","otlp","tracing","observability","simhash","signature","incident","fingerprint"],"author":{"name":"Ben Howdle"},"license":"MIT","_id":"@benhowdle/norrin@0.2.0","maintainers":[{"name":"benhowdle","email":"benhowdle89@gmail.com"}],"homepage":"https://github.com/benhowdle89/norrin#readme","bugs":{"url":"https://github.com/benhowdle89/norrin/issues"},"bin":{"norrin":"dist/cli/index.js"},"dist":{"shasum":"a92e467ad4e059ce987ae8a6443933756143587e","tarball":"https://registry.npmjs.org/@benhowdle/norrin/-/norrin-0.2.0.tgz","fileCount":15,"integrity":"sha512-b+YC7jSkrixLW3nElw3jK2BPzk1fzE7GVOylgFjbh7liHrIM49oeS6xkOOY47iky/nrs6LoAfDT1/MGqwjCT9w==","signatures":[{"sig":"MEYCIQD0M1WaueJ7q5Xq9phfVbmAcLAM057jqC3nlKu1ZNRaGgIhAMRrXiPO3GOKiUCuZijh+p082vyp0aHTF1Lf86oWKYsG","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@benhowdle%2fnorrin@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":588894},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"65c4584d876d95a53e7d4cc1b59e5540be41c397","scripts":{"demo":"sh scripts/demo.sh","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:ec077618-4792-4745-a9d3-417a1793853d"}},"repository":{"url":"git+https://github.com/benhowdle89/norrin.git","type":"git"},"_npmVersion":"11.19.0","description":"Have we seen this shape before? Fingerprints for distributed-system event sequences, immune to ids, timestamps and jitter.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.1","dependencies":{"citty":"^0.1.6","picocolors":"^1.1.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vitest":"^2.1.8","typescript":"^5.7.2","@types/node":"^20.19.9"},"_npmOperationalInternal":{"tmp":"tmp/norrin_0.2.0_1786365136303_0.04026498384171817","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@benhowdle/norrin","version":"0.2.1","keywords":["opentelemetry","otlp","tracing","observability","simhash","signature","incident","fingerprint"],"author":{"name":"Ben Howdle"},"license":"MIT","_id":"@benhowdle/norrin@0.2.1","maintainers":[{"name":"benhowdle","email":"benhowdle89@gmail.com"}],"homepage":"https://github.com/benhowdle89/norrin#readme","bugs":{"url":"https://github.com/benhowdle89/norrin/issues"},"bin":{"norrin":"dist/cli/index.js"},"dist":{"shasum":"57319c88a972862252e30c23ee5a1ecdf4ae20b3","tarball":"https://registry.npmjs.org/@benhowdle/norrin/-/norrin-0.2.1.tgz","fileCount":15,"integrity":"sha512-wPu2x4GL9Yevk1EDT+fTEvpk6L4utj08ORXRi2aChh6+IHEQ938fQDqBNB0DPBKAPVsAoZ4yfW/CSDkjBVtlzQ==","signatures":[{"sig":"MEUCIHx7qeGIn9ggIQLZo/ZUQcIS68KlcIEePTlfTVcHQMvEAiEAnw8C7ccazquzy6BiV+4D0+G9DQ4ybckRGtj3yM88dNo=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@benhowdle%2fnorrin@0.2.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":590101},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"3406778dcf7e4ac8c4fc0fd9b0334227c967faa3","scripts":{"demo":"sh scripts/demo.sh","test":"vitest run","build":"tsup","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:ec077618-4792-4745-a9d3-417a1793853d"}},"repository":{"url":"git+https://github.com/benhowdle89/norrin.git","type":"git"},"_npmVersion":"11.19.0","description":"Have we seen this shape before? Fingerprints for distributed-system event sequences, immune to ids, timestamps and jitter.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.1","dependencies":{"citty":"^0.1.6","picocolors":"^1.1.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vitest":"^2.1.8","typescript":"^5.7.2","@types/node":"^20.19.9"},"_npmOperationalInternal":{"tmp":"tmp/norrin_0.2.1_1786367167077_0.4955205071231856","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@benhowdle/norrin","version":"0.3.0","description":"Have we seen this shape before? Fingerprints for distributed-system event sequences, immune to ids, timestamps and jitter.","keywords":["opentelemetry","otlp","tracing","observability","simhash","signature","incident","fingerprint"],"license":"MIT","author":{"name":"Ben Howdle"},"repository":{"type":"git","url":"git+https://github.com/benhowdle89/norrin.git"},"homepage":"https://github.com/benhowdle89/norrin#readme","bugs":{"url":"https://github.com/benhowdle89/norrin/issues"},"type":"module","engines":{"node":">=20"},"bin":{"norrin":"dist/cli/index.js"},"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./otel":{"import":{"types":"./dist/otel/index.d.ts","default":"./dist/otel/index.js"},"require":{"types":"./dist/otel/index.d.cts","default":"./dist/otel/index.cjs"}},"./package.json":"./package.json"},"publishConfig":{"access":"public"},"sideEffects":false,"scripts":{"build":"tsup","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","prepublishOnly":"npm run build","demo":"sh scripts/demo.sh"},"dependencies":{"citty":"^0.1.6","picocolors":"^1.1.1"},"devDependencies":{"@types/node":"^20.19.9","tsup":"^8.3.5","typescript":"^5.7.2","vitest":"^2.1.8"},"peerDependencies":{"@opentelemetry/sdk-trace-base":">=1.18.0 <3"},"peerDependenciesMeta":{"@opentelemetry/sdk-trace-base":{"optional":true}},"gitHead":"97ffe8e26bc52a379ed5fb7bd14445411393f737","_id":"@benhowdle/norrin@0.3.0","_nodeVersion":"22.23.1","_npmVersion":"11.19.0","dist":{"integrity":"sha512-bBxE5tdl0TppJJaTx+8BRSw3LiLKJHBPmSUnD84PY1FM8INBfFZE3mXKqOP1zl+miGEDqvOEeEGrqnW5eYZPSg==","shasum":"48bf073a692b5157e9256106fabcb2b4ac2b404c","tarball":"https://registry.npmjs.org/@benhowdle/norrin/-/norrin-0.3.0.tgz","fileCount":27,"unpackedSize":980451,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@benhowdle%2fnorrin@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIDndj+4758P0aBBBIkTRqYQgXHPAWYSPV3mAoThlXyWcAiEAtu1AtMxZpyHzZ1CMIpBiJ1ZUwkVERL4U6sxpY0RUInc="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:ec077618-4792-4745-a9d3-417a1793853d"}},"directories":{},"maintainers":[{"name":"benhowdle","email":"benhowdle89@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/norrin_0.3.0_1786369112982_0.8080843762142198"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-10T11:39:01.234Z","modified":"2026-08-10T13:38:33.551Z","0.1.0":"2026-08-10T11:39:01.547Z","0.2.0":"2026-08-10T12:32:16.440Z","0.2.1":"2026-08-10T13:06:07.247Z","0.3.0":"2026-08-10T13:38:33.192Z"},"bugs":{"url":"https://github.com/benhowdle89/norrin/issues"},"author":{"name":"Ben Howdle"},"license":"MIT","homepage":"https://github.com/benhowdle89/norrin#readme","keywords":["opentelemetry","otlp","tracing","observability","simhash","signature","incident","fingerprint"],"repository":{"type":"git","url":"git+https://github.com/benhowdle89/norrin.git"},"description":"Have we seen this shape before? Fingerprints for distributed-system event sequences, immune to ids, timestamps and jitter.","maintainers":[{"name":"benhowdle","email":"benhowdle89@gmail.com"}],"readme":"# norrin\n\n**Have we seen this shape before?**\n\nnorrin reduces a distributed trace to a short fingerprint of *what happened*, with\nevery id, timestamp, hostname and millisecond of jitter stripped out first. The same\nkind of incident produces the same fingerprint, so the second time it happens, you\nknow.\n\n[![CI](https://github.com/benhowdle89/norrin/actions/workflows/ci.yml/badge.svg)](https://github.com/benhowdle89/norrin/actions/workflows/ci.yml)\n[![npm](https://img.shields.io/npm/v/@benhowdle/norrin.svg)](https://www.npmjs.com/package/@benhowdle/norrin)\n[![license](https://img.shields.io/npm/l/@benhowdle/norrin.svg)](./LICENSE)\n\n![norrin compare](https://raw.githubusercontent.com/benhowdle89/norrin/main/docs/compare.gif)\n\nThose two files are the same incident, three days apart. Different trace ids, span\nids, timestamps, pod names, customer ids, query-parameter order and sibling\nordering, plus an extra retry span. Almost every byte differs. norrin says 93.8%,\nand points at that retry as the only thing that actually changed.\n\n## And it remembers\n\n![norrin scan](https://raw.githubusercontent.com/benhowdle89/norrin/main/docs/scan.gif)\n\nName a shape once and every later occurrence arrives already carrying the name.\nThat recall is the whole product. Today it lives in the head of whoever was on call\nin March.\n\n**Local-only:** signatures are computed and stored on your machine. Nothing is sent\nanywhere, and there is no telemetry.\n\nIntegrating with an AI agent? See [AGENTS.md](./AGENTS.md).\n\n## Install\n\n```sh\nnpm install @benhowdle/norrin        # library\nnpm install -g @benhowdle/norrin     # CLI, installed as `norrin`\nnpx @benhowdle/norrin --help         # no install needed\n```\n\nThe package is scoped, the command is not: once installed it is just `norrin`.\nNode 20 or newer. The core engine has **zero runtime dependencies** and nothing\nnative.\n\n## Use it as a library\n\n```ts\nimport { SignatureEngine } from '@benhowdle/norrin';\n\nconst engine = new SignatureEngine({ threshold: 0.9 });\n\nengine.on('signature', ({ sig, match }) => {\n  if (!match) return console.log(`new shape ${sig}`);\n  console.log(\n    `${(match.similarity * 100).toFixed(1)}% match, seen ${match.record.count}×` +\n      `${match.record.labels.length ? `, ${match.record.labels.join(', ')}` : ''}`,\n  );\n});\n\nengine.ingestOtlp(otlpJson);   // or engine.ingest(events) for anything else\nawait engine.flush();          // close open windows and emit\n```\n\nThe `match` object is the product. Everything else exists to produce it.\n\n## Add it to a running app\n\nIf the app already uses the OpenTelemetry Node SDK, this is the whole\nintegration. Add norrin's span processor **alongside** the existing ones, never\ninstead of them: norrin only reads spans, and traces keep going wherever they\nalready go.\n\n```ts\nimport { NodeSDK } from '@opentelemetry/sdk-node';\nimport { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';\nimport { NorrinSpanProcessor } from '@benhowdle/norrin/otel';\n\nconst sdk = new NodeSDK({\n  spanProcessors: [\n    new BatchSpanProcessor(exporter),  // your existing pipeline, untouched\n    new NorrinSpanProcessor(),         // norrin, alongside\n  ],\n});\n```\n\n`@opentelemetry/sdk-trace-base` is an *optional* peer dependency. norrin imports\nonly types from it, so the subpath loads whether or not OTel is installed, and\nthe core engine keeps its zero runtime dependencies.\n\nAttach a listener through the processor's engine:\n\n```ts\nconst norrin = new NorrinSpanProcessor();\nnorrin.engine.on('signature', ({ sig, match }) => {\n  if (match) console.log(`${(match.similarity * 100).toFixed(1)}% match of ${match.sig}`);\n});\n```\n\nNot using the Node SDK? See the `watch` recipes below for the sidecar and\ncollector-fanout routes, or [AGENTS.md](./AGENTS.md) for the full decision tree.\nThere is a runnable example in [`examples/express-otel/`](./examples/express-otel).\n\n## Why this is hard\n\nEvery team has one person who remembers. You page them at 2am, they squint at a\ntrace for ten seconds and say *\"oh, this is the thing where the payments pool\nsaturates and checkout times out, we saw it in March.\"* That recall is the most\nvaluable thing in the room, and it leaves when they do.\n\nA machine struggles because no two incidents are literally identical. Fresh ids,\nfresh timestamps, fresh hostnames, jittered latencies, spans arriving in a different\norder. Exact matching finds nothing. Text similarity drowns, because the noise is\nmost of the bytes.\n\nnorrin removes the noise first and fingerprints what is left: which services called\nwhich, in what order, succeeding or failing, fast or slow.\n\nIt is deliberately not machine learning. No model, no training, no embeddings, no\ninference cost. A normaliser, a hash and a Hamming distance, and you can read every\nrule it applies.\n\n## CLI\n\n### `norrin compare <a.json> <b.json>`\n\nFingerprint two OTLP/JSON trace files, print their similarity, then the shingles\nunique to each side. **Exits 0 on a match, 1 otherwise**, so it works as a CI gate:\n\n```sh\nnorrin compare baseline.json \"$(latest-trace)\" --threshold 0.85 || echo \"shape changed\"\n```\n\n| Flag | Meaning |\n| --- | --- |\n| `--threshold <0..1>` | similarity that counts as a match (default `0.9`) |\n| `--quiet` | print just the percentage |\n| `--full` | print every differing shingle, not the first 12 |\n\n### `norrin scan <dir>`\n\nFingerprint every `*.json` trace in a directory against the store. `NEW` and\n`SEEN ×N` refer to the exact signature. The `≈` line is a *different* signature that\nis still within the threshold: the same kind of thing happening, not quite the same\nway.\n\n### `norrin watch [--port 4318]`\n\nAn HTTP server that accepts OTLP/JSON `POST`s at `/v1/traces` and streams\nsignatures. Three ways to feed it, best first.\n\n**a. Alongside, in-process.** Prefer the [span processor](#add-it-to-a-running-app)\nover any of this. It needs no extra port, no extra process, and cannot interfere\nwith your existing exporter.\n\n**b. In the middle.** norrin receives the traces and passes them on untouched:\n\n```sh\nnorrin watch --port 4319 --forward https://collector.example:4318\n```\n\n```sh\nOTEL_EXPORTER_OTLP_PROTOCOL=http/json \\\nOTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4319 \\\n  node your-app.js\n```\n\nBodies are forwarded byte for byte with their original content type. Forwarding\nis fire-and-forget and answered after local ingest, so a collector that is down\nnever turns into a failed export for your app; you get one dim warning per run\nof failures rather than one per request.\n\n**Without `--forward`, repointing `OTEL_EXPORTER_OTLP_ENDPOINT` at norrin\ndisconnects your real backend.** norrin is not a tracing backend and will not\nstore your spans. Either use `--forward`, or use one of the other two recipes.\n\n**c. Collector fanout.** If you already run an OpenTelemetry Collector, give it a\nsecond exporter and leave your existing pipeline alone:\n\n```yaml\nexporters:\n  otlp/backend:                      # whatever you already had\n    endpoint: backend.example.com:4317\n  otlphttp/norrin:\n    endpoint: http://localhost:4319\n    encoding: json                   # norrin speaks OTLP/JSON only\n\nservice:\n  pipelines:\n    traces:\n      exporters: [otlp/backend, otlphttp/norrin]\n```\n\nNote that **4318 is the standard OTLP/HTTP port**, so a local collector will\nalready own it. The recipes above use 4319 for exactly that reason; pick any free\nport with `--port`.\n\n```\n· new shape 1cbf36576032604a 10 events trace 9c8b7a6d5e4f3a2b\n⚡ 93.8% match, seen 14×, last 3 Aug, label: incident-2026-03-14  1cbf36576032604a\n```\n\nA trace's window closes once it has been quiet for `--quiet-ms` (default 5000).\n\n| Flag | Meaning |\n| --- | --- |\n| `--port <n>` | port to listen on (default `4318`) |\n| `--forward <url>` | pass every body on to another OTLP/HTTP endpoint, unmodified |\n| `--json` | NDJSON on stdout, status on stderr |\n| `--quiet-ms <n>` | how long a trace must be silent before its window closes |\n\n### `norrin demo`\n\n```sh\nnpx @benhowdle/norrin demo\n```\n\nRuns the comparison at the top of this README against two traces bundled with the\npackage. No clone, no arguments. Prints 93.8% and exits 0, which makes it a\none-command check that an install is sound.\n\n### `norrin label <sig> <label>`\n\n```sh\nnorrin label 1cbf36576032604a incident-2026-03-14\n```\n\nA unique prefix is enough. All three store-backed commands take `--store <dir>`,\ndefaulting to `.norrin`.\n\n## How a signature is built\n\n```\nOTLP/JSON  ->  ingest  ->  window  ->  canonicalise  ->  shingle  ->  SimHash-64\n                             |             |               |            |\n                      group by trace   strip noise    3 views of   64 weighted\n                                       into stable    the window   bit votes\n                                       tokens                      ->  a3f9c2...\n```\n\n**1. Canonicalise.** Every rule is data, not code. Ids, hostnames, IPs, emails,\ntimestamps and path integers become `<id>`, `<host>`, `<ip>`, `<email>`, `<ts>`,\n`<n>`. Query strings keep sorted key *names* and drop values. Durations are bucketed\nonto a log scale, because precise latency is noise while order of magnitude is not.\nSiblings are sorted by canonical name so concurrency jitter cannot move the result,\nwhile parent to child links are preserved. A span comes out as one token:\n\n```\nserver POST /checkout http.request.method=POST http.response.status_code=500 http.route=/checkout !error dur:<10s\n```\n\n| Input | Token |\n| --- | --- |\n| `GET /users/8f14e45f-ceea-467a-9f57-1b2c3d4e5f60` | `GET /users/<id>` |\n| `/users/123/orders/9` | `/users/<n>/orders/<n>` |\n| `pod checkout-7d9f8b6c5-x2k9p` | `pod <host>` |\n| `https://api.example.com:8443/v1/carts/77` | `https://<host>:<port>/v1/carts/<n>` |\n| `/search?b=2&a=1` | `/search?a&b` |\n| `240ms` / `331ms` | `dur:<1s` |\n\nDotted identifiers like `pg.query` are *not* treated as hostnames, since only a\n`://` proves a host. Numbers of three digits or fewer are left alone, so status\ncodes survive.\n\n**2. Shingle.** Three overlapping views of the window, weighted:\n\n| Shingle | Weight | What it captures |\n| --- | --- | --- |\n| `n:token` | 1 | *what* happened |\n| `e:parent>child` | 2 | *what caused what*, the part that identifies a shape |\n| `s:prev\\|next` | 1 | *what followed what* |\n\nEdges are worth double because structure is what makes a shape a shape.\n\n**3. SimHash-64.** Each shingle is hashed with FNV-1a (64-bit, `BigInt`, no deps)\nand votes `±weight` on all 64 bits. The signature keeps the winning side of every\nvote. That is what makes it degrade gracefully instead of avalanching: change a few\nshingles and only the bits whose votes were close will flip.\n\nSimilarity is `1 - hamming(a, b) / 64`. The 0.9 default means at most 6 of 64 bits\ndiffer.\n\n**4. Match.** The store scans linearly by Hamming distance, which is fine to roughly\n100k signatures. It is append-only JSONL at `.norrin/store.jsonl`, replayed\nlast-write-wins on load and compacted on close.\n\nAppends are queued in the background rather than awaited per write, so a store is\ndurable once `close()` has run. `scan` always calls it, and `watch` calls it on\nSIGINT and SIGTERM. A hard kill (SIGKILL, power loss) can lose the most recent\nappends that had not yet reached disk. Earlier signatures, counts and labels are\nunaffected, since every line carries the record's full state.\n\n## Machine output\n\nEvery command takes `--json`. `scan` and `watch` emit NDJSON, one object per line;\n`compare` and `demo` emit a single object. In JSON mode all startup and status\nlines go to stderr, so stdout is safe to pipe straight into a parser, and no ANSI\ncodes are ever emitted.\n\nThese schemas are **stable**: fields may be added, but existing fields will not\nchange meaning or disappear without a major version.\n\n```sh\nnorrin scan ./traces --json | jq -c 'select(.status == \"new\")'\n```\n\n`scan --json`, one line per window:\n\n```json\n{\n  \"file\": \"traces/checkout.json\",\n  \"sig\": \"1cbf36576032604a\",\n  \"eventCount\": 10,\n  \"status\": \"new\",\n  \"count\": 1,\n  \"lastSeen\": \"2026-08-10T12:00:00.000Z\",\n  \"labels\": [],\n  \"nearest\": { \"sig\": \"1cffb6776032204a\", \"similarity\": 0.9375, \"distance\": 4, \"labels\": [] }\n}\n```\n\n`status` is `new` or `seen`, and refers to the *exact* signature; `nearest` is a\ndifferent signature that is still within the threshold, and is absent when there\nis none.\n\n`watch --json`, one line per signature:\n\n```json\n{\n  \"sig\": \"1cbf36576032604a\",\n  \"traceId\": \"9c8b7a6d5e4f3a2b1c0d9e8f7a6b5c4d\",\n  \"eventCount\": 10,\n  \"match\": {\n    \"sig\": \"1cbf36576032604a\",\n    \"similarity\": 1,\n    \"distance\": 0,\n    \"count\": 14,\n    \"lastSeen\": \"2026-08-10T12:00:00.000Z\",\n    \"labels\": [\"incident-2026-03-14\"]\n  }\n}\n```\n\n`compare --json`, one object. Exit codes are unchanged: 0 on a match, 1 otherwise.\n\n```json\n{\n  \"a\": { \"file\": \"a.json\", \"sig\": \"1cbf36576032604a\", \"eventCount\": 10 },\n  \"b\": { \"file\": \"b.json\", \"sig\": \"1cffb6776032204a\", \"eventCount\": 11 },\n  \"similarity\": 0.9375,\n  \"distance\": 4,\n  \"matched\": true,\n  \"threshold\": 0.9,\n  \"onlyInA\": [],\n  \"onlyInB\": [\"s:client INSERT orders …\"]\n}\n```\n\n## Writing custom rulesets\n\nA ruleset is a name and a list of rules. There are six kinds, all data:\n\n```ts\nimport { SignatureEngine, rulesets, type Ruleset } from '@benhowdle/norrin';\n\nconst graphql: Ruleset = {\n  name: 'graphql',\n  rules: [\n    // Fold synonyms onto one key, first match wins.\n    { kind: 'alias', field: 'graphql.operation.name', from: ['graphql.operation.name', 'gql.op'] },\n    // Drop fields entirely. `foo.*` is a prefix glob, `*` is everything.\n    { kind: 'strip', field: 'graphql.document' },\n    // An allowlist. One `keep` anywhere makes attribute handling allowlist-only.\n    { kind: 'keep', field: 'graphql.operation.type' },\n    { kind: 'keep', field: 'graphql.operation.name' },\n    // Rewrite inside string values.\n    { kind: 'replace', field: '*', pattern: /\\bcursor:[\\w=]+/g, token: 'cursor:<id>' },\n    // Sort query keys, drop query values.\n    { kind: 'query', field: 'url.path' },\n    // Coarsen a duration. The last bucket rule to match wins.\n    { kind: 'bucket', field: 'durationMs', edges: [50, 500], labels: ['fast', 'ok', 'slow'] },\n  ],\n};\n\nconst engine = new SignatureEngine({\n  rulesets: [rulesets.base, rulesets.http, graphql],\n});\n```\n\nRules are applied in phases rather than in declaration order (`alias`, `strip`,\n`keep`, `query`, `replace`, `bucket`), so a ruleset never behaves differently\ndepending on which file it was concatenated into. Within a phase, order is\npreserved.\n\nTwo things worth knowing:\n\n- **`base` is safe alone, `http` turns on the allowlist.** `base` only rewrites\n  noise. `http` declares `keep` rules, and one `keep` anywhere means every unlisted\n  attribute is dropped. If you write your own `keep` rules, list everything you need.\n- **Canonicalisation must be idempotent.** Running your rules over their own output\n  has to be a no-op, or signatures stop being stable. There is a test for this. Add\n  yours to it.\n\n### Not using OpenTelemetry?\n\n`engine.ingest()` takes anything with a `name`. Job runs, webhook deliveries, audit\nlogs, state machine transitions:\n\n```ts\nengine.ingest([\n  { id: 'j1', name: 'job.start', traceId: 'run-88', attributes: { queue: 'billing' } },\n  { id: 'j2', parentId: 'j1', name: 'charge.attempt', status: 'error', durationMs: 4200 },\n]);\n```\n\n## API\n\n```ts\nnew SignatureEngine({\n  window: { by: 'trace' },              // v1 windows by trace id\n  rulesets: [rulesets.base, rulesets.http],\n  threshold: 0.9,                       // similarity that counts as a match\n  store: undefined,                     // defaults to JsonlStore('.norrin')\n});\n```\n\n| Member | Does |\n| --- | --- |\n| `ingestOtlp(json)` / `ingest(events)` / `ingestEvents(events)` | feed the engine |\n| `flush()` | close every open window, emit, return the events |\n| `drain(now?)` | close only windows that have gone quiet |\n| `close()` | flush, then compact the store |\n| `label(sig, label)` | tag a signature |\n| `on('signature', cb)` | `{ sig, traceId, eventCount, tokens, record, match?, matches }` |\n| `SignatureEngine.compare(a, b)` | static, pure, `0..1` |\n| `SignatureEngine.signatureOf(events)` | static, pure, no store involved |\n\nBring your own storage by implementing `SignatureStore` (`upsert`, `nearest`,\n`label`, `all`). `MemoryStore` and `JsonlStore` both ship.\n\n## Roadmap\n\n- **Time-window mode**, `{ by: 'time', spanMs }`, for event streams with no trace\n  id. The `Windower` interface is already in place. Only the implementation is\n  missing.\n- **OTel Collector processor**, so fingerprinting happens in the pipeline, before\n  storage costs.\n- **Hosted signature index**, opt-in, so a shape one team has already labelled is\n  recognised by the next.\n- **Agent API**, handing an incident responder \"this is 94% the shape of\n  `incident-2026-03-14`, here are its five sample traces\" as a tool call.\n\n## The name\n\nIn 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.\n\n## Development\n\n```sh\nnpm install\nnpm run build\nnpm test\nnpm run typecheck\nvhs docs/compare.tape # regenerate the GIFs (needs charmbracelet/vhs)\nvhs docs/scan.tape\n```\n\nThe four files in `fixtures/` are the demo and most of the test surface: a healthy\ncheckout, the same checkout failing on a database timeout, that same failure three\ndays later with everything incidental changed, and an unrelated search fan-out.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}