Neural PII redaction
Mask names, addresses and IDs with an on-device model before logs leave the process
autoRedact is a synchronous pattern pass: it masks emails, IP addresses,
Luhn-valid card numbers, JWTs, and any value under a sensitive key name. What it
cannot see is free-text PII — a person’s name, a street address, a phone number,
a national ID — because those have no fixed shape.
A token-classification model does see them. The logixlysia/desertant subpath
wires one in at the transport boundary, so console output keeps its current
speed and only records leaving the process pay for inference.
import { Redact } from '@desert-ant-labs/redact/native'
import { Elysia } from 'elysia'
import logixlysia from 'logixlysia'
import { createAxiomTransport } from 'logixlysia/axiom'
import { withRedaction } from 'logixlysia/desertant'
const app = new Elysia().use(
logixlysia({
config: {
autoRedact: true, // fast pattern pass, every record
transports: [
withRedaction(createAxiomTransport(), Redact.load, {
onError: error => console.error('[redact]', error)
})
]
}
})
)
GET /orders/42 200 — Anna Müller, Hauptstraße 5, +49 151 23456789
console → Anna Müller, Hauptstraße 5, +49 151 23456789
Axiom → [GIVEN_NAME_1] [SURNAME_1], [STREET_NAME_1] [BUILDING_NUMBER_1], [PHONE_1]
No dependency is added
Logixlysia does not import
@desert-ant-labs/redact — you pass the
model in, and the subpath only describes the shape it needs:
interface NeuralRedactor {
redaction: (
text: string,
options?: NeuralCallOptions
) => Promise<{ redactedText: string }>
withCallGroup?: <T>(body: (group: string) => Promise<T>) => Promise<T>
}
Anything matching that works — another model, a stub in tests, a call out to a DLP service. Nothing about Desert Ant’s install size (~195 MB of native and Wasm builds), its runtime model download, or its source-available licence (free below 100,000 monthly active devices per platform) reaches users who do not opt in.
The model itself is installed by you:
bun add @desert-ant-labs/redact # server-side inference in Node.js
Its native build covers linux-x64, linux-arm64 and darwin-arm64; elsewhere
load() throws a clear error.
Loading the model
withRedaction accepts a redactor, a promise for one, or a loader called once
on the first record. Passing Redact.load directly keeps the model download off
the boot path:
withRedaction(transport, Redact.load) // lazy, on first log
withRedaction(transport, await Redact.load()) // eager, at boot
withRedaction(transport, () => Redact.load({ directory: '/models/redact' })) // self-hosted
A failed load stays failed — retrying per record would hammer the model host —
and every affected record is reported through onError.
Behaviour worth knowing
Redaction runs off the request path. log() returns immediately and the
record is delivered when the model finishes, so a slow model never adds latency
to a response.
Order is preserved. Records reach the transport in the order they were logged, not in the order inference happens to finish.
It fails closed. A record the model could not process is dropped and
reported, never forwarded unredacted. Set onFailure: 'forward' only when
losing the log is worse than shipping raw PII.
The queue is bounded. Inference is far slower than logging, so at most
maxQueue records (default 1000) wait at once; beyond that, records are dropped
and reported rather than growing the queue until the process runs out of memory.
A record dropped this way is never forwarded, whatever onFailure says.
Errors are rebuilt, not mutated. An Error in meta gets a redacted copy —
message, stack and own fields — while the original instance is left untouched
for the rest of the pipeline.
Options
| Option | Type | Default | Description |
|---|---|---|---|
includeMeta |
boolean |
true |
Also walk meta and redact the strings inside it |
labels |
Iterable<string> |
model default | Restrict redaction to these PII categories |
maxDepth |
number |
4 |
Nested levels of meta walked; deeper values pass through |
maxQueue |
number |
1000 |
Records allowed to await the model before new ones are dropped |
minimumConfidence |
number |
model default | Neural confidence threshold; deterministic recognizers always apply |
onError |
(error: unknown) => void |
— | Called on load failure, redaction failure, and dropped records |
onFailure |
'drop' | 'forward' |
'drop' |
What to do with a record the model could not process |
The returned transport exposes flush(), which awaits the queued records and
then flushes the wrapped transport — call it before process exit.
Restricting what gets masked
Every string costs one model call, so narrowing the categories is the cheapest lever when throughput matters:
withRedaction(transport, Redact.load, {
labels: ['GIVEN_NAME', 'SURNAME', 'STREET_NAME', 'PHONE'],
includeMeta: false // message only
})
Keep autoRedact: true on as well: the pattern pass protects console and file
output, which never reach the model, and it costs microseconds.