Direct OCSF Ingestion
Ship pre-parsed OCSF straight into ClickHouse from Tenzir, Cribl, or a custom forwarder — no Vector
Direct OCSF Ingestion
If you already run a data aggregator that parses and shapes events to OCSF — Tenzir, Cribl, or a custom forwarder — you can write those records directly into nano's ClickHouse, bypassing nano's Vector pipeline entirely. Your aggregator owns parsing and normalization; nano owns storage, enrichment, search, and detection.
OCSF profile only. This path requires a deployment running the OCSF schema profile (NANO_SCHEMA_PROFILE=ocsf). The UDM logs table is not direct-writable — its ingestion is exclusively Vector-owned. The installer prompts for the schema profile on a fresh install.
Already using an aggregator? Pick who owns normalization
If you already run Tenzir, Cribl, or a similar tool, there are two ways to feed nano — and the choice is really about who parses and normalizes the data: nano, or your aggregator.
| nano parses (feed Vector) | Your aggregator parses (direct to ClickHouse) | |
|---|---|---|
| UDM schema | ✅ Ship to nano's HEC (:8088) or HTTP (:8080) endpoint tagged with a source_type; Vector's parsers normalize into logs. Default full stack. | ❌ Not supported — the UDM logs table has no direct-write contract. |
| OCSF schema | ✅ Same as above; Vector normalizes into ocsf_logs. Full stack. | ✅ This page. Your aggregator shapes to OCSF and writes straight to ClickHouse. No Vector — run the SIEM-only stack. |
Most teams should start by pointing at Vector (left column): ship raw events with a known source_type and let nano handle parsing, enrichment, storage, and detection. No ClickHouse credential, no schema commitment, works on either profile. See Splunk HEC (Cribl and Splunk forwarders speak this natively) or the push/pull routing model.
Choose direct ingestion (this page) when your aggregator is already your OCSF normalization layer and you want nano purely as the analytics + detection backend — no nano-side parsing. nano ships a SIEM-only deployment for exactly this: the full stack minus Vector. One tradeoff to weigh up front: nano keeps the promoted OCSF columns, message, and an unmapped spill — not a verbatim copy of your original event blob (see How it works).
docker compose -f docker-compose.siem-only.yml up -dSame aggregator, two postures: a Tenzir/Cribl shop can start on the Vector path (zero commitment, either schema) and graduate to the Vector-less direct-OCSF model when they want nano as pure storage and analytics.
How it works
You INSERT OCSF records into the nanosiem.ocsf_logs_raw landing table (a Null-engine table — nothing is stored there). An insert-time materialized view derives every promoted, enriched, and prevalence column into nanosiem.ocsf_logs from the event JSON, so a third-party INSERT gets exactly the same treatment as a Vector-lane row:
- Promoted columns materialize from the event (dotted OCSF paths:
src_endpoint.ip,actor.process.cmd_line, …) — indexed and searchable immediately. - Geo / ASN enrichment is applied at insert when the event isn't already enriched (pre-enriched OCSF passes through untouched).
- Prevalence — direct rows both feed and read the shared prevalence universe.
- Aggregation surfaces — entity first/last-seen, identity observations, per-source telemetry, and NAT detection all populate from direct-written rows.
- Row identity — a server-minted UUIDv7
idfor the event inspector.
The raw event blob is not retained. nano stores three things per row: the promoted columns (the dotted OCSF paths it materializes — e.g. src_endpoint.ip, actor.process.cmd_line), the message, and the unmapped spill holding every field with no promoted home. The original JSON object is not kept verbatim. Nothing is silently dropped — an unpromoted field is still queryable under unmapped — but if you need to re-read the exact original bytes later, keep your own copy upstream. To see which fields are promoted vs. spilled for your data, open a row in the event inspector: promoted OCSF paths surface as first-class columns; everything else appears under unmapped.
The credential
Authenticate as the dedicated INSERT-only ClickHouse user — never the application user.
| User | nanosiem_ingest |
| Password | CLICKHOUSE_INGEST_PASSWORD (generated by install.sh; key clickhouse_ingest_password in the nanosiem-secrets Kubernetes secret) |
| Scope | INSERT on nanosiem.ocsf_logs_raw, plus the derivations the insert-time MV chain needs |
This credential cannot read log content, query other tables, or write to the enrichment/prevalence source tables. Never hand a foreign pipeline the shared nanosiem application user — it can read and write the entire database.
Reaching ClickHouse, safely. If your aggregator runs as a container on the same Docker network as nano, it reaches clickhouse:8123 internally — no published port needed. If it runs elsewhere on the same host, the SIEM-only compose binds :8123 to 127.0.0.1 by default. To reach it from another host, you must widen that bind and put it behind a firewall or IP-allowlist — note that doing so also exposes the app (nanosiem) and admin (nanosiem_admin) users to the network, not just nanosiem_ingest. Never leave :8123 open to the internet.
Wire format
INSERT rows with JSONEachRow over the ClickHouse HTTP interface (port 8123).
HTTP works for any client; Tenzir can also use a native sink. The HTTP + JSONEachRow path below works everywhere and is the right default for Cribl, forwarders, and custom clients. Tenzir ≥ 6.4.0 can instead use its native to_clickhouse operator (the first release able to write ClickHouse JSON columns) — see Worked example: Tenzir. Older Tenzir, and other clickhouse-cpp-based sinks without JSON-column support, must use HTTP.
curl "http://<clickhouse-host>:8123/" \
--user "nanosiem_ingest:$CLICKHOUSE_INGEST_PASSWORD" \
--data-binary 'INSERT INTO nanosiem.ocsf_logs_raw (event, source_type) FORMAT JSONEachRow
{"event": {"class_uid": 4001, "category_uid": 4, "type_uid": 400101, "activity_id": 1, "severity_id": 1, "time": 1781234567890, "src_endpoint": {"ip": "203.0.113.7"}, "dst_endpoint": {"ip": "198.51.100.9", "port": 443}, "metadata": {"product": {"name": "my-fw"}, "version": "1.8.0"}}, "source_type": "my_firewall"}'Required per row
| Field | Rule |
|---|---|
event | The full OCSF JSON object (not a JSON-encoded string — a string fails JSONEachRow parsing and, with async inserts, can silently discard the whole batch). Must carry class_uid and time. |
event.time | Epoch milliseconds (OCSF timestamp_t). See fallback semantics below. |
source_type | A lowercase routing identity for the feed (e.g. my_firewall). Without it, rows land as unknown and every source_type=-scoped detection or hunt skips them. |
Optional: timestamp (derived from event.time when omitted — prefer omitting it), id (server-minted UUIDv7 when omitted).
nano's ingest minimum is looser than OCSF's. nano only needs class_uid + time to land a row — it promotes what it recognizes and spills the rest. But a schema-valid OCSF event carries more: for Network Activity (class_uid: 4001) that means at least category_uid (4), type_uid (class_uid × 100 + activity_id, e.g. 400101), activity_id, severity_id, metadata, and time. The examples on this page include those fields so they validate in OCSF tooling — emit the same from your pipeline rather than the bare minimum.
time fallback semantics
The timestamp column derives from event.time and never silently loses a row:
event.time shape | Result |
|---|---|
| Epoch milliseconds (spec) | Used as-is |
| Epoch seconds | Detected and multiplied to ms |
| Epoch microseconds | Detected and divided to ms |
| RFC3339 / ISO 8601 string | Parsed best-effort |
| Missing / garbage / negative | Falls back to insert time |
Only the first row is OCSF-compliant: timestamp_t is defined as epoch milliseconds. Epoch seconds, microseconds, and RFC3339 strings are nano compatibility extensions, not part of the spec — lean on them for a quick start, but emit epoch ms if you want events that are portable to other OCSF consumers.
Lowercase rule
Keep source_type — and any other categorical routing identifier you set on the row — lowercase. The server cannot rewrite client-provided values; the query layer tolerates mixed-case source_type, but lowercasing keeps facets, group-bys, and dashboards from splitting one feed into multiple values. This applies to categorical labels, not to data: fields inside the event object (IP addresses, hostnames, and the like) are stored exactly as your aggregator emits them and don't need lowercasing.
Async inserts
The server enables async_insert with wait_for_async_insert=1, so HTTP 200 is a durability ACK — the response waits for the flush, and a failing batch returns a retryable error. Batch your events (don't send one per request); expect up to ~1s of flush latency, which batching absorbs. Retrying is safe: a block-level dedup window means re-sending a byte-identical batch is acknowledged but writes nothing.
Worked example: Tenzir
A complete Tenzir pipeline that maps events to OCSF and writes them in via the native to_clickhouse operator. Requires Tenzir ≥ 6.4.0. Validated end-to-end against Tenzir v6.4.0.
// Shape your events as OCSF (or read records that already are), then:
from_file "/data/events.ndjson" { read_ndjson }
this = { event: this, source_type: "my_tenzir_feed" }
to_clickhouse table="nanosiem.ocsf_logs_native_raw",
host="<clickhouse-host>", port=9000,
user="nanosiem_ingest", password=secret("CLICKHOUSE_INGEST_PASSWORD"),
tls=false, mode="append"A few notes:
- Keep the password in a Tenzir secret.
password=secret("CLICKHOUSE_INGEST_PASSWORD")resolves the value at run time from the node config, an environment variable, or the connected Tenzir platform — the credential never lands in the persisted pipeline definition. Don't inline the literal. - Validate before you send (recommended). Running Tenzir's
ocsf::castconforms your record to the official OCSF schema — it type-checks fields, enforces required attributes, and reorders into canonical form — a solid gate before the sink. One caveat:ocsf::castserializes time-typed fields as RFC3339 strings; nano'stimefallback parses them, but keeptimeas an integer (epoch ms) where you can. - Wrap each OCSF record in the wire shape
{event: <ocsf record>, source_type: "<your feed>"}as the last transform before the sink. - Target
ocsf_logs_native_raw, notocsf_logs_raw.to_clickhouse'sappendmode validates every column type on the target and rejectsocsf_logs_raw'sLowCardinality(String)/ timezone-qualifiedDateTime64(3, 'UTC')columns.ocsf_logs_native_rawexposes onlyevent JSON+source_type Stringand forwards into the identical derivation chain.to_clickhousealso defaults to TLS — passtls=falsefor the plaintext native port.
A self-contained smoke test with a literal event:
from {
event: {
class_uid: 4001,
category_uid: 4,
type_uid: 400101,
activity_id: 1,
severity_id: 1,
time: 1781234567890,
src_endpoint: {ip: "10.0.0.50", port: 51234},
dst_endpoint: {ip: "8.8.8.8", port: 443},
metadata: {product: {name: "tenzir"}, version: "1.8.0"}
},
source_type: "my_tenzir_feed"
}
to_clickhouse table="nanosiem.ocsf_logs_native_raw",
host="<clickhouse-host>", port=9000,
user="nanosiem_ingest", password=secret("CLICKHOUSE_INGEST_PASSWORD"),
tls=false, mode="append"Worked example: Cribl
In Cribl Stream, send shaped OCSF events to a ClickHouse destination (or a generic HTTP destination) configured for the HTTP interface:
- URL:
http://<clickhouse-host>:8123/?query=INSERT INTO nanosiem.ocsf_logs_raw (event, source_type) FORMAT JSONEachRow&wait_for_async_insert=1 - Method:
POST, formatJSONEachRow(one JSON object per line) - Auth: HTTP Basic,
nanosiem_ingest+CLICKHOUSE_INGEST_PASSWORD - Payload: each record is
{"event": { …OCSF… }, "source_type": "<your feed>"}
Use a Cribl Eval/Serialize function to nest your shaped OCSF object under event and set a lowercase source_type before the destination.
Verify it landed
The nanosiem_ingest credential is INSERT-only — it can't run a SELECT (see The credential), so verify from the nano search bar rather than querying ClickHouse as the ingest user. After sending a batch, the data is queryable natively:
source_type=my_tenzir_feed | stats count by src_endpoint.ipPromoted columns, insert-time geo/ASN enrichment, prevalence, entity pages, and detection-rule matches all work on direct-written rows exactly as they do for Vector-collected data.
To confirm straight against ClickHouse instead, run the count with a read-capable credential — the nanosiem application user — never the INSERT-only nanosiem_ingest:
curl -s "http://<clickhouse-host>:8123/" \
--user "nanosiem:<app-password>" \
--data-binary "SELECT source_type, count() FROM nanosiem.ocsf_logs
WHERE _inserted_at > now() - INTERVAL 10 MINUTE GROUP BY source_type"Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
to_clickhouse rejects the insert with a column-type error | Writing to ocsf_logs_raw (whose columns are LowCardinality / timezone-qualified) via the native append sink | Target nanosiem.ocsf_logs_native_raw — see Worked example: Tenzir |
| Connection refused / timeout on port 9000 | The native ClickHouse port isn't reachable — not published, or firewalled off | Reach ClickHouse over the internal Docker network, or widen the bind behind a firewall (see The credential); or fall back to the HTTP :8123 + JSONEachRow path |
TLS handshake / protocol error from to_clickhouse | to_clickhouse defaults to TLS, but the native port is plaintext | Pass tls=false |
to_clickhouse unknown / rejects JSON columns | Tenzir older than 6.4.0 (first release able to write ClickHouse JSON columns) | Upgrade to Tenzir ≥ 6.4.0, or use the HTTP + JSONEachRow path |
Rows land but source_type shows as unknown | source_type missing or not lowercased | Set a lowercase source_type on every record before the sink |
| Rows are timestamped at insert time, not event time | event.time missing or malformed, so the fallback used insert time | Emit event.time as epoch milliseconds (OCSF timestamp_t) |
| INSERT reports an error yet rows appear anyway | A materialized-view grant is missing; the flush can fail after the ocsf_logs parts commit | Extend the nanosiem_ingest grants for any custom MV (managed upgrades do this automatically); a byte-identical retry is safe |
| Events ingest but fail validation in other OCSF tools | Required OCSF fields missing (category_uid, type_uid, severity_id) | nano ingests them regardless, but emit schema-valid OCSF — gate with ocsf::cast |
Known limitations
- Self-managed deployments that add materialized views reading new columns must extend the
nanosiem_ingestgrants in lockstep (the managed operator upgrade does this automatically). - A flush rejected by a missing grant can return an error after the
ocsf_logsparts are committed — the rows are stored even though the INSERT reported failure. The dedup window makes a byte-identical retry safe.