Sinks and transformers
Sinks deliver events to destinations; transformers reshape or enrich an event before delivery. Both are Python modules discovered at runtime.
Sinks
| Sink | Destination | A batch is written as |
|---|---|---|
logging_sink | Log output (dev/testing) | one event at a time |
webhook_sink | HTTP POST to any URL | one event at a time |
syslog_sink | RFC 5424 syslog | one event at a time |
loki_sink | Grafana Loki | one push, equal label sets merged |
opensearch_sink | OpenSearch / Elasticsearch | one _bulk request |
aws_s3_sink | Amazon S3 | one JSON Lines object per partition folder (the receipt day) |
aws_cloudwatch_sink | Amazon CloudWatch Logs | one PutLogEvents call |
gcs_sink | Google Cloud Storage | one JSON Lines object per partition folder (the receipt day) |
azure_blob_sink | Azure Blob Storage | one JSON Lines blob per partition folder (the receipt day) |
datadog_sink | Datadog Logs API | one request, up to 1000 entries |
splunk_sink | Splunk HTTP Event Collector | one request |
snowflake_sink | Snowflake (via the Python connector, not bundled) | one connection and one multi-row insert |
clickhouse_sink | ClickHouse table over the HTTP interface; pair it with the audit_clickhouse transformer | one multi-row insert |
postgres_sink | PostgreSQL table; the event is stored as JSON in an event_data column | one multi-row insert in one transaction |
netlicensing_sink | Licensing-platform integration: turns completed checkout transactions into licensee and license records; requires extra.transaction | one event at a time |
The last column applies to pipelines with batch.enabled: true. GET /registry on the sink service reports the same per sink as batch: true|false.
Which time each sink uses
An event has an event time (eventTime, when the action happened) and a receipt time (timestamp, when AuditFlow received it); see Time fields. Every bundled sink and transformer uses them the same way, and none uses the moment of delivery.
| Destination | Time used |
|---|---|
| ClickHouse, OpenSearch | both, as event_time and timestamp; reports group on event_time |
| Loki, Splunk, syslog | the entry sits at the event time |
| CloudWatch Logs | the entry sits at the receipt time: CloudWatch refuses entries older than 14 days, so an old eventTime stays inside the message |
| S3, Google Cloud Storage, Azure Blob | folders and object names use the receipt day (UTC), so a publisher cannot choose where its data lands and a backfill lands in today’s folder; eventTime is inside each object |
| PostgreSQL | both inside event_data; the inserted_at column of the example table is the write time and is never a reporting axis |
| Datadog, webhook, logging, Snowflake | the event as it is, with both times inside; the destination applies its own date handling (point Datadog’s Log Date remapper at eventTime to date logs by event time) |
To read an archive by the day things happened, filter on eventTime inside the objects: the date folders are the receipt day, and a late arrival sits in a later folder.
On Kubernetes with the chart’s NetworkPolicy enabled, the pod may only reach HTTPS (port 443) outside the platform services. A sink whose destination listens on another port (ClickHouse 8123 or 8443, PostgreSQL 5432, syslog, Splunk HEC 8088, Loki 3100, OpenSearch 9200) needs a matching networkPolicy.extraEgress rule in the chart values.
Transformers
| Transformer | What it does |
|---|---|
zero | Pass-through (no change) |
audit_loki | Reshape event for Loki label conventions |
audit_opensearch | Reshape event for OpenSearch indexing |
audit_clickhouse | Flatten event into one ClickHouse row; extensible per use case via make_transform() |
A domain layer on top of audit_clickhouse is a mounted example, not shipped in the image: see Modelling your own domain.
Adding your own sink or transformer
Drop a .py file into sinks/ or transformers/. It’s discovered at runtime — no backend changes, no image rebuild for local development.
# sinks/my_sink.py
def process(event_data: dict, properties: dict) -> dict:
# event_data: the audit event JSON
# properties: pipeline-specific config from sink.properties
return {"sent": True}
Then reference it by filename (without .py) in a pipeline:
sink:
name: my_sink
properties:
api-key: "${MY_API_KEY}"
See DEVELOPERS.md for full details on the plugin API, transformer chaining, and testing.
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,
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 (AuditFlow sets it to the receipt time when a publisher omits it, so it is never empty), 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.
Modelling your own domain
The ClickHouse transformer and schema are generic: they promote the well-known audit-semantics keys into columns and leave everything else in a Map(String, String). A use case that wants its own fields as queryable dimensions adds a layer instead of changing the core: a key map on top of the shipped transformer, an ALTER TABLE script and a documented vocabulary.
| Layer | Generic (shipped) | Use-case layer (the repository’s worked example) |
|---|---|---|
| Columns | schema.sql | an ALTER TABLE ... ADD COLUMN script, for example schema-netlicensing.sql |
| Promotion | audit_clickhouse | a module of about 40 lines of data: make_transform({...}), for example audit_clickhouse_netlicensing.py |
| Vocabulary | Extra in the API contract | a field and event reference, for example NETLICENSING_EVENTS.md |
| Pipeline | tenants/_platform.yaml | a tenant file that selects the layered transformer |
The example layer is mounted into the transformer’s bootstrap directory rather than baked into the image, which is the same extension point you use for your own. Both pipelines write to the same table, so the compose stack shows the two side by side. The worked example models a licensing business (entity lifecycle and validation events plus a payment lifecycle) with the extra keys each event emits and the ClickHouse queries built on them. It is deliberately not a full analytics model: no recurring-revenue, retention or cohort machinery. It shows what ClickHouse gives you over a log line, and leaves the reporting model to the deployment.
Rules to preserve when you build a layer:
- Never add a domain key to the generic transformer or a domain column to the generic schema. Extend with
make_transform()and anALTER TABLEscript. A transformer takes no pipeline properties, so the module id selects a vocabulary: one module per domain. - Every promoted key needs a column and every column a promoted key, or the pairing silently half-works.
event_timeis the analytics axis,timestampis receipt time.- Never redact a key that is promoted to a reporting dimension: a redacted column reads as empty, not as an error.