Events
Events is the system of record of MapexOS. It is the end of the line: every other stage — ingest, transform, route, automate — hands its output here, and Events persists it into ClickHouse as a typed, queryable row. It makes no decisions and publishes nothing back to the bus; it is pure storage and read. When you ask what happened, to which asset, when, and why — this is the service that answers.
Its defining trick is that it stores schema-less data in a typed, columnar shape: arbitrary per-vendor fields become queryable typed columns without a migration, every row carries its own retention, and a single tracking id ties one event's entire journey together.
Port
5004· Go (DDD + Hexagonal) · NATS JetStream batch consumers → ClickHouse (bulk insert, TTL retention) · MongoDB (retention catalog) + Redis + S3/MinIO (tiered template cache) · three modules:events,asset_status,retention.
EVA — typed storage for schema-less data
Every asset speaks a different payload: one sends temperature, another co2, a third doorState. Storing that as JSON blobs would make it unqueryable; storing a column per field would mean a migration per device. MapexOS does neither. It uses EVA (Entity-Value-Attribute): each template field is assigned a numeric fieldId, and the value lands in one of four typed ClickHouse MAP columns, keyed by that id:
| Column | Type | Holds |
|---|---|---|
eva_number |
map<uint16, float64> |
numeric fields — temperature, pressure, count |
eva_string |
map<uint16, string> |
text fields — status, location, device type |
eva_bool |
map<uint16, uint8> |
booleans — alarm, online, active |
eva_date |
map<uint16, datetime> |
timestamps — last update, alarm time |
The type comes from the field's declared type in the asset's template (with inference as a fallback; a geo field stores its latitude as a number and a "lat,lng" string). The result: a new vendor or model is a template configuration, never a schema change — yet every dynamic field is stored typed and columnar, so you can filter and aggregate on it directly. The POST /store/query endpoint takes a list of EvaFilters — conditions on those dynamic fields — and pushes them straight into ClickHouse.
To turn the numeric ids back into human-readable names on read, Events resolves the asset's template through a tiered cache (L0 RAM → L1 disk → L2 S3/MinIO → HTTP fallback to the Assets service), kept coherent by a FANOUT template.invalidate consumer so a template edit upstream never serves stale names.
Per-row retention — each row knows how long to live
Retention is not one global setting. Every row is stamped at write time with its own retention_days, resolved per-organization and per-table from the retention module (a MongoDB catalog, Redis cache-aside), and ClickHouse's native TTL expires it automatically. Different classes of data live for different spans by default — raw payloads and debug logs briefly, execution histories longer, processed events longest — and any organization can set its own policy:
| Data class | Default lifetime |
|---|---|
| Raw ingest · JS-executor debug | 1 day |
| Router / trigger / workflow execution history | 7 days |
| Processed events · dead-letter | 30 days |
A new organization is seeded with default policies automatically (the module consumes the organization.created lifecycle event), and editing a policy issues the corresponding ClickHouse TTL change.
What it stores — one consumer per stream
Events runs a batch consumer per stream, each writing its own ClickHouse table:
| Stream | What it captures |
|---|---|
events.save |
Processed events — the primary table, with full EVA mapping. |
events.raw |
Raw ingest payloads, as received. |
events.logs.jsexecutor |
JS-executor decode/validate/convert debug output. |
events.router |
Router decisions — the per-event routing history. |
events.trigger |
Trigger execution outcomes. |
events.workflow (EVENTS-WORKFLOW-LOGS) |
Terminal workflow executions. |
dlq |
Dead-letter entries from any service. |
Together these are a complete, queryable audit trail: the processed event itself, and the record of how it was routed, what it triggered, and which workflow it ran.
Traceable end to end by one id
A single eventTrackerId (a UUID) is minted at ingest and propagated through every service — route, trigger, workflow — and written onto every row here. So reconstructing one event's entire story is a single key lookup, not a join across systems: you can follow it from the raw payload, through the routing decision, to the trigger that fired and the workflow that ran. Names (asset_name, template_name, …) are denormalized at write time, so reads never pay for a join.
How a batch is persisted
All consumers share one generic three-phase pipeline, tuned for ClickHouse's love of large writes:
Phase 1 — parse in parallel a bounded worker pool (NumCPU × 2) parses,
validates, and EVA-maps each message
Phase 2 — one bulk insert every valid row goes to ClickHouse in a single
bulk INSERT — minimal write amplification
Phase 3 — settle per message Ack on success · Nack (retry) on insert failure
· Reject (→ DLQ) on a parse/validation failure
The dead-letter consumer uses a deliberate variant that never Nacks — a message it can't parse is acknowledged and skipped rather than retried, so the DLQ can never feed itself in a loop.
Beyond events — connectivity history
A second module, asset_status, persists every online/offline transition an asset makes into its own table and serves a connectivity timeline: GET /api/v1/events/connectivity_history and …/assets/:assetUUID/connectivity_history, filterable by time window and transition type. Operations can see exactly when a device dropped and came back — a first-class history, not something reconstructed from logs.
Read & manage — the HTTP surface
All read routes are cursor-paginated, organization-scoped, and permission-gated, under /api/v1/events:
- Per-stream lists:
GET /raw,/jsexec,/router,/trigger,/workflow,/dlq(+/dlq/countsgrouped by service). POST /store/query— processed events with optionalEvaFilters(query by dynamic field).GET /store/:eventTrackerId— one processed event with its EVA ids resolved to template-defined names.GET /workflow/execution/:executionId— a single workflow record.- Connectivity timelines (above).
Retention policies are managed under /api/v1/retention: GET /, GET /:id, PUT / (upsert by {orgId, type}), DELETE /:id.
Inputs & outputs
Consumes (NATS JetStream, batch pull): events.save, events.raw, events.logs.jsexecutor, events.router, events.trigger, events.workflow, dlq, events.asset_status_save, organization.created, and the FANOUT template.invalidate.
Produces: nothing on the bus. Events is a terminal sink — its only outbound effect is a ClickHouse TTL change when a retention policy is edited.
Observability
Per-consumer Prometheus metrics track messages consumed / parsed / inserted / failed, ClickHouse bulk-insert latency per table, EVA template-cache hit/miss, and batch sizes. Health surfaces the dependency graph (MongoDB, Redis, NATS required; ClickHouse and S3 degrade gracefully — the service keeps accepting messages from NATS even when writes are briefly unavailable).
Where to go next
| To dive into… | Go to |
|---|---|
| Who decides what gets persisted | Router |
| Where dynamic fields are defined | Architecture Overview |
| What produces execution histories | Triggers · Workflow Engine |
MapexOS