← 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 flag, pii_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