← Back to fasten

Installation

PyPI and npm packages publish at v1.0.0-beta.0 (roadmap). Until then, install from source.

SDKPrerequisitesNative dep?
PythonPython 3.10+, pipNone — pure Python
GoGo 1.22+None — pure Go, CGO_ENABLED=0 friendly
RustRust 1.85+None — cargo handles everything
C++14Any C++14 compiler (GCC 5+, Clang 3.4+, MSVC 2015+)None — single-header copy
Node / TypeScriptNode 24+None — pure JS
SwiftSwift 5.9+None — pure Swift

Today — Install From Source

bash
pip install ./python # Python — no prerequisites beyond Python 3.10 go get github.com/nerdapplabs/fasten/go # Go — no prerequisites beyond Go 1.22 npm install ./js # Node — no prerequisites beyond Node 24 # Swift: swift package resolve (no Rust toolchain required) # C++14: copy cpp/include/fasten.hpp — no build step

Registry Publish — Pending v1.0 GA Tag roadmap

bash
pip install fasten # Python — reference impl npm install @nerdapplabs/fasten # Node / TypeScript cargo add fasten # Rust

5-Minute Quickstart

1Register Audit Codes — Once, at Startup

python
import fasten from fasten.codes import register, Meta, Severity, RetentionClass register("user", { "USER_CREATED": Meta( domain="user", category="account", action="create", severity=Severity.INFO, description="New user account created", emitter="auth-service", retention_class=RetentionClass.LONG, ), "USER_DELETED": Meta( domain="user", category="account", action="delete", severity=Severity.WARN, description="User account permanently deleted", emitter="auth-service", retention_class=RetentionClass.LONG, ), })

2Init — Reads Env Vars; FASTEN_AUDIT_DSN Required

.env
FASTEN_AUDIT_DSN=sqlite:///./audit.db # or postgres://...
python
fasten.init(service_id="auth-service", node_id="host-01") # reads FASTEN_AUDIT_DSN; raises RuntimeError if unset. # Audit rows go to durable storage — no silent in-memory fallback.

3Emit Anywhere

python
fasten.emit(code="USER_CREATED", target="u-42", actor="admin", detail={"email": "alice@example.com"}) fasten.log.info("signup_complete", user_id="u-42")

Production: Env-Var Driven

.env
FASTEN_SERVICE_ID=auth-service FASTEN_NODE_ID=host-01 FASTEN_AUDIT_DSN=postgres://user:pw@db:5432/appdb
python
fasten.init() # reads everything from env — same emit() call

Other Languages — Same Shape

Per-SDK READMEs in the repo carry the equivalent quickstart for each language; they all share spec/row-schema.json as the wire-format source of truth.

LanguageQuickstartWorked example
Python python/README.md FastAPI service
Go go/README.md net/http service
Node.js / TSjs/README.md node:http service
Rust rust/README.md tiny_http service
C++14 cpp/README.md connector + reader
Swift swift/ package
Java placeholder — emit().write() throws UnsupportedOperationException with a pointer to the other SDKs.

What You Actually See

Every emit() and log.* writes one NDJSON line to stdout. Docker's log driver captures and rotates it.

emit("USER_CREATED", …) → stdout

stdout — shape: audit
{ "shape": "audit", "id": "evt-a1b2c3d4e5f6a7b8c9d0", "monotonic_seq": 1, "timestamp": "2026-04-24T10:23:45.123Z", "code": "USER_CREATED", "action": "create", "severity": "info", "service_id": "auth-service", "source_node_id": "host-01", "actor": "admin", "actor_kind": "human", "target": "u-42", "category": "account", "domain": "user", "method": "http", "request_id": "d4e5f6a1b2c3", "detail": {"email": "alice@example.com"} }

log.info("signup_complete", …) → stdout

stdout — shape: sys
{"shape":"sys","level":"info","event":"signup_complete","request_id":"d4e5f6a1b2c3","service_id":"auth-service","timestamp":"2026-04-24T10:23:45.124Z","user_id":"u-42"}

Both lines share request_id: "d4e5f6a1b2c3" — the join key across all three streams.

Querying Back

bash
# All audit rows for one request curl "http://localhost:8080/api/v1/logs/audit?request_id=d4e5f6a1b2c3" # Recent warnings in syslog curl "http://localhost:8080/api/v1/logs/sys?level=warn&limit=50" # USER_DELETED events in the last 7 days curl "http://localhost:8080/api/v1/logs/audit?code=USER_DELETED&since=2026-04-17T00:00:00Z" # Filter by actor + target, paginate curl "http://localhost:8080/api/v1/logs/audit?actor=admin&target=u-42&limit=20&offset=0"

Audit responses are {"rows": [...], "total": N, "limit": L, "offset": O}. Sys / api responses are {"rows": [...]} (ring buffers, no offset).

Debugging a Real Incident

Scenario: A pipeline config change was applied at 14:32. By 14:33 the MQTT broker shows message loss.

Without fasten

  1. docker logs gateway | grep 14:32 — hundreds of lines, no actor info
  2. docker logs pipeline-engine | grep 14:32 — different format, different timestamps
  3. Cross-reference manually — which request caused which effect? Unknown.
  4. Ask on Slack "who changed the config?" — 20-minute delay

With fasten — One Query, 30 Seconds

bash
curl "/api/v1/logs/audit?since=2026-04-24T14:30:00Z&until=2026-04-24T14:35:00Z"
response — 3 rows, same request_id threads all of them
{ "total": 3, "limit": 100, "offset": 0, "rows": [ { "timestamp": "14:32:11Z", "code": "CONFIG_NODE_UPDATED", "actor": "praveen", "actor_kind": "human", "method": "http", "target": "pipelines/modbus-01/connection/url", "request_id": "req-9f8e7d6c", "detail": {"old": "modbus://10.0.1.10", "new": "modbus://10.0.2.10"} }, { "timestamp": "14:32:12Z", "code": "CONNECTOR_DISCONNECTED", "actor": "system", "method": "mqtt", "target": "modbus://10.0.1.10", "request_id": "req-9f8e7d6c" }, { "timestamp": "14:32:14Z", "code": "CONNECTOR_CONNECTED", "actor": "system", "method": "mqtt", "target": "modbus://10.0.2.10", "request_id": "req-9f8e7d6c" } ] }

Same request_id threads the HTTP config change, MQTT disconnect, and reconnect — across two services and two transports. 30 seconds vs 20 minutes.

The 7 Anchors

5 Ws + H + CORRELATION — enforced at the type level. Caller supplies only code, target, and optional detail. Everything else auto-fills.

AnchorFieldsAuto-filled?
WHO actor, actor_kindfrom ctx / caller
WHAT code, actioncode from caller; action from Meta
WHEN timestamp, monotonic_seqauto
WHERE source_node_id, service_id, tenant_idauto from fasten.init()
WHOM target, category, domaintarget from caller; rest from Meta
HOW method ∈ {http, mqtt, cli, scheduler, ui, agent_tool}auto from shim / caller
CORRELATION request_idfrom context; minted if absent

WHY lives in detail.reason (free text). A policy plugin can enforce it on mutation codes.

Three Streams

StreamStorageEndpointPersistent?
syslog In-memory ring (10k lines) /logs/sys No — ring only
API logRing + opt-in SQL /logs/api Opt-in via FASTEN_API_DSN
audit SQL fasten_audit/logs/auditYes — per-code TTL

All three carry request_id. A ?request_id=<id> query returns rows from all services that carried it.

Shims

Nothing default-on. Import only what your transport uses. Each shim does one thing: read an id from the wire → stash in context → propagate downstream.

ShimWire conventionStatus
shim.http X-Request-ID header (mint if absent) bundled
shim.mqtt _req field inside payload bundled
shim.scheduler Mints scheduler-<run_id> at job startbundled
shim.agent_toolAgent tool-call context propagation planned
Custom (gRPC, NATS, …) 10-line pattern: read wire → set ctx

Schema

fasten owns its audit table; your product owns its own DB. They share a request_id join key and never share a table. Two storage topologies are supported:

SQLite — single-node
{DATA_DIR}/fasten.db ← fasten owns this (created by fasten.init()) └── fasten_audit ← one row per emit(); TTL-swept by fasten └── fasten_api_log ← opt-in; one row per HTTP request {DATA_DIR}/your-app.db ← your product owns this ├── ...your tables... └── audit_replay ← YOUR table; soft-ref to fasten_audit.id
Postgres — three naming strategies
# Dedicated DB — cleanest isolation FASTEN_AUDIT_DSN=postgresql://fasten_user:pw@db/fasten_audit # Shared DB, dedicated schema — fasten lives in its own namespace FASTEN_AUDIT_DSN=postgresql://app_user:pw@db/myapp?table=fasten.audit_log # Shared DB, prefix-only — no new schema needed FASTEN_AUDIT_DSN=postgresql://app_user:pw@db/myapp?table=fasten_audit_log

fasten_audit Columns

ColumnTypeNotes
idTEXT PKevt-<20-hex>
codeTEXTe.g. USER_CREATED
actor / actor_kindTEXTWHO anchor
target / category / domainTEXTWHOM anchor — adopter-defined strings
service_id / source_node_id / tenant_idTEXTWHERE anchor — from init()
methodTEXTHOW anchor
request_idTEXTCORRELATION — join key across streams
timestamp / monotonic_seqTEXT / INTWHEN anchor
detailJSONAdopter-owned free blob; secrets redacted

fasten creates its audit table via idempotent CREATE TABLE IF NOT EXISTS on init(). Your product creates audit_replay via its own migrations. They must never share a table — use a dedicated DB, a separate schema, or a fasten_ prefix so names cannot collide.

Catalog YAML — Single Source Across SDKs

Code catalogs can be declared in *.codes.yaml files instead of programmatically. Same file works across every SDK; reload is atomic + fault-tolerant (parse / validate fully into a fresh dict, then swap under a lock; on any failure the previous catalog stays active).

fleet.codes.yaml
domain: fleet emitter: edge-manager codes: FLEET_NODE_REGISTERED: category: node.lifecycle action: registered severity: info description: Edge node claimed and registered retention_class: long FLEET_TELEMETRY_DROPPED: category: telemetry action: dropped severity: warn description: Telemetry batch dropped due to ingest backpressure
python
import fasten fasten.codes.load("fleet.codes.yaml") fasten.codes.reload() # atomic + fault-tolerant; on SIGHUP, etc.
go
fasten.MustLoad("fleet.codes.yaml") _ = fasten.Reload() // returns error; previous catalog kept on failure
javascript
import { codes } from '@nerdapplabs/fasten'; await codes.load('fleet.codes.yaml'); await codes.reload();
rust (feature: codes-yaml)
use fasten::codes_yaml; codes_yaml::load("fleet.codes.yaml")?; codes_yaml::reload()?;
c++ (opt-in: fasten/codes_yaml.hpp)
#include "fasten/codes_yaml.hpp" fasten::codes::load("fleet.codes.yaml"); fasten::codes::reload();

Reload is not additive. Codes removed from the file become unknown after a reload — already-stored audit rows stay readable (the wire code field is a free string). Programmatic register() calls survive reload (tracked separately from yaml-loaded codes). Python ships a typegen CLI: fasten codes typegen fleet.codes.yaml > codes_stub.py emits IDE stubs.

Audit-Store Failure Handling

Audit failures must never break the request being audited. emit() defaults to queue mode: rows are pushed onto a bounded in-memory queue, drained by a background thread with exponential backoff (100 ms → 60 s, ±20 % jitter). A locked / down store no longer cascades into 5xxs on the request path.

StrategyBehaviorWhen to use
queue (default)emit() returns immediately; drainer retries foreverProduction. Default for v1.0 GA.
raiseSynchronous insert; raises AuditStoreError on failureTests, dev mode, adopters wanting loud failures during config debugging.
python
fasten.init( service_id="auth-service", node_id="host-01", audit_store_failure_strategy="queue", # default queue_capacity=100, # queued + in-flight retry queue_retry_initial_ms=100, queue_retry_max_ms=60_000, queue_retry_jitter=True, ) # Or via env: FASTEN_AUDIT_STORE_FAILURE_STRATEGY=queue|raise
go
fasten.Init(fasten.Config{ ServiceID: "auth-service", NodeID: "host-01", AuditStore: store, AuditStoreFailureStrategy: "queue", QueueCapacity: 100, QueueRetryInitial: 100 * time.Millisecond, QueueRetryMax: 60 * time.Second, })
rust
fasten::init(fasten::Config { service_id: "auth-service".into(), node_id: "host-01".into(), audit_store: Some(store), audit_store_failure_strategy: Some("queue".into()), queue_capacity: Some(100), ..Default::default() })?; EmitBuilder::new("USER_CREATED", "u-42").submit()?;
c++
fasten::Config cfg; cfg.service_id = "auth-service"; cfg.node_id = "host-01"; cfg.audit_store_failure_strategy = "queue"; cfg.queue_capacity = 100; fasten::set_audit_sink([](const fasten::Row& r) { /* persist */ }); fasten::init(cfg); // drainer thread spawned here

Sys-Stream Self-Report

The drainer never silently degrades. State transitions write {shape:"sys"} NDJSON lines so existing log aggregation (Loki, Splunk, journald, or whichever hosted log indexer you run) catches audit-pipeline issues without any new alerting plumbing. Adopter request_id propagates onto every line.

EventLevelWhen
audit_drain_failed warn First failure after a successful insert
audit_drain_degraded error 5+ consecutive failures
audit_drain_recovered info Insert succeeds after a failure burst
audit_queue_high_water warn Used capacity ≥ 50 %
audit_queue_near_full error Used capacity ≥ 80 %

Programmatic Snapshot — queue_stats()

python
fasten.queue_stats() # { # "depth": 0, # queued + in-flight retry # "capacity": 100, # "high_water": 12, # max depth seen since process start # "drained_total": 12_456, # "retry_count_active": 0, # "in_backoff_seconds": 0.0, # "last_error": None, # } # Returns None in raise mode (no drainer running).

Equivalents: Go fasten.GetQueueStats() (returns *QueueStats); JS queueStats(); Rust fasten::queue_stats(); C++ fasten::queue_stats(). All return null / nil / None in raise mode.

Deterministic Shutdown — flush()

For k8s preStop hooks, CLI exit paths, or test teardown — block until pending rows drain. No-op + true in raise mode so adopter shutdown code is mode-agnostic across both strategies.

python
fasten.flush(timeout=5.0) # True iff fully drained
go
fasten.Flush(5 * time.Second)
javascript
await flush(5000)
rust
fasten::flush(std::time::Duration::from_secs(5));
c++
fasten::flush(std::chrono::seconds(5));

Cross-language deviation: JS. Node is single-threaded — emit() can't synchronously block on a counting semaphore the way Python / Go / Rust / C++ do. queueCapacity is therefore the high-water-warn threshold in JS, not a hard cap. The drainer + retry-forever-with-backoff matches the others exactly. queueStats(), flush(), and the audit_drain_* events all behave identically. Swift uses a Thread-based in-process drainer with blocking enqueue() like Python / Go.

Security Model

fasten is responsible for the row's integrity from emit to insert, and for any surface fasten itself exposes — but never for the security of storage you brought.

Responsibility Split

ZoneOwner
At write
redaction · schema · registry integrity · SQL safety
fasten
At rest
file perms · encryption · backup
You. Perimeter is your DB's existing security — RBAC, KMS, network isolation.
At read
authn · authz · CORS · rate limit
You. Mount the reader behind your existing auth (FastAPI Depends, gateway, session middleware).
Distribution
signed releases · SBOM · CVE disclosure · vuln scanning
fasten

Wire Your Existing Auth

The reader is a FastAPI router. Mount it like any other route, behind whatever auth your app already runs.

python
from fastapi import Depends from your_app.auth import require_audit_read # your existing dependency app.include_router( fasten.reader.router(), prefix="/api/v1/logs", dependencies=[Depends(require_audit_read)], # ← your check, your rules )

fasten's reader doesn't add its own check here. Two auth layers fight each other; one well-placed layer is the point.

Reserved Headers

HeaderPurpose
X-Fasten-Request-IdCorrelation id bundled

What Stays Out of Scope

  • TLS termination — your reverse proxy / load balancer.
  • Network ACLs — your VPC / firewall / k8s NetworkPolicy.
  • Key distribution — your secret manager (Vault, k8s Secret, AWS SSM).
  • DB encryption at rest — your DB layer (e.g. Postgres TDE) or OS-level on the SQLite file.

API Mounting

fasten's reader is a mountable router — not a standalone service. Wire it in like any sub-router under whatever prefix you choose.

Python (FastAPI)

python
import fasten from fastapi import FastAPI from fasten.shim.http import RequestIDMiddleware, APILogger fasten.init() # reads FASTEN_AUDIT_DSN etc. app = FastAPI() app.add_middleware(RequestIDMiddleware) # mints / honours X-Request-ID app.add_middleware(APILogger, skip={"/health", "/metrics"}) # one api row per request app.include_router(fasten.reader.router(), # auto-uses fasten.init() globals prefix="/api/v1/logs") # GET /api/v1/logs/sys?level=warn&limit=50 # GET /api/v1/logs/api?path=/users # GET /api/v1/logs/audit?actor=admin&target=u-42&offset=0&limit=20

Go (chi)

go
import ( fasten "github.com/nerdapplabs/fasten/go" httpshim "github.com/nerdapplabs/fasten/go/shim/http" ) fasten.Init(fasten.Config{}) r := chi.NewRouter() r.Use(httpshim.RequestID) r.Mount("/api/v1/logs", fasten.NewReader()) // GET /api/v1/logs/sys | /api | /audit

Node.js (built-in http or Express)

JS doesn't ship a mountable router today; wire X-Request-ID via withRequestID() in your handler, and surface the audit / sys / api streams over your existing HTTP framework. See the js/examples service for the pattern.

javascript
import http from 'node:http'; import fasten, { withRequestID, mintID } from '@nerdapplabs/fasten'; http.createServer((req, res) => { const rid = req.headers['x-request-id'] ?? mintID(); res.setHeader('x-request-id', rid); withRequestID(rid, async () => { fasten.emit({ code: 'USER_CREATED', target: 'u-42', actor: 'admin' }); res.end('{"ok":true}'); }); }).listen(8080);

Rust (tiny_http or axum)

The Rust SDK is sync-by-default. Wrap each request in with_request_id() and call EmitBuilder::submit() inside.

rust
use fasten::{with_request_id, mint_id, EmitBuilder}; // for each request: let rid = headers.get("x-request-id").map(String::from).unwrap_or_else(mint_id); with_request_id(rid, || { EmitBuilder::new("USER_CREATED", "u-42").actor("admin", "user").submit() });

C++14 (single-header)

Use the bundled FastenReader + reader_simplehttp_main.cpp wiring; or call into your own HTTP server.

c++
#include "fasten.hpp" // per request: fasten::RequestScope scope(rid); // RAII; restores prev id on exit fasten::emit("USER_CREATED", fasten::target("u-42"), fasten::actor("admin", "user"));

Swift (SPM)

SQLite-backed store out of the box; uses system sqlite3 and swift-crypto. Async request_id via @TaskLocal.

swift
import Fasten // 1. Register codes once at app start Fasten.register("user", codes: [ "USER_CREATED": Meta(id: "USER_CREATED", domain: "user", category: "account", action: "create", description: "New user", emitter: "auth-svc"), ]) // 2. Configure let store = try SQLiteStore(path: "./fasten-audit.db") try Fasten.configure(serviceID: "auth-svc", nodeID: "host-01", store: store, strategy: .queue) // 3. Emit — non-blocking in .queue mode try Fasten.emit("USER_CREATED", target: "u-42", actor: "admin", detail: ["email": "alice@example.com"]) Fasten.log.info("signup_complete", fields: ["user_id": "u-42"]) // 4. Propagate request_id (async) await Fasten.withRequestID(Fasten.mintID()) { try? await Fasten.emit("USER_CREATED", target: "u-42", actor: "admin") } // 5. Shutdown (k8s preStop / test teardown) Fasten.flush(timeout: 5.0)

Mounted Reader Endpoints — at a Glance

PathPurpose
GET /auditQuery audit rows (filters: actor, target, since, until, code, request_id, domain, source_node_id, offset, limit). Response: {rows, total, limit, offset}.
GET /sysQuery syslog ring (filters: level, request_id, service_id, limit).
GET /apiQuery API-log ring (filters: method, path, request_id, limit).
GET /audit/doctorAudit-pipeline health snapshot — store reachability + row count, queue stats, transport ring depths, redactor state, current init parameters, hash-chain integrity. Same auth as /audit via the router's dependencies=. One curl for compliance auditors / k8s liveness probes / status pages.
/audit/doctor — example response
{ "store": {"kind": "SQLiteStore", "reachable": true, "rows": 12456, "last_error": null}, "queue": {"depth": 0, "capacity": 100, "high_water": 12, "drained_total": 12456, "retry_count_active": 0, "in_backoff_seconds": 0.0, "last_error": null}, "transport": {"stdout_active": true, "syslog_ring_depth": 247, "api_ring_depth": 0}, "redactor": {"active": true}, "init": {"service_id": "auth-service", "node_id": "host-01", "tenant_id": null, "failure_strategy": "queue"} }

Adopter Middleware Accessors

For adopters writing custom middleware or logging layers that need the same primitives emit() uses internally. Python and Go expose these as accessor functions; JS, Swift, and C++ expose equivalent standalone functions.

AccessorPythonGoReturns
Transportfasten.transport() fasten.GetTransport() Active StdoutTransport — push custom api / sys rows into the ring + stdout
Redactorfasten.redactor() fasten.RedactDetail(m) Active Redactor — applies key-pattern + value-shape redaction; use in custom logger processors. JS: coreRedact(json). C++: fasten_redact() C ABI.
Audit storefasten.audit_store()(via Config.AuditStore)Active AuditRepository — for custom readers, replication, or outbox layers

Env-Var Reference

No fasten.yaml. No config daemon. Env-vars only.

VariableDefaultPurpose
FASTEN_SERVICE_IDrequiredWHERE — service identity
FASTEN_NODE_IDrequiredWHERE — host/node identity
FASTEN_TENANT_IDunsetWHERE — tenant / org / site (optional)
FASTEN_AUDIT_DSNrequiredAudit store (sqlite:// or postgres://) — fasten refuses to start without it
FASTEN_API_DSNring onlyOpt-in API-log SQL persistence
FASTEN_LEVELinfoSyslog threshold
FASTEN_REDACT_KEYSsensible defaultExtra redaction patterns (comma-sep)
FASTEN_AUDIT_STORE_FAILURE_STRATEGYqueueP1-15 — queue (async drainer, retry forever) or raise (sync; raises AuditStoreError)
FASTEN_SYS_RING_SIZE10000Syslog ring size (lines)
FASTEN_API_RING_SIZE10000API-log ring size (lines)
FASTEN_AUDIT_RETENTION_CLASS_SHORT30Days for short-class codes
FASTEN_AUDIT_RETENTION_CLASS_MEDIUM180Days for medium-class codes
FASTEN_AUDIT_RETENTION_CLASS_LONG1095 (3 yr)Days for long-class codes
FASTEN_RETENTION_SWEEP_INTERVAL6hTTL sweep cadence

Retention + PII

ClassDefault TTLExample codes
short 30 days High-volume operational codes
medium 180 days CONNECTOR_STARTED, SCHEDULE_TRIGGERED
long 3 years USER_CREATED, CONFIG_UPDATED, AUTH_LOGIN

PII — Three Mechanisms

1. Redaction at emit time — two passes, applied before writing anywhere:

Key-pattern pass — substring match, case-insensitive: api_key, password, passwd, token, secret, authorization, bearer, m2m_key, cert_private, private_key, access_key, session_id, cookie, credential. Any key containing one of these patterns (e.g. customer_token, user_password) has its value replaced with ***. Extend with FASTEN_REDACT_KEYS=ssn,dob or extra_redact_keys= in init().

Value-shape pass — string values that look like known secret formats are replaced with a type-hinting token regardless of key name: JWT → ***JWT***, PEM private key → ***PRIVATE_KEY***, AWS access key (AKIA/ASIA) → ***AWS_KEY***, GitHub token (ghp_/ghs_/…) → ***GH_TOKEN***, Stripe live key → ***STRIPE_KEY***, OpenAI key → ***OPENAI_KEY***, credit-card numbers passing Luhn check → ***CC***. Key-pattern fires first — if the key matches, the value is never inspected for shape.

python
fasten.emit("USER_CREATED", target="u-42", detail={"email": "alice@acme.com", "api_key": "sk-abc123"}) # stored: {"email": "alice@acme.com", "api_key": "***"} ← key-pattern match fasten.emit("TOKEN_REFRESH", target="u-42", detail={"note": "refreshed eyJhbGci....eyJzdWIi....sig"}) # stored: {"note": "***JWT***"} ← value-shape match (neutral key) fasten.init(..., extra_redact_keys=["ssn", "credit_card", "dob"])

2. PII class flagpii_in_detail=True does two things: forces retention to short (30 days) regardless of declared class, and replaces the entire detail dict with {"_redacted":"***","_pii_in_detail":true} at emit time. Individual fields can be preserved via detail_passthrough_keys:

python
Meta(..., pii_in_detail=True) # stored detail: {"_redacted": "***", "_pii_in_detail": true} Meta(..., pii_in_detail=True, detail_passthrough_keys=["region", "severity_level"]) # stored detail: {"_redacted": "***", "_pii_in_detail": true, "region": "EU", "severity_level": "high"}

3. TTL sweep — rows beyond their class are deleted on schedule. For GDPR right-to-erasure: delete rows by actor or target, then let TTL sweep clean up the rest.

Operational FAQ

What Is the Latency Overhead?

Queue mode (default): emit() returns in <0.1 ms — the background drainer handles the store write asynchronously. Raise mode: ~0.1–0.5 ms per emit() with SQLite WAL, ~1–5 ms with Postgres. fasten.log.* is ring + stdout only — sub-millisecond in either mode. High-volume codes (>100/sec): set high_volume=True in Meta to skip the SQL insert.

What Is the Memory Footprint?

~8 MB at full occupancy: syslog ring (10k × ~500 B) + API log ring (10k × ~300 B). Tune with FASTEN_SYS_RING_SIZE and FASTEN_API_RING_SIZE.

Does SQLite Contend Under Concurrent Writes?

WAL mode allows concurrent readers + one writer. Typical audit volumes are well below contention. Mark hot codes high_volume=True or switch to Postgres if you see it.

Does fasten Add a Thread or Process?

One drainer thread per process when audit-store failure handling runs in queue mode (the default). Python, Go, and C++ bind to the shared fasten-core C ABI drainer (a single Rust std::thread inside the shared library); Rust runs it natively. JS uses a setImmediate chain (single-threaded event loop, no native deps). Swift uses a Thread-based pure Swift in-process drainer. Set audit_store_failure_strategy="raise" if you want strict synchronous semantics with no background drainer.

What Happens if the DB Is Unavailable?

  • The row is written to stdout regardless — Docker / journald captures it.
  • Queue mode (default): the row is buffered in-memory; the drainer retries with exponential backoff (100 ms → 60 s, ±20 % jitter). emit() never raises on store failure. The drainer self-reports state transitions to the sys stream (audit_drain_failed, audit_drain_degraded, audit_drain_recovered) so existing log aggregation catches issues.
  • Capacity covers queued + in-flight retry combined (default queue_capacity=100). When saturated, emit() blocks rather than silently drops. JS deviates here — single-threaded event loop means emit() stays sync; capacity is the high-water-warn threshold instead.
  • Raise mode: emit() calls insert synchronously and raises fasten.AuditStoreError on failure. Adopter chooses how to react.
  • Use fasten.queue_stats() for a programmatic snapshot, or GET /logs/audit/doctor for the same data over HTTP.

Does fasten Work in Air-Gapped Environments?

Yes. Stdout is the primary transport — no network required. SQLite works fully offline. Rows drain upstream when connectivity resumes.

Deployment Recipes

fasten is in-process, no sidecar. The only orchestration concerns are: pass env vars, mount a writable volume for the SQLite file (or wire Postgres), and call fasten.flush() from your shutdown / preStop hook so the queue drains before the container dies.

Docker Compose

docker-compose.yml
services: app: image: my-app:latest environment: FASTEN_SERVICE_ID: my-app FASTEN_NODE_ID: ${HOSTNAME} FASTEN_AUDIT_DSN: sqlite:///data/fasten-audit.db # Or postgres: # FASTEN_AUDIT_DSN: postgres://user:pw@db:5432/audit # Override the queue-mode default if you want sync semantics: # FASTEN_AUDIT_STORE_FAILURE_STRATEGY: raise volumes: - audit-data:/data stop_grace_period: 10s # lets the drainer flush before SIGKILL volumes: audit-data:

Kubernetes

deployment.yaml
apiVersion: apps/v1 kind: Deployment metadata: { name: my-app } spec: template: spec: terminationGracePeriodSeconds: 10 containers: - name: app image: my-app:latest env: - name: FASTEN_SERVICE_ID, value: my-app - name: FASTEN_NODE_ID valueFrom: { fieldRef: { fieldPath: spec.nodeName } } - name: FASTEN_AUDIT_DSN, value: sqlite:///data/fasten-audit.db lifecycle: preStop: # Block on flush() before SIGTERM proceeds — adopter-side hook exec: { command: ["curl", "-fsS", "http://localhost:8080/internal/flush"] } livenessProbe: # Hits /audit/doctor — fails the pod if the audit pipeline degrades httpGet: { path: /api/v1/logs/audit/doctor, port: 8080 } periodSeconds: 30 volumeMounts: [{ name: audit-data, mountPath: /data }] volumes: [{ name: audit-data, persistentVolumeClaim: { claimName: audit-pvc } }]

systemd

/etc/systemd/system/my-app.service
[Service] Environment=FASTEN_SERVICE_ID=my-app Environment=FASTEN_NODE_ID=%H Environment=FASTEN_AUDIT_DSN=sqlite:///var/lib/my-app/fasten-audit.db ExecStart=/usr/local/bin/my-app TimeoutStopSec=10s KillMode=mixed # SIGTERM main, SIGKILL group on timeout [Install] WantedBy=multi-user.target

Observability — Wiring Drainer Events to Your Stack

P1-15 ships five sys-stream events covering the audit pipeline. They land on your existing log channel (the same stdout NDJSON your aggregator already scrapes) — no separate metrics endpoint required. Wire them to alerts in whatever you already use.

Loki / Grafana

Alert when the audit drainer degrades:

LogQL alert rule
sum by (service_id) ( count_over_time({app="my-app"} | json | shape="sys" | event="audit_drain_degraded" [5m]) ) > 0

Hosted log monitor (vendor-neutral)

Most hosted log indexers expose the same idea — search by the JSON fields, roll up over a window, alert above a threshold. The query syntax below uses one common dialect; translate to your provider's selector + roll-up grammar as needed.

Log monitor query
logs("@shape:sys @event:audit_drain_degraded service:my-app").rollup("count").last("5m") > 0

Prometheus (via vector / fluent-bit log → metric)

Convert the sys events to counters in your log pipeline; fasten doesn't expose a Prometheus endpoint directly (stays in-process, zero deps).

vector.toml — count drainer events as a metric
[sources.app] type = "stdin" # or file / docker_logs [transforms.parse] type = "remap" inputs = ["app"] source = '. = parse_json!(.message)' [transforms.audit_events] type = "filter" inputs = ["parse"] condition = '.shape == "sys" && starts_with!(.event, "audit_")' [sinks.prom] type = "prometheus_exporter" inputs = ["audit_events"] address = "0.0.0.0:9090"

k8s Liveness Probe

The reader's GET /audit/doctor endpoint is k8s-friendly out of the box — JSON response with store.reachable and queue.retry_count_active. Wire it as a liveness probe so the pod restarts if the audit pipeline stays degraded.

Compliance Auditor Cheatsheet

EventAuditor question it answers
audit_drain_failed + audit_drain_recovered pair"Was there an outage? How long?" — paired timestamps give exact duration.
audit_drain_degraded alone (no recovery)"Are audit rows currently making it to the store?" — page someone.
audit_queue_near_full"Is the audit pipeline saturated?" — capacity / throughput sizing review.
queue_stats().drained_total"How many audit rows were processed in this window?"

Logging — Structured Sys Stream

fasten's four log functions write {"shape":"sys"} NDJSON lines to stdout and push them into the in-memory syslog ring (queryable via GET /logs/sys). They are not audit rows — no code catalog entry needed, no durable storage, no 5 Ws enforcement. Use them for operational diagnostics alongside emit() for compliance events.

Python (fasten.log.*)GoJS (fasten.log.*)C++ (fasten::log::*)
fasten.log.info(event, **fields) fasten.LogInfo(ctx, event, kv...) fasten.log.info(event, fields) fasten::log::info(event, fields)
fasten.log.warn(event, **fields) fasten.LogWarn(ctx, event, kv...) fasten.log.warn(event, fields) fasten::log::warn(event, fields)
fasten.log.error(event, **fields) fasten.LogError(ctx, event, kv...) fasten.log.error(event, fields) fasten::log::error(event, fields)
fasten.log.debug(event, **fields) fasten.LogDebug(ctx, event, kv...) fasten.log.debug(event, fields) fasten::log::debug(event, fields)

All four auto-stamp request_id from the ambient context, service_id from init(), and timestamp. Any extra keyword arguments (Python), key-value pairs (Go), field object (JS), or Fields map (C++) are merged into the output line.

Python

python
import fasten fasten.log.info("signup_complete", user_id="u-42", method="email") fasten.log.warn("rate_limit_approaching", threshold=80, current=74) fasten.log.error("payment_gateway_timeout", gateway="stripe", retry=3) fasten.log.debug("cache_miss", key="session:abc123") # Per-module logger with bound fields — all emitted with logger="buffer-mgr" log = fasten.log.bound("buffer-mgr", subsystem="ingestion") log.info("batch_flushed", count=512)
stdout — shape: sys
{"shape":"sys","level":"info","event":"signup_complete","request_id":"d4e5f6a1b2c3","service_id":"auth-service","timestamp":"2026-04-24T10:23:45.124Z","user_id":"u-42","method":"email"}

Go

Key-value pairs follow slog-style: alternating string key + any value. ctx carries the request_id via fasten.WithRequestID(ctx, rid).

go
fasten.LogInfo(ctx, "signup_complete", "user_id", "u-42", "method", "email") fasten.LogWarn(ctx, "rate_limit_approaching", "threshold", 80, "current", 74) fasten.LogError(ctx, "payment_gateway_timeout", "gateway", "stripe", "retry", 3) fasten.LogDebug(ctx, "cache_miss", "key", "session:abc123")

JavaScript / TypeScript

fields is a plain object. request_id comes from the active AsyncLocalStorage store set by withRequestID().

javascript
import fasten from '@nerdapplabs/fasten'; fasten.log.info("signup_complete", { user_id: "u-42", method: "email" }); fasten.log.warn("rate_limit_approaching", { threshold: 80, current: 74 }); fasten.log.error("payment_gateway_timeout", { gateway: "stripe", retry: 3 }); fasten.log.debug("cache_miss", { key: "session:abc123" });

C++14

fasten::Fields is std::vector<std::pair<std::string,std::string>>. Request id comes from the active RequestScope.

c++
#include "fasten.hpp" fasten::log::info("signup_complete", {{"user_id", "u-42"}, {"method", "email"}}); fasten::log::warn("rate_limit_approaching", {{"threshold", "80"}, {"current", "74"}}); fasten::log::error("payment_gateway_timeout", {{"gateway", "stripe"}, {"retry", "3"}}); fasten::log::debug("cache_miss", {{"key", "session:abc123"}});

Logger Shims — Use Your Existing Logging Library

If you already have a logging framework in the codebase, opt-in shims mirror every log call into fasten's syslog ring without changing any existing call sites.

LanguageShimWhat it does
Go fasten.NewSlogHandler(base) Wraps any slog.Handler. Install once with slog.SetDefault(slog.New(fasten.NewSlogHandler(base))) — existing slog.Info/Warn/Error/Debug calls are mirrored to the fasten ring automatically. The underlying handler still writes to its own destination; no double-write occurs.
Python fasten.shim.structlog.make_fasten_processor()
fasten.shim.structlog.configure()
Pushes every structlog event into fasten's syslog ring. configure() is the one-call opinionated setup: installs the fasten processor, redaction processor, JSON or console renderer, and a stdlib bridge so import logging calls reach the same destination.
Python (stdlib) fasten.shim.stdlib Bridges Python's built-in logging module into the fasten ring. Use when structlog is not in the stack.
C++ fasten/shim/spdlog.hpp Add fasten::shim::spdlog_sink_mt to your spdlog logger's sink list. Every spdlog::info/warn/… call is mirrored to the fasten ring. Thread-safe; recursion-guarded.
C++ fasten/shim/glog.hpp Install fasten::shim::GlogSink as a glog sink. All LOG(INFO), LOG(WARNING), LOG(ERROR) calls flow into the fasten ring.
C++ fasten/shim/boost_log.hpp A Boost.Log sink backend — attach to the logging core to mirror Boost.Log output to fasten.
go — slog shim
import "log/slog" base := slog.NewJSONHandler(os.Stdout, nil) slog.SetDefault(slog.New(fasten.NewSlogHandler(base))) // Existing code unchanged — now also writes to fasten syslog ring: slog.InfoContext(ctx, "signup_complete", "user_id", "u-42")
python — structlog shim
from fasten.shim.structlog import configure configure() # installs fasten processor + JSON renderer + stdlib bridge import structlog log = structlog.get_logger() log.info("signup_complete", user_id="u-42") # → fasten ring + stdout
c++ — spdlog shim
#include "fasten.hpp" #include "fasten/shim/spdlog.hpp" #include <spdlog/spdlog.h> // After fasten::init(): spdlog::default_logger()->sinks().push_back( std::make_shared<fasten::shim::spdlog_sink_mt>() ); // Existing code unchanged — now also writes to fasten ring: spdlog::info("user_login user_id={}", uid);

Shims are side-effect-only: they push into the ring and return the event dict unchanged. Your existing log pipeline (stdout, file, remote sink) is not replaced — fasten is an additive layer.

Public Accessors — flush & queue_stats

Two functions expose the internal queue state from outside the audit pipeline. Use them in shutdown hooks, health probes, and tests.

flush(timeout) — Deterministic Shutdown

Blocks until every queued audit row has been written to the store, or until the timeout expires. Returns True / true if fully drained; False / false if timed out with rows still pending. In raise mode (no drainer) it is a no-op that returns True immediately — shutdown code is therefore mode-agnostic.

LanguageSignatureUnit
Pythonfasten.flush(timeout: float = 5.0) → boolseconds
Gofasten.Flush(timeout time.Duration) boolany time.Duration
JSawait flush(timeoutMs: number = 5000) → booleanmilliseconds
Rustfasten::flush(timeout: std::time::Duration) → boolany Duration
C++fasten::flush(timeout: std::chrono::duration) → boolany chrono::duration
python — k8s preStop / atexit
import atexit, fasten fasten.init() def _shutdown(): ok = fasten.flush(timeout=5.0) if not ok: fasten.log.warn("flush_timeout", msg="Some rows may not have drained") atexit.register(_shutdown)
go — graceful shutdown
sigC := make(chan os.Signal, 1) signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT) <-sigC ok := fasten.Flush(5 * time.Second) if !ok { log.Println("fasten: flush timeout — some rows may be pending") }
javascript — process exit
import fasten, { flush } from '@nerdapplabs/fasten'; process.on('SIGTERM', async () => { const ok = await flush(5000); if (!ok) console.warn('fasten: flush timeout'); process.exit(0); });

queue_stats() — Runtime Health Snapshot

Returns a snapshot of the drainer's current state. Returns None / nil / null in raise mode (no drainer running). Use it in health probes, status pages, or to feed your own metrics pipeline without mounting the full reader.

LanguageSignatureReturn type
Pythonfasten.queue_stats() → dict | Nonedict or None in raise mode
Gofasten.GetQueueStats() *QueueStats*QueueStats or nil in raise mode
JSfasten.queueStats() → object | nullobject or null in raise mode
Rustfasten::queue_stats() → Option<QueueStats>Some(stats) or None
C++fasten::queue_stats() → std::optional<QueueStats>std::nullopt in raise mode
python — health probe
stats = fasten.queue_stats() # None → raise mode, no drainer # dict → queue mode; fields: # { # "depth": 0, # rows currently queued + in-flight retry # "capacity": 100, # max depth (queue_capacity from init()) # "high_water": 12, # highest depth seen since process start # "drained_total": 12456, # total rows successfully persisted # "retry_count_active": 0, # consecutive failures since last success # "in_backoff_seconds": 0.0,# seconds until next retry attempt # "last_error": None, # last store error message, or None # }
go — health check handler
func healthHandler(w http.ResponseWriter, r *http.Request) { s := fasten.GetQueueStats() if s == nil { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{"ok": true, "mode": "raise"}) return } ok := s.RetryCountActive < 5 && s.Depth < s.Capacity status := http.StatusOK if !ok { status = http.StatusServiceUnavailable } w.WriteHeader(status) json.NewEncoder(w).Encode(s) }
javascript
import { queueStats } from '@nerdapplabs/fasten'; const stats = queueStats(); // null → raise mode // { depth, capacity, highWater, drainedTotal, // retryCountActive, inBackoffSeconds, lastError } if (stats && stats.retryCountActive > 5) { console.error('fasten audit pipeline degraded', stats); }

When to use which: Use flush() at shutdown and in test teardown to guarantee all rows land before the process exits. Use queue_stats() in liveness probes and status pages — retry_count_active > 0 means the store is temporarily unreachable; depth >= capacity means backpressure is building. Both are also available over HTTP as GET /logs/audit/doctor without any in-process code.

Reader Endpoint Reference

All three endpoints are served by the mountable fasten.reader.router(). Mount it under a prefix (e.g. /api/v1/logs) — the paths below are relative to that prefix. No built-in auth; always pass dependencies=[Depends(...)] or mount behind a gateway.

GET /audit

Query durable audit rows from the fasten_audit SQL table. Supports pagination. All parameters are optional and combinable.

ParameterTypeDescription
request_idstringFilter by correlation id — returns all rows from a single logical request across any service that carried it.
codestringExact audit code, e.g. USER_CREATED.
domainstringAdopter-defined domain, e.g. user, billing.
actorstringWHO — the actor id that performed the action.
targetstringWHOM — the resource acted on.
source_node_idstringWHERE — the node/host that emitted the row.
tenant_idstringWHERE — multi-tenant isolation filter.
sinceISO 8601 datetimeLower bound on timestamp (inclusive).
untilISO 8601 datetimeUpper bound on timestamp (inclusive).
limitint (default 100, max 1000)Page size.
offsetint (default 0, min 0)Pagination offset — number of rows to skip.

Response shape:

GET /audit — response
{ "rows": [ { ...audit row... }, ... ], // array of AuditRow dicts "total": 1247, // total matching rows (for pagination) "limit": 100, "offset": 0 }

GET /sys

Query the in-memory syslog ring (default 10 000 lines, configurable via FASTEN_SYS_RING_SIZE). Ring-only — no offset, no total. Rows are gone when the ring wraps.

ParameterTypeDescription
levelstringFilter by log level: debug · info · warn · error.
request_idstringFilter to sys lines from one logical request.
service_idstringFilter by originating service.
limitint (default 100, max 1000)Number of most-recent lines to return.

Response shape:

GET /sys — response
{ "rows": [ { "shape": "sys", "level": "info", "event": "signup_complete", "request_id": "d4e5f6a1b2c3", "service_id": "auth-service", "timestamp": "2026-04-24T10:23:45.124Z", "user_id": "u-42" }, ... ] }

GET /audit/doctor

Single-call audit-pipeline health snapshot. Same auth as /audit (applied at router level). Use as a k8s liveness probe, compliance auditor curl, or status-page data source.

FieldTypeMeaning
store.kindstringClass name of the active AuditRepository, e.g. SQLiteStore.
store.reachablebooltrue if the store responded to a count() call without error.
store.rowsint | nullTotal audit rows currently in the store. null if the store doesn't implement count().
store.last_insert_atISO datetime | nullTimestamp of most recent successful insert.
store.last_errorstring | nullMost recent store error, or null.
queueobject | nullFull queue_stats() snapshot — null in raise mode. Fields: depth, capacity, high_water, drained_total, retry_count_active, in_backoff_seconds, last_error.
transport.stdout_activebooltrue if the stdout transport is initialised.
transport.syslog_ring_depthintCurrent number of lines in the syslog ring.
transport.api_ring_depthintCurrent number of lines in the API-log ring.
redactor.activebooltrue if a redactor is configured.
init.service_idstring | nullValue passed to (or read by) init().
init.node_idstring | nullValue passed to (or read by) init().
init.tenant_idstring | nullValue passed to (or read by) init(), if any.
init.failure_strategystring"queue" or "raise".
init.worker_pidintOS process id — identifies the worker under multi-worker servers (e.g. uvicorn --workers N).
chain.verifiedbool | nullHash-chain integrity result for the most recent 50 rows from this node. null if no rows or chain verification could not run.
chain.breaksint0 if intact; 1 if a tampered or missing row was detected.
chain.last_verified_atISO datetime | nullWhen the chain check ran.
GET /audit/doctor — full response example
{ "store": { "kind": "SQLiteStore", "reachable": true, "rows": 12456, "last_insert_at": null, "last_error": null }, "queue": { "depth": 0, "capacity": 100, "high_water": 12, "drained_total": 12456, "retry_count_active": 0, "in_backoff_seconds": 0.0, "last_error": null }, "transport": { "stdout_active": true, "syslog_ring_depth": 247, "api_ring_depth": 0 }, "redactor": { "active": true }, "init": { "service_id": "auth-service", "node_id": "host-01", "tenant_id": null, "failure_strategy": "queue", "worker_pid": 12345 }, "chain": { "verified": true, "breaks": 0, "last_verified_at": "2026-04-24T10:23:45.124+00:00" } }

k8s liveness probe pattern: parse store.reachable (store up?) and queue.retry_count_active (drainer healthy?). The endpoint itself always returns HTTP 200 — use the field values to drive your probe logic, not the status code.

Glossary

TermMeans
anchorOne of the 7 mandatory fields a typed audit row must carry: WHO, WHAT, WHEN, WHERE, WHOM, HOW, CORRELATION. Enforced at the type level — emit refuses to produce a row missing any of these.
codeThe string identifier of an audit event — e.g. USER_CREATED, CONFIG_UPDATED. Adopter-defined, registered once at startup with register(). Maps 1:1 to a Meta describing severity, retention, PII flag.
domainAn adopter-defined namespace grouping related codes. Plain string — fasten has no opinions. Examples: user, billing, fleet, config.
categoryWithin a domain, the sub-group. Adopter-defined. Examples (user domain): account, profile, session.
actor / actor_kindWHO did the action. actor is a string identity (user id, service name, agent name); actor_kind is one of user · service · schedule · agent.
targetWHOM the action acted on — the resource id, never PII. Free string.
methodHOW the action was triggered. One of http · mqtt · cli · scheduler · ui · agent_tool · sdk.
request_idCorrelation key threading all three streams for one logical request. Mintable via mint_id(); honored from X-Request-ID by the HTTP shim.
streamOne of the three NDJSON channels fasten writes to stdout: audit (typed rows, durable), sys (structured logs, ring buffer), api (HTTP access trail, ring buffer or opt-in SQL).
shimAn opt-in module that propagates request_id across a transport — shim.http, shim.mqtt, shim.scheduler. Read the wire id, stash in context, propagate downstream.
retention classOne of short (30d) · medium (180d, default) · long (1095d). Per-code; pii_in_detail=True codes are forced to short.
queue mode vs raise modeP1-15 store-failure strategies. queue (default) — async drainer, never blocks emit() on store errors. raise — sync; throws AuditStoreError.

Code Evolution + Compatibility

Renaming a Code

python
# v1 register("user", {"USR_CREATED": Meta(...)}) # v2 — rename; keep old emittable for one release register("user", { "USER_CREATED": Meta(...), "USR_CREATED": Meta(..., declared_unused=True), # old rows still readable })

Schema Contract

The 7 anchor columns are stable — never change type. detail is yours to evolve.

  • Identity: id, origin_id, monotonic_seq, timestamp
  • WHAT: code, action, severity
  • WHERE: service_id, source_node_id, tenant_id
  • WHO: actor, actor_kind
  • WHOM: target, category, domain
  • HOW: method
  • CORRELATION: request_id
  • Free JSON: detail — you own its schema

OTel / trace_id Bridge

Pass your OpenTelemetry trace_id as fasten's request_id — they become the same id. A future transport/otlp plugin will export audit rows as OpenTelemetry LogRecord entries, letting existing Grafana/Jaeger stacks query fasten rows.

python
from opentelemetry import trace span = trace.get_current_span() ctx = fasten.context.set_request_id( format(span.get_span_context().trace_id, '032x') )
fasten

The audit substrate for distributed systems — and the belief layer for the AI agents on top of them.

Products
fastenmembranefasten fleetmbnl · control — part of membrane
Resources
DocsHow It WorksQuickstartWhy fastenContact
© 2026 fasten · nerdAppLabs Software Solutions Pvt. Ltd.SDK Apache-2.0 · membrane & fleet commercial