Files
cairnobs/storage/README.md
T
jcoffey-dev cd8aa290ca Phase 1: Windows log collection + full-text search
Extends the agent, ingest, storage, api, and web with Windows Event
Log/ETW sourcing and Tantivy-backed free-text search, per the approved
Phase 1 plan.

- CLAUDE.md: materialized on disk (never existed as a file before) with
  a new Phase 1 "done looks like" section.
- agent: Windows Event Log (EvtSubscribe) and ETW sources, Windows
  service wrapper (install/uninstall/run-service), both feature- and
  target_os-gated so Linux builds/tests/clippy stay unaffected. Also
  fixed two pre-existing Phase 0 clippy gaps (dead-code on
  default-features-only builds, a type-inference edge case) found while
  testing every feature combination properly for the first time.
  UNVERIFIED on real Windows -- no Windows toolchain existed anywhere in
  the build environment; flagged prominently in three places.
- proto/ingest: new record_id field, assigned once server-side in
  ingest's gRPC front end so ClickHouse and Tantivy agree on the same ID
  for the same record.
- storage: record_id column + bloom filter index, verified against a
  live ClickHouse.
- search: new service, Tantivy index, rskafka consumer as an independent
  second consumer group on the same Redpanda topic ingest already reads.
- api/web: new /search endpoint and page, sharing the query page's
  result-table shape and component.
- hack/windows-fixture: sends realistic Windows-shaped data straight to
  ingest, so the pipeline's handling of it is verifiable without a
  Windows host.

Verified end-to-end on the live docker-compose stack: the same record_id
comes back from both /query and /search for the same log line, including
for windows-fixture's synthetic Windows Event Log data. Real bugs found
and fixed along the way: api/Dockerfile missing proto/ in its build
context, search's logs being completely silent (RUST_LOG gap), and
search/target/ missing from .gitignore/.dockerignore.
2026-08-13 11:27:35 -07:00

5.1 KiB

storage

ClickHouse schema and migration tooling for Sentry's analytical store.

Schema

One table, logs (Phase 0 columns plus record_id, added in migrations/0002_add_record_id.sql):

CREATE TABLE logs
(
    `timestamp`  DateTime64(9, 'UTC'),
    `host`       String,
    `service`    String,
    `severity`   LowCardinality(String),
    `message`    String,
    `attributes` Map(String, String),
    `record_id`  UUID DEFAULT generateUUIDv4()
)
ENGINE = MergeTree
PARTITION BY toDate(timestamp)
ORDER BY (service, timestamp)
-- plus: INDEX record_id_idx record_id TYPE bloom_filter GRANULARITY 4

record_id (Phase 1) is the stable per-record identifier Tantivy's full-text search joins back to this table with — /ingest's gRPC front end assigns it once, server-side, before a record is produced to Redpanda (see /ingest/README.md for why it has to happen exactly once, upstream of both the ClickHouse-writer and Tantivy-indexer consumers). Added via ALTER TABLE ... ADD COLUMN + ADD INDEX rather than changing ORDER BY: ORDER BY (service, timestamp) is the proven time-range-scan access pattern from Phase 0 and shouldn't be disturbed for a fundamentally different access pattern (point lookups by ID). A data-skipping bloom filter index on record_id serves the WHERE record_id IN (...) lookup Tantivy-backed search results need, without touching the primary sort order. The DEFAULT generateUUIDv4() is a safety net, not the normal path — every row /ingest writes explicitly supplies its own record_id from the proto message; the default only matters for rows written some other way.

Notes on choices that weren't fully specified by the task description:

  • DateTime64(9, 'UTC') (nanosecond precision) rather than second or millisecond precision, to match the agent's timestamp_unix_nano field end to end without truncation.
  • severity as LowCardinality(String), not a numeric OTel SeverityNumber. /ingest's normalize package writes short text values (TRACE/DEBUG/INFO/WARN/ERROR/FATAL/UNSPECIFIED). LowCardinality gets you most of the storage/query efficiency of an enum without committing to one at the schema level. Splitting into a proper SeverityNumber + SeverityText pair (full OTel shape) is one of the open questions already flagged in /docs/architecture.md.
  • PARTITION BY toDate(timestamp) (daily partitions) and ORDER BY (service, timestamp) are exactly what the task asked for — service-scoped queries over a time range are the dominant access pattern this is optimized for.
  • No TTL/retention clause yet — also an open question in architecture.md, deferred until storage sizing is a real concern.

Migration tooling: a plain SQL-file runner, not golang-migrate

migrate.sh applies migrations/*.sql in filename order over ClickHouse's HTTP interface, tracking what's applied in a schema_migrations table. Chosen over golang-migrate for Phase 0 because there's exactly one migration to run — pulling in a migration framework (another dependency, another thing to configure/vendor) for a single CREATE TABLE is exactly the kind of premature machinery this project's conventions say to avoid. Revisit golang-migrate once there's real schema churn across environments (rollback support, checksums, concurrent-apply safety become worth their cost at that point, not before).

Convention: one DDL statement per migration file. The ClickHouse HTTP interface isn't reliably multi-statement, so migrate.sh doesn't try to split multi-statement files — keep each migration to a single statement.

Running

docker compose up -d                          # starts a standalone ClickHouse for local work
CLICKHOUSE_PASSWORD=sentry-dev-only ./migrate.sh   # applies migrations/*.sql

CLICKHOUSE_PASSWORD here has to match whatever docker-compose.yml set on the clickhouse service — found this the hard way running the Phase 0 runbook for real: the official ClickHouse image silently disables all network access (including the published port, not just container-to- container traffic) for the default user unless CLICKHOUSE_USER or CLICKHOUSE_PASSWORD is a genuinely non-empty value. An empty CLICKHOUSE_PASSWORD="" still triggers the lockdown — it has to actually have a value. Not a real secret, just what this image demands.

Environment variables migrate.sh reads (all optional except CLICKHOUSE_PASSWORD as of the above, matching /ingest's ClickHouse defaults so the two stay in sync out of the box):

Var Default
CLICKHOUSE_HTTP http://localhost:8123
CLICKHOUSE_USER default
CLICKHOUSE_PASSWORD (empty — override, see above)
CLICKHOUSE_DATABASE sentry

There's also a Dockerfile (bash + curl baked in, migrations/ copied in at build time) used by the root-level docker-compose.yml as a one-shot init service — no runtime package install, no host volume mount needed.

Adding a migration

Add migrations/000N_description.sql with the next sequential number and a single DDL statement. migrate.sh picks it up automatically — no registration step.