Back to fasten

Installation

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

Today — install from source

bash
pip install ./python                        # Python — reference impl
go get github.com/nerdapplabs/fasten-go    # Go (module path, works today)
npm install ./js                            # Node / TypeScript
# C++14: copy cpp/include/fasten.hpp — zero dependencies

v1.0.0-beta.0 — coming soon roadmap

bash
pip install fasten==1.0.0b0                     # Python — reference impl
pip install 'fasten[tui]==1.0.0b0'              # + bundled live TUI
npm install @nerdapplabs/fasten@1.0.0-beta.0    # Node / TypeScript

5-minute quickstart

1 Register 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,
    ),
})

2 Init — 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.

3 Emit 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

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 one SQLite file (fasten.db). Your product owns its own DB. They share a request_id join key — they never share a file.

storage layout
{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
                             holds replay_payload for recovery

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 fasten_audit via idempotent CREATE TABLE IF NOT EXISTS on init(). Your product creates audit_replay via its own migrations. Never mix them in the same file.

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/fasten"
    httpshim "github.com/nerdapplabs/fasten-go/fasten/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

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_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 — keys matching known patterns (api_key, password, token, authorization) have values replaced with *** before writing anywhere.

python
fasten.emit("USER_CREATED", target="u-42",
           detail={"email": "alice@acme.com", "api_key": "sk-abc123"})
# stored: {"email": "alice@acme.com", "api_key": "***"}

fasten.init(..., extra_redact_keys=["ssn", "credit_card", "dob"])

2. PII class flag — forces retention to short regardless of declared class:

python
Meta(..., pii_in_detail=True)   # overrides → retention_class: short (30 days)

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?

~0.1–0.5 ms per emit() with SQLite WAL. ~1–5 ms with Postgres. fasten.log.* is ring + stdout only — sub-millisecond. 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?

No. In-process library. Ring buffer uses a mutex (channel in Go). No goroutines, no threads, no sidecar.

What happens if the DB is unavailable?

  • The row is written to stdout regardless — Docker / journald captures it.
  • The SQL insert fails silently in the request path; the row is not lost.
  • For guaranteed durable SQL persistence, use the outbox variant with a retry queue (planned).

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.

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 OTel trace_id as fasten's request_id — they become the same id. A future transport/otlp plugin will export audit rows as OTel 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')
)