AuditFlow

Overview

AuditFlow is a central router for audit events. It allows you to publish events once from any service and route them to multiple destinations (sinks) based on configurable, tenant-isolated pipelines. AuditFlow is stateless—it does not store events itself, but ensures they reach the configured persistent stores.

Capabilities

Capability Description
Tenant Isolation Every pipeline belongs to exactly one tenant. Events route only through their respective tenant’s pipelines.
Pluggable Sinks Send events to the destinations configured for a tenant.
Pluggable Transformers Modify or enrich event payloads in-flight before they reach a sink.
Stateless Routing Relies entirely on external persistence; it operates purely as an event processor.

Architecture

AuditFlow consumes events from RabbitMQ (or via direct API), passes them through a tenant’s pipeline (which may include transformers), and sends them to sinks.

flowchart LR
    subgraph Input
        API[HTTP POST]
        MQ[(RabbitMQ)]
    end
    
    subgraph AuditFlow Pipeline
        R[Router]
        T1[Transformer: Anonymize]
        T2[Transformer: Enrich]
    end
    
    subgraph Sinks
        S1[(OpenSearch)]
        S2[(S3 Bucket)]
    end
    
    API --> R
    MQ --> R
    
    R -->|"Match Tenant A"| T1
    T1 --> S1
    
    R -->|"Match Tenant B"| T2
    T2 --> S2

Quick Start

Run AuditFlow independently using Docker Compose:

git clone https://github.com/Labs64/labs64.io-auditflow.git
cd labs64.io-auditflow
just up

Test it by publishing an event:

curl -sS -i -X POST http://localhost:8080/audit/publish \
  -H 'Content-Type: application/json' \
  -d '{"eventType":"demo.event","sourceSystem":"demo","extra":{"hello":"world"}}'

Event fields

AuditEvent requires only eventType and sourceSystem. Everything your domain needs goes in extra, an open map — no key in it is required, and none is guaranteed. Every deployment defines its own field set, and keys AuditFlow does not recognise are delivered unchanged in the sink’s metadata map, so an event never loses data by using your own names.

A small convention sits on top: the generic audit-semantics keys userId, actionName, actionStatus, actionMessage, sessionId, durationMs and responseStatus, which the bundled transformers promote out of the map into dedicated fields and columns. Promotion is what turns a key into a queryable report dimension. All of them are optional, and an absent key produces an omitted field, never a placeholder.

To promote your own keys, either build a transformer module on a bundled one:

from audit_clickhouse import make_transform
transform = make_transform({"orderRef": "order_ref"}, module_id=__name__)

or configure it with no code at all, on the transformer container:

AUDITFLOW_PROMOTED_KEYS: '{"orderRef": "order_ref"}'

Either way, a promoted key needs a matching column in the sink schema — for ClickHouse, an ALTER TABLE ... ADD COLUMN — or the value is silently dropped at insert, because clickhouse_sink inserts with input_format_skip_unknown_fields=1. The config-only path is not schema-agnostic: it still requires the column to exist before the key is promoted. The full vocabulary and both promotion paths are documented on the Extra schema in the AuditFlow OpenAPI contract.

Configuration

Configuration is provided via environment variables or Helm values.

Variable Description Default
SPRING_RABBITMQ_HOST RabbitMQ broker host. localhost
SPRING_RABBITMQ_USERNAME RabbitMQ user. guest
SPRING_RABBITMQ_PASSWORD RabbitMQ password. guest
AUDITFLOW_PIPELINE_PATH Path to the directory containing tenant pipelines. /etc/auditflow/tenants

Pipeline Configuration Example

Tenants configure their pipelines via YAML files (e.g., tenants/tenant1.yaml):

tenantId: "tenant1"
pipelines:
  - name: "Store in OpenSearch"
    condition: "eventType == 'demo.event'"
    sinks:
      - type: "opensearch"
        properties:
          index: "audit-logs"

Transformers and sinks

Build every pipeline from a small set of explicit stages. The configured set depends on the deployment; validate a plugin’s supported interface in the service repository before enabling it.

Category Purpose Typical use
Transformers: privacy Remove, mask, or pseudonymize fields Keep personal data out of a downstream audit index
Transformers: enrichment Add context derived from known event fields Attach source or classification metadata
Transformers: normalization Reshape event data into a common form Make events easier to query consistently
Sinks: search Write records for investigation and querying OpenSearch
Sinks: object storage Store durable archive copies S3-compatible storage
Sinks: observability / SIEM Send events to security or operations tooling Splunk and equivalent configured adapters
Sinks: analytics Store events for aggregation and dashboards ClickHouse

Order matters: apply privacy transformations before a sink that should never receive the original field. Keep sink credentials in deployment secrets, not in pipeline files.

ClickHouse

The ClickHouse sink targets analytics workloads — aggregations by tenant, event type and time window. Pair clickhouse_sink with the audit_clickhouse transformer: the transformer flattens the canonical event into one row whose keys are the table’s column names, and the sink is pure transport. Pairing the sink with zero instead will fail every delivery.

Create the database and table before enabling the pipeline; the sink never runs DDL.

CREATE DATABASE IF NOT EXISTS audit;

CREATE TABLE IF NOT EXISTS audit.audit_events
(
    timestamp        DateTime64(3, 'UTC'),
    event_time       DateTime64(3, 'UTC'),
    event_id         UUID,
    correlation_id   String,

    event_type       LowCardinality(String),
    source_system    LowCardinality(String),
    tenant_id        LowCardinality(String),

    action_name      LowCardinality(String),
    action_status    LowCardinality(String),
    action_message   String,
    user_id          String,
    session_id       String,
    duration_ms      Nullable(UInt32),
    response_status  Nullable(UInt16),

    geo_lat          Nullable(Float64),
    geo_lon          Nullable(Float64),
    geo_country_code LowCardinality(String),
    geo_country      LowCardinality(String),
    geo_region       LowCardinality(String),
    geo_city         LowCardinality(String),

    extra            Map(LowCardinality(String), String),

    INDEX idx_ingest_time timestamp TYPE minmax GRANULARITY 4
)
ENGINE = ReplacingMergeTree(timestamp)
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_type, event_time, event_id)
TTL toDateTime(event_time) + INTERVAL 1095 DAY;

timestamp is server receipt time — AuditFlow assigns it, a client cannot override it. event_time is the business time the action happened at the source (the transformer falls back to timestamp when a publisher omits it), and it is the analytics axis: PARTITION BY, ORDER BY and TTL all key on event_time, not timestamp. Partitioning or grouping on timestamp instead silently breaks any backfill or replay, because a late-arriving event then lands in the wrong partition and the wrong retention window relative to when it actually happened.

ORDER BY leads with tenant_id because every dashboard query is tenant-scoped first. Tune PARTITION BY and TTL to your retention policy — the values above are illustrative.

The table is a ReplacingMergeTree keyed on event_id, not a plain MergeTree: AuditFlow is at-least-once, so a DLQ replay after the ~24h idempotency window can re-deliver an event, and ReplacingMergeTree collapses the two copies on merge (the later timestamp wins as the version column). That collapse only happens at merge time, so a query issued between deliveries can still see both rows — use SELECT ... FINAL (or an aggregating rollup) for any query where double-counting would matter, such as revenue.

pipelines:
  - name: analytics
    enabled: true
    transformer:
      name: audit_clickhouse
    sink:
      name: clickhouse_sink
      properties:
        service-url: http://clickhouse:8123
        database: audit
        table: audit_events
        username: auditflow
        password: ${secretRef:clickhouse-password}

Insert batching. AuditFlow delivers one event per request, and row-at-a-time inserts into MergeTree create one part per row. The sink relies on ClickHouse’s server-side async_insert to batch them, with wait_for_async_insert=1 so a successful delivery means the row is durably written and the retry/DLQ chain stays meaningful. This costs up to async-insert-busy-timeout-ms of latency per delivery. Never set wait-for-async-insert: false to buy that latency back — it makes the sink acknowledge events it may still lose.

Because every delivery blocks until its own flush, the rows a flush can collect are bounded by how many deliveries are in flight at once, not by how long the window is. Measured against ClickHouse 25.3, the largest flush always equalled the delivery concurrency. So the lever for fewer parts is more concurrent delivery (more sink replicas, more consumer concurrency) — a longer window only makes each caller wait longer, which lowers throughput and starves the very buffer it was meant to fill. At 32 concurrent deliveries a 1000 ms window measured worse on every axis than the 200 ms default: 24.7 vs 31.5 rows per flush, 24 vs 18 new parts, and 1016 ms vs 198 ms p50 delivery latency.

Property Default Notes
async-insert-busy-timeout-ms 200 ClickHouse aliases this to async_insert_busy_timeout_max_ms — it sets the max of the window, not the window. Keep timeout (default 10s) comfortably above it.
async-insert-use-adaptive-busy-timeout true Since ClickHouse 24.2 the window floats between the min and the max based on ingest rate. Leaving it on keeps latency low when events are sparse. Set false to pin the window at the max: worth ~5.1 → 7.9 rows per flush and 52 → 35 parts if deliveries are genuinely concurrent, but it costs ~3.4× p50 latency if they are not.
async-insert-busy-timeout-min-ms ClickHouse’s 50 Lower bound of the adaptive window; has no effect once the adaptive timeout is off.

async_insert_max_data_size and async_insert_max_query_number are not exposed: both are ceilings that can only flush a batch earlier, and neither is the binding constraint here — an audit row is ~473 bytes against a 10 MiB default, and ClickHouse only honours the query-number limit when async_insert_deduplicate is enabled.

Duplicates. DLQ replay can re-deliver an event. AuditFlow’s eventId deduplication (~24h) absorbs the common case, and the table schema above absorbs the rest: it is a ReplacingMergeTree keyed on event_id, not a plain MergeTree, so a re-delivered row is collapsed on merge rather than counted twice. Deduplication happens only within a partition and only after merge, so a query run between deliveries can still see both rows — use FINAL for anything where a duplicate would matter, such as revenue.

REST APIs

AuditFlow primarily consumes events from RabbitMQ, but also provides an API to publish directly or manage configurations.

Endpoint Method Description
/audit/publish POST Publish a single event synchronously.
/actuator/health GET Health check endpoint.

Events

AuditFlow listens to the shared RabbitMQ exchange for all ecosystem events.

Event Property Required Description
eventId Yes Unique UUID for the event.
eventType Yes Dot-separated action name (e.g., user.login).
tenantId No Target tenant. If omitted, routes to _platform.

Examples

Custom Transformer Plugin

Create a transformer to mask sensitive data (transformers/mask.py):

def transform(input_data: dict) -> dict:
    if "email" in input_data.get("payload", {}):
        input_data["payload"]["email"] = "***@***.com"
    return input_data

Operations

AuditFlow is designed to be horizontally scaled. Increase the replicaCount in your Helm chart to process more events concurrently. The underlying RabbitMQ queues will automatically distribute messages across the replicas.

Troubleshooting

Symptom Cause Resolution
Events not reaching sink Pipeline condition mismatch Verify the eventType and tenantId match the pipeline definition.
Python plugin crash Syntax error in plugin Check the AuditFlow logs for Python tracebacks. Ensure the plugin implements the correct method signature.
RabbitMQ connection error Bad credentials or network Verify SPRING_RABBITMQ_* environment variables.