Build Axiom

KINDAreplaces $25/mosaves $300/yrback to the verdict

0%0 of 20 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

An ingest endpoint with per-source rate and size limits, events stored compactly with the fields you actually query indexed, a query interface fast enough to be used during an incident, retention that compacts old data rather than deleting it outright, and alerts on patterns you define.

estimated effort multi-daythe files for this build are in the project pack

RuntimeGo or Node 22 with PostgreSQLIngestA bounded HTTP endpoint with per-source token authenticationStorageStructured columns for what you query, a compressed blob for the restRetentionCompaction to counts and samples before deletionAlertingSaved queries evaluated on a schedule

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the ingest endpoint and the query service. Ingest is a hot path, so pick the one you can profile confidently.

    Get it go.dev or nodejs.org.

    Verify The toolchain builds and runs a hello world

  2. installfree

    Why Handles both the write-heavy ingest and the analytical queries at the volume one team produces.

    Get it Install locally or run it in Docker, with enough disk for your intended retention. open ↗

    Verify psql connects and reports 16 or higher

  3. decidefree

    Why It determines the limits, the retention period and whether one database is enough. Guessing high wastes money and guessing low means the store stops working during the incident you built it for.

    Get it Measure a day of logs from your busiest service. Multiply by your services and by a factor for incidents, since incidents produce far more logs than normal operation.

    Verify A written estimate of events per day and bytes per day exists

  4. have readyfree

    Why Field selection, compression ratios and redaction patterns are all only meaningful against real log lines, which are messier and more repetitive than generated ones.

    Get it Export a day from a service you run. Include an incident if you have one, since that is the shape the store must survive.

    Verify A real day's logs import and their bytes per event is measured

  5. have readyvaries with volume; this is the recurring cost

    Why The storage ceiling and eviction policy exist because disks fill, and a store that fills is a store that stops accepting the logs you need.

    Get it Size from your estimate, then add capacity for an incident producing far more than a normal day.

    Verify Free disk comfortably exceeds the configured storage ceiling

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- sources(id, name, token_hash, rate_limit_per_minute, max_event_bytes, enabled, created_at)
- events(id, source_id, at, level, message, service, trace_id, attributes_compressed) · indexed fields are explicit, everything else compressed
- ingest_stats(source_id, minute, accepted, rejected_rate, rejected_size, bytes) · so a rejecting source is visible rather than silent
- saved_queries(id, name, query_json, created_at)
- alerts(id, saved_query_id, threshold, window_minutes, last_fired_at, state)
- compactions(at, from_date, to_date, events_before, rows_after) · proof that retention ran and what it kept
- Which fields are indexed is a deliberate list rather than everything, because that list is the difference between a store that answers quickly and one that does not.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://user:pass@localhost:5432/logsYour PostgreSQL connection string.
DEFAULT_RATE_LIMIT_PER_MINUTErequired6000Per source. Overridable per source, and the containment for a runaway service.
MAX_EVENT_BYTESrequired32768Per event. A single enormous event is a different failure from a flood.
STORAGE_CEILING_GBrequired200Total. Reaching it evicts oldest compacted data rather than refusing ingest.
COMPACT_AFTER_DAYSrequired14Age at which events compact to counts and samples.
QUERY_DEFAULT_WINDOW_MINUTESrequired60Default time range, so a casual query never scans everything.
REDACT_PATTERNSrequired./config/redact.jsonCredential patterns redacted on ingest, before storage.

The build, in order

  1. Limits before anything else

    A source that starts logging in a loop is refused rather than filling the disk.

    1. A token per source, so limits and rejections attribute correctly.

      Files src/auth.go

    2. Both. A single enormous event and a flood of small ones are different failures.

      Files src/limits.go

    3. Per source per minute. A silently rejecting source is a source whose operators think it is logging.

      Files src/stats.go

    done when · tick each as it passes
    watch out
    • Applying a global rate limit rather than a per-source one means a runaway service starves every other service of ingest, and you lose the logs from the systems that were working. Limit per source, so a misbehaving one is contained rather than contagious.
  2. Storing what you will query

    Events are stored compactly and the fields you actually search are fast.

    1. Time, level, service and a trace identifier cover most incident queries. Write down why.

      Files docs/schema.md

    2. One blob per event. Queryable slowly, which is the right cost for a rare query.

      Files src/store.go

    3. Bytes per event and query time. Every later claim depends on these numbers.

      Files scripts/generate.go

    done when · tick each as it passes
    watch out
    • Indexing every attribute of every event produces an index larger than the data and slows ingest to the point where it becomes the bottleneck. Choose the handful of fields you actually filter by, index those, and accept that a query on an unindexed attribute scans, which is the correct trade at this scale.
  3. Queries you can run during an incident

    Finding the relevant events takes seconds when it matters.

    1. Time, level, service, text and trace. Five filters cover most of what an incident needs.

      Files src/query.go

    2. Recent by default. Widening is a deliberate action with the cost shown.

      Files src/query.go

    3. So an expensive query is visibly expensive rather than mysteriously slow.

      Files src/query.go

    done when · tick each as it passes
    watch out
    • Defaulting a query to all time means every casual search scans the whole store, and during an incident that is exactly when the store becomes unresponsive. Default to a recent window, require an explicit choice to widen it, and show how much was scanned.
  4. Retention that compacts rather than deletes

    Old data becomes small rather than absent.

    1. Hourly per service per level, with a bounded sample of full events.

      Files src/compact.go

    2. In one transaction, or the failure mode is losing both.

      Files src/compact.go

    3. What it processed and what it kept. The proof that retention happened.

      Files src/compact.go

    done when · tick each as it passes
    watch out
    • Deleting old logs outright removes your ability to answer whether a problem is new, which is usually the first question during an incident. Compaction keeps the shape of history at a fraction of the size, and the retained samples keep enough detail to recognise a recurrence.
  5. Alerts as saved queries

    A pattern you care about notifies you once rather than continuously.

    1. One query language for searching and alerting keeps both honest.

      Files src/alerts.go

    2. Into and out of the condition. Not every evaluation.

      Files src/alerts.go

    3. Query, count, window and a link. Enough to judge without opening anything.

      Files src/notify.go

    done when · tick each as it passes
    watch out
    • An alert firing on every evaluation while a condition persists produces a message every minute during an incident, which is the moment you least want your attention consumed. Fire on the transition into and out of the condition, and include a link and a count rather than repeating.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more monitoring.