Vibecode Axiom
track this build5 phases, 15 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Axiom, ingest bounded logs, search them, and retain a compact local event store. The hard boundary is high-performance cloud storage, query engine, integrations, and managed operations, plus independent infrastructure and reliable alerting.
You are building a lean indie version of Axiom. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Axiom · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces | | Ingest | A bounded HTTP endpoint with per-source token authentication | the limits are per source, so sources must be identifiable | | Storage | Structured columns for what you query, a compressed blob for the rest | indexing every field of every event costs more than the events are worth | | Retention | Compaction to counts and samples before deletion | an aggregate of last quarter is far more useful than nothing and far cheaper than everything | | Alerting | Saved queries evaluated on a schedule | an alert is a query with a threshold, which keeps one language for both | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Go or Node.js 22 or newer** · free - 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 - [ ] **PostgreSQL 16 or newer** · free - 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. - Verify: psql connects and reports 16 or higher - [ ] **An estimate of your actual event volume** · free - 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 - [ ] **A day of real logs from a real service** · free - 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 - [ ] **Disk sized from the estimate with room to spare** · varies 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. - high-performance cloud storage, query engine, integrations, and managed operations - global probe network - phone and SMS delivery - massive retention - advanced incident response and support If one of those is essential to you, that is the reason to keep paying for Axiom, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Axiom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a log store that accepts bounded ingest, answers queries fast, and cannot be filled by a runaway process to replace Axiom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The failure that defines this category is a service logging in a loop. Within an hour it produces more events than a month of normal traffic, the disk fills, ingestion stops for everything, and the logs you needed to diagnose the loop are the ones you no longer have. So limits come first: a per-source rate cap, a per-event size cap and a total storage ceiling with an eviction policy, all built before any query interface exists. A log store that refuses excess traffic loudly is far more useful than one that accepts everything until it stops accepting anything. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces - Ingest: A bounded HTTP endpoint with per-source token authentication · the limits are per source, so sources must be identifiable - Storage: Structured columns for what you query, a compressed blob for the rest · indexing every field of every event costs more than the events are worth - Retention: Compaction to counts and samples before deletion · an aggregate of last quarter is far more useful than nothing and far cheaper than everything - Alerting: Saved queries evaluated on a schedule · an alert is a query with a threshold, which keeps one language for both ### Data model (create this before Phase 1) - 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. ### Phase 1 · Limits before anything else Build: An ingest endpoint requiring a per-source token. A rate limit per source per minute and a maximum event size. Reject excess with a clear response and record the rejection so the source's own operators can see it. Trap: 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. Done when: A source exceeding its rate limit is rejected while other sources continue unaffected, verified by flooding one while another logs normally. An oversized event is rejected with a clear response naming the limit. Rejections are recorded per source per minute. Ingest requires a valid token. Do not build yet: No storage layout, queries or alerts. ### Phase 2 · Storing what you will query Build: Decide which fields are indexed columns and which go into a compressed attribute blob. Store timestamps in a consistent zone. Measure bytes per event and query time for an indexed field against ten million events. Trap: 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. Done when: Ten million events are stored and the bytes per event are measured. A query filtering on an indexed field over that volume returns in well under a second. A query on an unindexed attribute completes, slowly, rather than failing. Timestamps are stored consistently and a query across a clock change returns the right events. Do not build yet: No query interface or retention yet. ### Phase 3 · Queries you can run during an incident Build: A query interface with time range, level, service and free text over the message, plus a trace identifier lookup that returns every event for one request. Default the time range to something recent so a query never accidentally scans everything. Trap: 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. Done when: A trace identifier lookup returns every event for that request across services, ordered by time. A free text query over ten million events returns in a usable time. The default time range is recent and widening it is explicit. Every query reports how many events it scanned. Do not build yet: No retention or alerts yet. ### Phase 4 · Retention that compacts rather than deletes Build: Compact events older than a threshold into per-service per-hour counts by level, with a small sample of full events retained. Delete the originals afterwards. Record what each compaction run did. Trap: 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. Done when: Compaction reduces storage substantially while preserving hourly counts by service and level. A question about whether an error rate is unusual can be answered against compacted data. Retained samples remain queryable. Each compaction run records what it processed and what it kept. Do not build yet: No alerts or operations yet. ### Phase 5 · Alerts as saved queries Build: Save a query with a threshold and a window. Evaluate on a schedule. Fire once when the threshold is crossed and once when it clears, rather than every evaluation. Include enough context in the alert to decide without opening the interface. Trap: 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. Done when: An alert fires once when its threshold is crossed and once when it clears, verified by sustaining the condition through several evaluations. The alert includes the query, the count and a link. A flapping condition does not produce a message per evaluation. Alert delivery failures are retried and then recorded. Do not build yet: Operations come last. ### Phase 6 · Access, secrets in logs, and backups Build: Scope query access per source so a team sees its own services. Add redaction on ingest for patterns that look like credentials. Back up and restore. Then check what is actually in your logs. Trap: Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing. Done when: A user scoped to one source cannot query another's events, tested by direct request. Ingest redacts values matching configured credential patterns before storage, verified by sending one. A search of the existing store for known secret patterns has been run and its findings recorded. A restore reproduces events and compacted history. Do not build yet: Nothing after this. ### Out of scope (and why) - The query performance of a purpose-built columnar engine at large scale, which is the actual product and is years of engineering. - The integration catalogue that ships logs from every platform and service without you writing a shipper. - Managed operations, including keeping ingest available while you are asleep. - Retention at a scale where storage cost forces architectural choices, which is where the hosted pricing starts to make sense. ### README must contain - That limits are per source, so a runaway service cannot starve the others - Which fields are indexed and why the rest are compressed - That queries default to a recent window and report what they scanned - That retention compacts to counts and samples rather than deleting outright - That credential patterns are redacted on ingest, and the result of auditing your existing store ===== AGENTS.md ===== # Agent instructions · Axiom indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Go or Node 22 with PostgreSQL, A bounded HTTP endpoint with per-source token authentication, Structured columns for what you query, a compressed blob for the rest, Compaction to counts and samples before deletion, Saved queries evaluated on a schedule. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Axiom 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Limits before anything else A source that starts logging in a loop is refused rather than filling the disk. ### Steps 1. Authenticate per source A token per source, so limits and rejections attribute correctly. Files: `src/auth.go` 2. Limit rate and size per source Both. A single enormous event and a flood of small ones are different failures. Files: `src/limits.go` 3. Record rejections visibly Per source per minute. A silently rejecting source is a source whose operators think it is logging. Files: `src/stats.go` ### Done when - [ ] One flooding source is contained without affecting others - [ ] An oversized event is rejected naming the limit - [ ] Rejections are recorded per source - [ ] Ingest requires a valid token ### 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. ## Phase 2 · Storing what you will query Events are stored compactly and the fields you actually search are fast. ### Steps 1. Choose the indexed fields deliberately Time, level, service and a trace identifier cover most incident queries. Write down why. Files: `docs/schema.md` 2. Compress the rest One blob per event. Queryable slowly, which is the right cost for a rare query. Files: `src/store.go` 3. Generate ten million events and measure Bytes per event and query time. Every later claim depends on these numbers. Files: `scripts/generate.go` ### Done when - [ ] Ten million events store with measured bytes per event - [ ] An indexed query returns in under a second - [ ] An unindexed query completes slowly rather than failing - [ ] Timestamps are consistent across a clock change ### 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. ## Phase 3 · Queries you can run during an incident Finding the relevant events takes seconds when it matters. ### Steps 1. Build the query interface Time, level, service, text and trace. Five filters cover most of what an incident needs. Files: `src/query.go` 2. Default the time range narrow Recent by default. Widening is a deliberate action with the cost shown. Files: `src/query.go` 3. Report scan volume So an expensive query is visibly expensive rather than mysteriously slow. Files: `src/query.go` ### Done when - [ ] A trace lookup returns all events for one request in order - [ ] Free text over ten million events returns usably fast - [ ] The default time range is recent - [ ] Every query reports events scanned ### 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. ## Phase 4 · Retention that compacts rather than deletes Old data becomes small rather than absent. ### Steps 1. Compact to counts plus samples Hourly per service per level, with a bounded sample of full events. Files: `src/compact.go` 2. Delete originals only after compaction succeeds In one transaction, or the failure mode is losing both. Files: `src/compact.go` 3. Record every run What it processed and what it kept. The proof that retention happened. Files: `src/compact.go` ### Done when - [ ] Compaction reduces storage substantially - [ ] Historical error rates remain answerable - [ ] Retained samples stay queryable - [ ] Every compaction run is recorded ### 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. ## Phase 5 · Alerts as saved queries A pattern you care about notifies you once rather than continuously. ### Steps 1. Evaluate saved queries on a schedule One query language for searching and alerting keeps both honest. Files: `src/alerts.go` 2. Fire on transitions only Into and out of the condition. Not every evaluation. Files: `src/alerts.go` 3. Include decisive context Query, count, window and a link. Enough to judge without opening anything. Files: `src/notify.go` ### Done when - [ ] An alert fires once on crossing and once on clearing - [ ] The alert carries query, count and link - [ ] A sustained condition does not repeat per evaluation - [ ] A failed delivery is retried then recorded ### 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. ## Not in this build - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. ## After v1, if you want it - Structured field extraction from unstructured messages, which makes more of your logs queryable - A second index field, chosen from what your queries actually filter by after a month - Sampling of high volume debug events, keeping a proportion rather than rejecting outright ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/logs # Required. Per source. Overridable per source, and the containment for a runaway service. DEFAULT_RATE_LIMIT_PER_MINUTE=6000 # Required. Per event. A single enormous event is a different failure from a flood. MAX_EVENT_BYTES=32768 # Required. Total. Reaching it evicts oldest compacted data rather than refusing ingest. STORAGE_CEILING_GB=200 # Required. Age at which events compact to counts and samples. COMPACT_AFTER_DAYS=14 # Required. Default time range, so a casual query never scans everything. QUERY_DEFAULT_WINDOW_MINUTES=60 # Required. Credential patterns redacted on ingest, before storage. REDACT_PATTERNS=./config/redact.json
You are building a lean indie version of Axiom. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Axiom · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces | | Ingest | A bounded HTTP endpoint with per-source token authentication | the limits are per source, so sources must be identifiable | | Storage | Structured columns for what you query, a compressed blob for the rest | indexing every field of every event costs more than the events are worth | | Retention | Compaction to counts and samples before deletion | an aggregate of last quarter is far more useful than nothing and far cheaper than everything | | Alerting | Saved queries evaluated on a schedule | an alert is a query with a threshold, which keeps one language for both | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Go or Node.js 22 or newer** · free - 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 - [ ] **PostgreSQL 16 or newer** · free - 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. - Verify: psql connects and reports 16 or higher - [ ] **An estimate of your actual event volume** · free - 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 - [ ] **A day of real logs from a real service** · free - 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 - [ ] **Disk sized from the estimate with room to spare** · varies 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. - high-performance cloud storage, query engine, integrations, and managed operations - global probe network - phone and SMS delivery - massive retention - advanced incident response and support If one of those is essential to you, that is the reason to keep paying for Axiom, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Axiom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a log store that accepts bounded ingest, answers queries fast, and cannot be filled by a runaway process to replace Axiom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The failure that defines this category is a service logging in a loop. Within an hour it produces more events than a month of normal traffic, the disk fills, ingestion stops for everything, and the logs you needed to diagnose the loop are the ones you no longer have. So limits come first: a per-source rate cap, a per-event size cap and a total storage ceiling with an eviction policy, all built before any query interface exists. A log store that refuses excess traffic loudly is far more useful than one that accepts everything until it stops accepting anything. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces - Ingest: A bounded HTTP endpoint with per-source token authentication · the limits are per source, so sources must be identifiable - Storage: Structured columns for what you query, a compressed blob for the rest · indexing every field of every event costs more than the events are worth - Retention: Compaction to counts and samples before deletion · an aggregate of last quarter is far more useful than nothing and far cheaper than everything - Alerting: Saved queries evaluated on a schedule · an alert is a query with a threshold, which keeps one language for both ### Data model (create this before Phase 1) - 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. ### Phase 1 · Limits before anything else Build: An ingest endpoint requiring a per-source token. A rate limit per source per minute and a maximum event size. Reject excess with a clear response and record the rejection so the source's own operators can see it. Trap: 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. Done when: A source exceeding its rate limit is rejected while other sources continue unaffected, verified by flooding one while another logs normally. An oversized event is rejected with a clear response naming the limit. Rejections are recorded per source per minute. Ingest requires a valid token. Do not build yet: No storage layout, queries or alerts. ### Phase 2 · Storing what you will query Build: Decide which fields are indexed columns and which go into a compressed attribute blob. Store timestamps in a consistent zone. Measure bytes per event and query time for an indexed field against ten million events. Trap: 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. Done when: Ten million events are stored and the bytes per event are measured. A query filtering on an indexed field over that volume returns in well under a second. A query on an unindexed attribute completes, slowly, rather than failing. Timestamps are stored consistently and a query across a clock change returns the right events. Do not build yet: No query interface or retention yet. ### Phase 3 · Queries you can run during an incident Build: A query interface with time range, level, service and free text over the message, plus a trace identifier lookup that returns every event for one request. Default the time range to something recent so a query never accidentally scans everything. Trap: 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. Done when: A trace identifier lookup returns every event for that request across services, ordered by time. A free text query over ten million events returns in a usable time. The default time range is recent and widening it is explicit. Every query reports how many events it scanned. Do not build yet: No retention or alerts yet. ### Phase 4 · Retention that compacts rather than deletes Build: Compact events older than a threshold into per-service per-hour counts by level, with a small sample of full events retained. Delete the originals afterwards. Record what each compaction run did. Trap: 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. Done when: Compaction reduces storage substantially while preserving hourly counts by service and level. A question about whether an error rate is unusual can be answered against compacted data. Retained samples remain queryable. Each compaction run records what it processed and what it kept. Do not build yet: No alerts or operations yet. ### Phase 5 · Alerts as saved queries Build: Save a query with a threshold and a window. Evaluate on a schedule. Fire once when the threshold is crossed and once when it clears, rather than every evaluation. Include enough context in the alert to decide without opening the interface. Trap: 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. Done when: An alert fires once when its threshold is crossed and once when it clears, verified by sustaining the condition through several evaluations. The alert includes the query, the count and a link. A flapping condition does not produce a message per evaluation. Alert delivery failures are retried and then recorded. Do not build yet: Operations come last. ### Phase 6 · Access, secrets in logs, and backups Build: Scope query access per source so a team sees its own services. Add redaction on ingest for patterns that look like credentials. Back up and restore. Then check what is actually in your logs. Trap: Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing. Done when: A user scoped to one source cannot query another's events, tested by direct request. Ingest redacts values matching configured credential patterns before storage, verified by sending one. A search of the existing store for known secret patterns has been run and its findings recorded. A restore reproduces events and compacted history. Do not build yet: Nothing after this. ### Out of scope (and why) - The query performance of a purpose-built columnar engine at large scale, which is the actual product and is years of engineering. - The integration catalogue that ships logs from every platform and service without you writing a shipper. - Managed operations, including keeping ingest available while you are asleep. - Retention at a scale where storage cost forces architectural choices, which is where the hosted pricing starts to make sense. ### README must contain - That limits are per source, so a runaway service cannot starve the others - Which fields are indexed and why the rest are compressed - That queries default to a recent window and report what they scanned - That retention compacts to counts and samples rather than deleting outright - That credential patterns are redacted on ingest, and the result of auditing your existing store ===== AGENTS.md ===== # Agent instructions · Axiom indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Go or Node 22 with PostgreSQL, A bounded HTTP endpoint with per-source token authentication, Structured columns for what you query, a compressed blob for the rest, Compaction to counts and samples before deletion, Saved queries evaluated on a schedule. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Axiom 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Limits before anything else A source that starts logging in a loop is refused rather than filling the disk. ### Steps 1. Authenticate per source A token per source, so limits and rejections attribute correctly. Files: `src/auth.go` 2. Limit rate and size per source Both. A single enormous event and a flood of small ones are different failures. Files: `src/limits.go` 3. Record rejections visibly Per source per minute. A silently rejecting source is a source whose operators think it is logging. Files: `src/stats.go` ### Done when - [ ] One flooding source is contained without affecting others - [ ] An oversized event is rejected naming the limit - [ ] Rejections are recorded per source - [ ] Ingest requires a valid token ### 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. ## Phase 2 · Storing what you will query Events are stored compactly and the fields you actually search are fast. ### Steps 1. Choose the indexed fields deliberately Time, level, service and a trace identifier cover most incident queries. Write down why. Files: `docs/schema.md` 2. Compress the rest One blob per event. Queryable slowly, which is the right cost for a rare query. Files: `src/store.go` 3. Generate ten million events and measure Bytes per event and query time. Every later claim depends on these numbers. Files: `scripts/generate.go` ### Done when - [ ] Ten million events store with measured bytes per event - [ ] An indexed query returns in under a second - [ ] An unindexed query completes slowly rather than failing - [ ] Timestamps are consistent across a clock change ### 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. ## Phase 3 · Queries you can run during an incident Finding the relevant events takes seconds when it matters. ### Steps 1. Build the query interface Time, level, service, text and trace. Five filters cover most of what an incident needs. Files: `src/query.go` 2. Default the time range narrow Recent by default. Widening is a deliberate action with the cost shown. Files: `src/query.go` 3. Report scan volume So an expensive query is visibly expensive rather than mysteriously slow. Files: `src/query.go` ### Done when - [ ] A trace lookup returns all events for one request in order - [ ] Free text over ten million events returns usably fast - [ ] The default time range is recent - [ ] Every query reports events scanned ### 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. ## Phase 4 · Retention that compacts rather than deletes Old data becomes small rather than absent. ### Steps 1. Compact to counts plus samples Hourly per service per level, with a bounded sample of full events. Files: `src/compact.go` 2. Delete originals only after compaction succeeds In one transaction, or the failure mode is losing both. Files: `src/compact.go` 3. Record every run What it processed and what it kept. The proof that retention happened. Files: `src/compact.go` ### Done when - [ ] Compaction reduces storage substantially - [ ] Historical error rates remain answerable - [ ] Retained samples stay queryable - [ ] Every compaction run is recorded ### 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. ## Phase 5 · Alerts as saved queries A pattern you care about notifies you once rather than continuously. ### Steps 1. Evaluate saved queries on a schedule One query language for searching and alerting keeps both honest. Files: `src/alerts.go` 2. Fire on transitions only Into and out of the condition. Not every evaluation. Files: `src/alerts.go` 3. Include decisive context Query, count, window and a link. Enough to judge without opening anything. Files: `src/notify.go` ### Done when - [ ] An alert fires once on crossing and once on clearing - [ ] The alert carries query, count and link - [ ] A sustained condition does not repeat per evaluation - [ ] A failed delivery is retried then recorded ### 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. ## Not in this build - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. ## After v1, if you want it - Structured field extraction from unstructured messages, which makes more of your logs queryable - A second index field, chosen from what your queries actually filter by after a month - Sampling of high volume debug events, keeping a proportion rather than rejecting outright ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/logs # Required. Per source. Overridable per source, and the containment for a runaway service. DEFAULT_RATE_LIMIT_PER_MINUTE=6000 # Required. Per event. A single enormous event is a different failure from a flood. MAX_EVENT_BYTES=32768 # Required. Total. Reaching it evicts oldest compacted data rather than refusing ingest. STORAGE_CEILING_GB=200 # Required. Age at which events compact to counts and samples. COMPACT_AFTER_DAYS=14 # Required. Default time range, so a casual query never scans everything. QUERY_DEFAULT_WINDOW_MINUTES=60 # Required. Credential patterns redacted on ingest, before storage. REDACT_PATTERNS=./config/redact.json
You are building a production product version of Axiom. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Axiom · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Axiom, ingest bounded logs, search them, and retain a compact local event store. The hard boundary is high-performance cloud storage, query engine, integrations, and managed operations, plus independent infrastructure and reliable alerting. ## Product outcome A log store that survives the incident it exists for, answers a trace lookup in seconds, keeps the shape of history after the detail is gone, and does not quietly accumulate credentials. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - server outside the monitored failure domain - PostgreSQL - optional ClickHouse - email or webhook destination - public HTTPS ## Explicit non-goals for v1 - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. - high-performance cloud storage, query engine, integrations, and managed operations - global probe network - phone and SMS delivery - massive retention - advanced incident response and support ## Success criteria - A flooding source is confirmed contained without affecting others - An indexed query over ten million events is confirmed under a second - Compaction is confirmed to preserve historical rates - An alert is confirmed to fire once per transition - A search of the store for known secret patterns has been run ===== BRIEF.md ===== # Build brief · Axiom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a log store that accepts bounded ingest, answers queries fast, and cannot be filled by a runaway process to replace Axiom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The failure that defines this category is a service logging in a loop. Within an hour it produces more events than a month of normal traffic, the disk fills, ingestion stops for everything, and the logs you needed to diagnose the loop are the ones you no longer have. So limits come first: a per-source rate cap, a per-event size cap and a total storage ceiling with an eviction policy, all built before any query interface exists. A log store that refuses excess traffic loudly is far more useful than one that accepts everything until it stops accepting anything. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces - Ingest: A bounded HTTP endpoint with per-source token authentication · the limits are per source, so sources must be identifiable - Storage: Structured columns for what you query, a compressed blob for the rest · indexing every field of every event costs more than the events are worth - Retention: Compaction to counts and samples before deletion · an aggregate of last quarter is far more useful than nothing and far cheaper than everything - Alerting: Saved queries evaluated on a schedule · an alert is a query with a threshold, which keeps one language for both ### Data model (create this before Phase 1) - 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. ### Phase 1 · Limits before anything else Build: An ingest endpoint requiring a per-source token. A rate limit per source per minute and a maximum event size. Reject excess with a clear response and record the rejection so the source's own operators can see it. Trap: 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. Done when: A source exceeding its rate limit is rejected while other sources continue unaffected, verified by flooding one while another logs normally. An oversized event is rejected with a clear response naming the limit. Rejections are recorded per source per minute. Ingest requires a valid token. Do not build yet: No storage layout, queries or alerts. ### Phase 2 · Storing what you will query Build: Decide which fields are indexed columns and which go into a compressed attribute blob. Store timestamps in a consistent zone. Measure bytes per event and query time for an indexed field against ten million events. Trap: 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. Done when: Ten million events are stored and the bytes per event are measured. A query filtering on an indexed field over that volume returns in well under a second. A query on an unindexed attribute completes, slowly, rather than failing. Timestamps are stored consistently and a query across a clock change returns the right events. Do not build yet: No query interface or retention yet. ### Phase 3 · Queries you can run during an incident Build: A query interface with time range, level, service and free text over the message, plus a trace identifier lookup that returns every event for one request. Default the time range to something recent so a query never accidentally scans everything. Trap: 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. Done when: A trace identifier lookup returns every event for that request across services, ordered by time. A free text query over ten million events returns in a usable time. The default time range is recent and widening it is explicit. Every query reports how many events it scanned. Do not build yet: No retention or alerts yet. ### Phase 4 · Retention that compacts rather than deletes Build: Compact events older than a threshold into per-service per-hour counts by level, with a small sample of full events retained. Delete the originals afterwards. Record what each compaction run did. Trap: 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. Done when: Compaction reduces storage substantially while preserving hourly counts by service and level. A question about whether an error rate is unusual can be answered against compacted data. Retained samples remain queryable. Each compaction run records what it processed and what it kept. Do not build yet: No alerts or operations yet. ### Phase 5 · Alerts as saved queries Build: Save a query with a threshold and a window. Evaluate on a schedule. Fire once when the threshold is crossed and once when it clears, rather than every evaluation. Include enough context in the alert to decide without opening the interface. Trap: 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. Done when: An alert fires once when its threshold is crossed and once when it clears, verified by sustaining the condition through several evaluations. The alert includes the query, the count and a link. A flapping condition does not produce a message per evaluation. Alert delivery failures are retried and then recorded. Do not build yet: Operations come last. ### Phase 6 · Access, secrets in logs, and backups Build: Scope query access per source so a team sees its own services. Add redaction on ingest for patterns that look like credentials. Back up and restore. Then check what is actually in your logs. Trap: Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing. Done when: A user scoped to one source cannot query another's events, tested by direct request. Ingest redacts values matching configured credential patterns before storage, verified by sending one. A search of the existing store for known secret patterns has been run and its findings recorded. A restore reproduces events and compacted history. Do not build yet: Nothing after this. ### Out of scope (and why) - The query performance of a purpose-built columnar engine at large scale, which is the actual product and is years of engineering. - The integration catalogue that ships logs from every platform and service without you writing a shipper. - Managed operations, including keeping ingest available while you are asleep. - Retention at a scale where storage cost forces architectural choices, which is where the hosted pricing starts to make sense. ### README must contain - That limits are per source, so a runaway service cannot starve the others - Which fields are indexed and why the rest are compressed - That queries default to a recent window and report what they scanned - That retention compacts to counts and samples rather than deleting outright - That credential patterns are redacted on ingest, and the result of auditing your existing store ===== ARCHITECTURE.md ===== # Architecture · Axiom ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces | | Ingest | A bounded HTTP endpoint with per-source token authentication | the limits are per source, so sources must be identifiable | | Storage | Structured columns for what you query, a compressed blob for the rest | indexing every field of every event costs more than the events are worth | | Retention | Compaction to counts and samples before deletion | an aggregate of last quarter is far more useful than nothing and far cheaper than everything | | Alerting | Saved queries evaluated on a schedule | an alert is a query with a threshold, which keeps one language for both | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Limits | Per-source rate, size and storage ceilings. | Not swappable for global limits. Containment per source is what stops one service starving the rest. | | Store | Indexed fields and compressed attributes. | A columnar engine is the upgrade at larger scale. The deliberate index list survives the move. | | Query | Filters, trace lookup and scan reporting. | Any interface, provided the default window stays narrow and cost stays visible. | | Compaction | Turning detail into shape. | Any aggregation, provided originals are deleted only after it succeeds. | | Redaction | Keeping credentials out. | Patterns grow over time. Redacting on ingest rather than on read is the property to keep. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Your PostgreSQL connection string. - `DEFAULT_RATE_LIMIT_PER_MINUTE` · required · Per source. Overridable per source, and the containment for a runaway service. - `MAX_EVENT_BYTES` · required · Per event. A single enormous event is a different failure from a flood. - `STORAGE_CEILING_GB` · required · Total. Reaching it evicts oldest compacted data rather than refusing ingest. - `COMPACT_AFTER_DAYS` · required · Age at which events compact to counts and samples. - `QUERY_DEFAULT_WINDOW_MINUTES` · required · Default time range, so a casual query never scans everything. - `REDACT_PATTERNS` · required · Credential patterns redacted on ingest, before storage. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Axiom product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Go or Node 22 with PostgreSQL, A bounded HTTP endpoint with per-source token authentication, Structured columns for what you query, a compressed blob for the rest, Compaction to counts and samples before deletion, Saved queries evaluated on a schedule. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. - Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing. ===== MILESTONES.md ===== # Delivery milestones · Axiom Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Limits before anything else A source that starts logging in a loop is refused rather than filling the disk. ### Steps 1. Authenticate per source A token per source, so limits and rejections attribute correctly. Files: `src/auth.go` 2. Limit rate and size per source Both. A single enormous event and a flood of small ones are different failures. Files: `src/limits.go` 3. Record rejections visibly Per source per minute. A silently rejecting source is a source whose operators think it is logging. Files: `src/stats.go` ### Done when - [ ] One flooding source is contained without affecting others - [ ] An oversized event is rejected naming the limit - [ ] Rejections are recorded per source - [ ] Ingest requires a valid token ### 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. ## M2 · Storing what you will query Events are stored compactly and the fields you actually search are fast. ### Steps 1. Choose the indexed fields deliberately Time, level, service and a trace identifier cover most incident queries. Write down why. Files: `docs/schema.md` 2. Compress the rest One blob per event. Queryable slowly, which is the right cost for a rare query. Files: `src/store.go` 3. Generate ten million events and measure Bytes per event and query time. Every later claim depends on these numbers. Files: `scripts/generate.go` ### Done when - [ ] Ten million events store with measured bytes per event - [ ] An indexed query returns in under a second - [ ] An unindexed query completes slowly rather than failing - [ ] Timestamps are consistent across a clock change ### 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. ## M3 · Queries you can run during an incident Finding the relevant events takes seconds when it matters. ### Steps 1. Build the query interface Time, level, service, text and trace. Five filters cover most of what an incident needs. Files: `src/query.go` 2. Default the time range narrow Recent by default. Widening is a deliberate action with the cost shown. Files: `src/query.go` 3. Report scan volume So an expensive query is visibly expensive rather than mysteriously slow. Files: `src/query.go` ### Done when - [ ] A trace lookup returns all events for one request in order - [ ] Free text over ten million events returns usably fast - [ ] The default time range is recent - [ ] Every query reports events scanned ### 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. ## M4 · Retention that compacts rather than deletes Old data becomes small rather than absent. ### Steps 1. Compact to counts plus samples Hourly per service per level, with a bounded sample of full events. Files: `src/compact.go` 2. Delete originals only after compaction succeeds In one transaction, or the failure mode is losing both. Files: `src/compact.go` 3. Record every run What it processed and what it kept. The proof that retention happened. Files: `src/compact.go` ### Done when - [ ] Compaction reduces storage substantially - [ ] Historical error rates remain answerable - [ ] Retained samples stay queryable - [ ] Every compaction run is recorded ### 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. ## M5 · Alerts as saved queries A pattern you care about notifies you once rather than continuously. ### Steps 1. Evaluate saved queries on a schedule One query language for searching and alerting keeps both honest. Files: `src/alerts.go` 2. Fire on transitions only Into and out of the condition. Not every evaluation. Files: `src/alerts.go` 3. Include decisive context Query, count, window and a link. Enough to judge without opening anything. Files: `src/notify.go` ### Done when - [ ] An alert fires once on crossing and once on clearing - [ ] The alert carries query, count and link - [ ] A sustained condition does not repeat per evaluation - [ ] A failed delivery is retried then recorded ### 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. ## M6 · Access, secrets in logs, and backups (production only) Logs are readable by the right people and do not contain what they should not. ### Steps 1. Scope access per source Enforced in the query rather than the interface. Test with a direct request. Files: `src/access.go` 2. Redact on ingest Before storage, for patterns that look like credentials. After storage is too late. Files: `src/redact.go` 3. Audit what you already store Search for known secret formats. The results are usually surprising and worth acting on. Files: `scripts/audit.sh` ### Done when - [ ] A scoped user cannot query another source - [ ] Credential patterns are redacted before storage - [ ] An audit of the existing store has been run and recorded - [ ] A restore reproduces events and compacted history ### Watch out - Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing. ===== OPERATIONS.md ===== # Operations · Axiom ## Backup Compacted history is small and valuable, recent events are large and shorter lived. Back up the compacted data and the configuration religiously and treat recent events as more replaceable, which inverts the usual instinct. ## Restore Restore and run a trace lookup and a historical rate query. The second is what proves the compacted history survived, which is the part you would actually miss. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Ingest rejection rate per source, storage against the ceiling, query latency percentiles and compaction run success. A rising rejection rate on one source is usually the first sign of a loop, before anyone notices the service itself. ## Incident checklist If ingest stops, check the storage ceiling before anything else, since that is the designed refusal. If queries slow during an incident, the default window has probably been widened by someone; showing scan volume is what makes that visible. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A flooding source is confirmed contained without affecting others - [ ] An indexed query over ten million events is confirmed under a second - [ ] Compaction is confirmed to preserve historical rates - [ ] An alert is confirmed to fire once per transition - [ ] A search of the store for known secret patterns has been run ## Launch constraint Do not market omitted Axiom capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/logs # Required. Per source. Overridable per source, and the containment for a runaway service. DEFAULT_RATE_LIMIT_PER_MINUTE=6000 # Required. Per event. A single enormous event is a different failure from a flood. MAX_EVENT_BYTES=32768 # Required. Total. Reaching it evicts oldest compacted data rather than refusing ingest. STORAGE_CEILING_GB=200 # Required. Age at which events compact to counts and samples. COMPACT_AFTER_DAYS=14 # Required. Default time range, so a casual query never scans everything. QUERY_DEFAULT_WINDOW_MINUTES=60 # Required. Credential patterns redacted on ingest, before storage. REDACT_PATTERNS=./config/redact.json
# Axiom · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces | | Ingest | A bounded HTTP endpoint with per-source token authentication | the limits are per source, so sources must be identifiable | | Storage | Structured columns for what you query, a compressed blob for the rest | indexing every field of every event costs more than the events are worth | | Retention | Compaction to counts and samples before deletion | an aggregate of last quarter is far more useful than nothing and far cheaper than everything | | Alerting | Saved queries evaluated on a schedule | an alert is a query with a threshold, which keeps one language for both | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Go or Node.js 22 or newer** · free - 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 - [ ] **PostgreSQL 16 or newer** · free - 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. - Verify: psql connects and reports 16 or higher - [ ] **An estimate of your actual event volume** · free - 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 - [ ] **A day of real logs from a real service** · free - 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 - [ ] **Disk sized from the estimate with room to spare** · varies 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. - high-performance cloud storage, query engine, integrations, and managed operations - global probe network - phone and SMS delivery - massive retention - advanced incident response and support If one of those is essential to you, that is the reason to keep paying for Axiom, and the README should say so rather than pretend.
# Build brief · Axiom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a log store that accepts bounded ingest, answers queries fast, and cannot be filled by a runaway process to replace Axiom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The failure that defines this category is a service logging in a loop. Within an hour it produces more events than a month of normal traffic, the disk fills, ingestion stops for everything, and the logs you needed to diagnose the loop are the ones you no longer have. So limits come first: a per-source rate cap, a per-event size cap and a total storage ceiling with an eviction policy, all built before any query interface exists. A log store that refuses excess traffic loudly is far more useful than one that accepts everything until it stops accepting anything. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces - Ingest: A bounded HTTP endpoint with per-source token authentication · the limits are per source, so sources must be identifiable - Storage: Structured columns for what you query, a compressed blob for the rest · indexing every field of every event costs more than the events are worth - Retention: Compaction to counts and samples before deletion · an aggregate of last quarter is far more useful than nothing and far cheaper than everything - Alerting: Saved queries evaluated on a schedule · an alert is a query with a threshold, which keeps one language for both ### Data model (create this before Phase 1) - 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. ### Phase 1 · Limits before anything else Build: An ingest endpoint requiring a per-source token. A rate limit per source per minute and a maximum event size. Reject excess with a clear response and record the rejection so the source's own operators can see it. Trap: 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. Done when: A source exceeding its rate limit is rejected while other sources continue unaffected, verified by flooding one while another logs normally. An oversized event is rejected with a clear response naming the limit. Rejections are recorded per source per minute. Ingest requires a valid token. Do not build yet: No storage layout, queries or alerts. ### Phase 2 · Storing what you will query Build: Decide which fields are indexed columns and which go into a compressed attribute blob. Store timestamps in a consistent zone. Measure bytes per event and query time for an indexed field against ten million events. Trap: 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. Done when: Ten million events are stored and the bytes per event are measured. A query filtering on an indexed field over that volume returns in well under a second. A query on an unindexed attribute completes, slowly, rather than failing. Timestamps are stored consistently and a query across a clock change returns the right events. Do not build yet: No query interface or retention yet. ### Phase 3 · Queries you can run during an incident Build: A query interface with time range, level, service and free text over the message, plus a trace identifier lookup that returns every event for one request. Default the time range to something recent so a query never accidentally scans everything. Trap: 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. Done when: A trace identifier lookup returns every event for that request across services, ordered by time. A free text query over ten million events returns in a usable time. The default time range is recent and widening it is explicit. Every query reports how many events it scanned. Do not build yet: No retention or alerts yet. ### Phase 4 · Retention that compacts rather than deletes Build: Compact events older than a threshold into per-service per-hour counts by level, with a small sample of full events retained. Delete the originals afterwards. Record what each compaction run did. Trap: 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. Done when: Compaction reduces storage substantially while preserving hourly counts by service and level. A question about whether an error rate is unusual can be answered against compacted data. Retained samples remain queryable. Each compaction run records what it processed and what it kept. Do not build yet: No alerts or operations yet. ### Phase 5 · Alerts as saved queries Build: Save a query with a threshold and a window. Evaluate on a schedule. Fire once when the threshold is crossed and once when it clears, rather than every evaluation. Include enough context in the alert to decide without opening the interface. Trap: 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. Done when: An alert fires once when its threshold is crossed and once when it clears, verified by sustaining the condition through several evaluations. The alert includes the query, the count and a link. A flapping condition does not produce a message per evaluation. Alert delivery failures are retried and then recorded. Do not build yet: Operations come last. ### Phase 6 · Access, secrets in logs, and backups Build: Scope query access per source so a team sees its own services. Add redaction on ingest for patterns that look like credentials. Back up and restore. Then check what is actually in your logs. Trap: Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing. Done when: A user scoped to one source cannot query another's events, tested by direct request. Ingest redacts values matching configured credential patterns before storage, verified by sending one. A search of the existing store for known secret patterns has been run and its findings recorded. A restore reproduces events and compacted history. Do not build yet: Nothing after this. ### Out of scope (and why) - The query performance of a purpose-built columnar engine at large scale, which is the actual product and is years of engineering. - The integration catalogue that ships logs from every platform and service without you writing a shipper. - Managed operations, including keeping ingest available while you are asleep. - Retention at a scale where storage cost forces architectural choices, which is where the hosted pricing starts to make sense. ### README must contain - That limits are per source, so a runaway service cannot starve the others - Which fields are indexed and why the rest are compressed - That queries default to a recent window and report what they scanned - That retention compacts to counts and samples rather than deleting outright - That credential patterns are redacted on ingest, and the result of auditing your existing store
# Agent instructions · Axiom indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Go or Node 22 with PostgreSQL, A bounded HTTP endpoint with per-source token authentication, Structured columns for what you query, a compressed blob for the rest, Compaction to counts and samples before deletion, Saved queries evaluated on a schedule. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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.
# Build plan · Axiom 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Limits before anything else A source that starts logging in a loop is refused rather than filling the disk. ### Steps 1. Authenticate per source A token per source, so limits and rejections attribute correctly. Files: `src/auth.go` 2. Limit rate and size per source Both. A single enormous event and a flood of small ones are different failures. Files: `src/limits.go` 3. Record rejections visibly Per source per minute. A silently rejecting source is a source whose operators think it is logging. Files: `src/stats.go` ### Done when - [ ] One flooding source is contained without affecting others - [ ] An oversized event is rejected naming the limit - [ ] Rejections are recorded per source - [ ] Ingest requires a valid token ### 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. ## Phase 2 · Storing what you will query Events are stored compactly and the fields you actually search are fast. ### Steps 1. Choose the indexed fields deliberately Time, level, service and a trace identifier cover most incident queries. Write down why. Files: `docs/schema.md` 2. Compress the rest One blob per event. Queryable slowly, which is the right cost for a rare query. Files: `src/store.go` 3. Generate ten million events and measure Bytes per event and query time. Every later claim depends on these numbers. Files: `scripts/generate.go` ### Done when - [ ] Ten million events store with measured bytes per event - [ ] An indexed query returns in under a second - [ ] An unindexed query completes slowly rather than failing - [ ] Timestamps are consistent across a clock change ### 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. ## Phase 3 · Queries you can run during an incident Finding the relevant events takes seconds when it matters. ### Steps 1. Build the query interface Time, level, service, text and trace. Five filters cover most of what an incident needs. Files: `src/query.go` 2. Default the time range narrow Recent by default. Widening is a deliberate action with the cost shown. Files: `src/query.go` 3. Report scan volume So an expensive query is visibly expensive rather than mysteriously slow. Files: `src/query.go` ### Done when - [ ] A trace lookup returns all events for one request in order - [ ] Free text over ten million events returns usably fast - [ ] The default time range is recent - [ ] Every query reports events scanned ### 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. ## Phase 4 · Retention that compacts rather than deletes Old data becomes small rather than absent. ### Steps 1. Compact to counts plus samples Hourly per service per level, with a bounded sample of full events. Files: `src/compact.go` 2. Delete originals only after compaction succeeds In one transaction, or the failure mode is losing both. Files: `src/compact.go` 3. Record every run What it processed and what it kept. The proof that retention happened. Files: `src/compact.go` ### Done when - [ ] Compaction reduces storage substantially - [ ] Historical error rates remain answerable - [ ] Retained samples stay queryable - [ ] Every compaction run is recorded ### 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. ## Phase 5 · Alerts as saved queries A pattern you care about notifies you once rather than continuously. ### Steps 1. Evaluate saved queries on a schedule One query language for searching and alerting keeps both honest. Files: `src/alerts.go` 2. Fire on transitions only Into and out of the condition. Not every evaluation. Files: `src/alerts.go` 3. Include decisive context Query, count, window and a link. Enough to judge without opening anything. Files: `src/notify.go` ### Done when - [ ] An alert fires once on crossing and once on clearing - [ ] The alert carries query, count and link - [ ] A sustained condition does not repeat per evaluation - [ ] A failed delivery is retried then recorded ### 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. ## Not in this build - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. ## After v1, if you want it - Structured field extraction from unstructured messages, which makes more of your logs queryable - A second index field, chosen from what your queries actually filter by after a month - Sampling of high volume debug events, keeping a proportion rather than rejecting outright
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/logs # Required. Per source. Overridable per source, and the containment for a runaway service. DEFAULT_RATE_LIMIT_PER_MINUTE=6000 # Required. Per event. A single enormous event is a different failure from a flood. MAX_EVENT_BYTES=32768 # Required. Total. Reaching it evicts oldest compacted data rather than refusing ingest. STORAGE_CEILING_GB=200 # Required. Age at which events compact to counts and samples. COMPACT_AFTER_DAYS=14 # Required. Default time range, so a casual query never scans everything. QUERY_DEFAULT_WINDOW_MINUTES=60 # Required. Credential patterns redacted on ingest, before storage. REDACT_PATTERNS=./config/redact.json
# Axiom · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Axiom, ingest bounded logs, search them, and retain a compact local event store. The hard boundary is high-performance cloud storage, query engine, integrations, and managed operations, plus independent infrastructure and reliable alerting. ## Product outcome A log store that survives the incident it exists for, answers a trace lookup in seconds, keeps the shape of history after the detail is gone, and does not quietly accumulate credentials. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - server outside the monitored failure domain - PostgreSQL - optional ClickHouse - email or webhook destination - public HTTPS ## Explicit non-goals for v1 - Columnar query performance at large scale. - The integration catalogue of log shippers. - Managed operations and availability. - Retention at a scale where storage cost drives the architecture. - high-performance cloud storage, query engine, integrations, and managed operations - global probe network - phone and SMS delivery - massive retention - advanced incident response and support ## Success criteria - A flooding source is confirmed contained without affecting others - An indexed query over ten million events is confirmed under a second - Compaction is confirmed to preserve historical rates - An alert is confirmed to fire once per transition - A search of the store for known secret patterns has been run
# Build brief · Axiom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a log store that accepts bounded ingest, answers queries fast, and cannot be filled by a runaway process to replace Axiom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The failure that defines this category is a service logging in a loop. Within an hour it produces more events than a month of normal traffic, the disk fills, ingestion stops for everything, and the logs you needed to diagnose the loop are the ones you no longer have. So limits come first: a per-source rate cap, a per-event size cap and a total storage ceiling with an eviction policy, all built before any query interface exists. A log store that refuses excess traffic loudly is far more useful than one that accepts everything until it stops accepting anything. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces - Ingest: A bounded HTTP endpoint with per-source token authentication · the limits are per source, so sources must be identifiable - Storage: Structured columns for what you query, a compressed blob for the rest · indexing every field of every event costs more than the events are worth - Retention: Compaction to counts and samples before deletion · an aggregate of last quarter is far more useful than nothing and far cheaper than everything - Alerting: Saved queries evaluated on a schedule · an alert is a query with a threshold, which keeps one language for both ### Data model (create this before Phase 1) - 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. ### Phase 1 · Limits before anything else Build: An ingest endpoint requiring a per-source token. A rate limit per source per minute and a maximum event size. Reject excess with a clear response and record the rejection so the source's own operators can see it. Trap: 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. Done when: A source exceeding its rate limit is rejected while other sources continue unaffected, verified by flooding one while another logs normally. An oversized event is rejected with a clear response naming the limit. Rejections are recorded per source per minute. Ingest requires a valid token. Do not build yet: No storage layout, queries or alerts. ### Phase 2 · Storing what you will query Build: Decide which fields are indexed columns and which go into a compressed attribute blob. Store timestamps in a consistent zone. Measure bytes per event and query time for an indexed field against ten million events. Trap: 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. Done when: Ten million events are stored and the bytes per event are measured. A query filtering on an indexed field over that volume returns in well under a second. A query on an unindexed attribute completes, slowly, rather than failing. Timestamps are stored consistently and a query across a clock change returns the right events. Do not build yet: No query interface or retention yet. ### Phase 3 · Queries you can run during an incident Build: A query interface with time range, level, service and free text over the message, plus a trace identifier lookup that returns every event for one request. Default the time range to something recent so a query never accidentally scans everything. Trap: 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. Done when: A trace identifier lookup returns every event for that request across services, ordered by time. A free text query over ten million events returns in a usable time. The default time range is recent and widening it is explicit. Every query reports how many events it scanned. Do not build yet: No retention or alerts yet. ### Phase 4 · Retention that compacts rather than deletes Build: Compact events older than a threshold into per-service per-hour counts by level, with a small sample of full events retained. Delete the originals afterwards. Record what each compaction run did. Trap: 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. Done when: Compaction reduces storage substantially while preserving hourly counts by service and level. A question about whether an error rate is unusual can be answered against compacted data. Retained samples remain queryable. Each compaction run records what it processed and what it kept. Do not build yet: No alerts or operations yet. ### Phase 5 · Alerts as saved queries Build: Save a query with a threshold and a window. Evaluate on a schedule. Fire once when the threshold is crossed and once when it clears, rather than every evaluation. Include enough context in the alert to decide without opening the interface. Trap: 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. Done when: An alert fires once when its threshold is crossed and once when it clears, verified by sustaining the condition through several evaluations. The alert includes the query, the count and a link. A flapping condition does not produce a message per evaluation. Alert delivery failures are retried and then recorded. Do not build yet: Operations come last. ### Phase 6 · Access, secrets in logs, and backups Build: Scope query access per source so a team sees its own services. Add redaction on ingest for patterns that look like credentials. Back up and restore. Then check what is actually in your logs. Trap: Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing. Done when: A user scoped to one source cannot query another's events, tested by direct request. Ingest redacts values matching configured credential patterns before storage, verified by sending one. A search of the existing store for known secret patterns has been run and its findings recorded. A restore reproduces events and compacted history. Do not build yet: Nothing after this. ### Out of scope (and why) - The query performance of a purpose-built columnar engine at large scale, which is the actual product and is years of engineering. - The integration catalogue that ships logs from every platform and service without you writing a shipper. - Managed operations, including keeping ingest available while you are asleep. - Retention at a scale where storage cost forces architectural choices, which is where the hosted pricing starts to make sense. ### README must contain - That limits are per source, so a runaway service cannot starve the others - Which fields are indexed and why the rest are compressed - That queries default to a recent window and report what they scanned - That retention compacts to counts and samples rather than deleting outright - That credential patterns are redacted on ingest, and the result of auditing your existing store
# Architecture · Axiom ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | ingest is a write-heavy path and queries are analytical, and one database handles both at the volume one team produces | | Ingest | A bounded HTTP endpoint with per-source token authentication | the limits are per source, so sources must be identifiable | | Storage | Structured columns for what you query, a compressed blob for the rest | indexing every field of every event costs more than the events are worth | | Retention | Compaction to counts and samples before deletion | an aggregate of last quarter is far more useful than nothing and far cheaper than everything | | Alerting | Saved queries evaluated on a schedule | an alert is a query with a threshold, which keeps one language for both | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Limits | Per-source rate, size and storage ceilings. | Not swappable for global limits. Containment per source is what stops one service starving the rest. | | Store | Indexed fields and compressed attributes. | A columnar engine is the upgrade at larger scale. The deliberate index list survives the move. | | Query | Filters, trace lookup and scan reporting. | Any interface, provided the default window stays narrow and cost stays visible. | | Compaction | Turning detail into shape. | Any aggregation, provided originals are deleted only after it succeeds. | | Redaction | Keeping credentials out. | Patterns grow over time. Redacting on ingest rather than on read is the property to keep. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Your PostgreSQL connection string. - `DEFAULT_RATE_LIMIT_PER_MINUTE` · required · Per source. Overridable per source, and the containment for a runaway service. - `MAX_EVENT_BYTES` · required · Per event. A single enormous event is a different failure from a flood. - `STORAGE_CEILING_GB` · required · Total. Reaching it evicts oldest compacted data rather than refusing ingest. - `COMPACT_AFTER_DAYS` · required · Age at which events compact to counts and samples. - `QUERY_DEFAULT_WINDOW_MINUTES` · required · Default time range, so a casual query never scans everything. - `REDACT_PATTERNS` · required · Credential patterns redacted on ingest, before storage. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Axiom product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Go or Node 22 with PostgreSQL, A bounded HTTP endpoint with per-source token authentication, Structured columns for what you query, a compressed blob for the rest, Compaction to counts and samples before deletion, Saved queries evaluated on a schedule. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. - Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing.
# Delivery milestones · Axiom Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Limits before anything else A source that starts logging in a loop is refused rather than filling the disk. ### Steps 1. Authenticate per source A token per source, so limits and rejections attribute correctly. Files: `src/auth.go` 2. Limit rate and size per source Both. A single enormous event and a flood of small ones are different failures. Files: `src/limits.go` 3. Record rejections visibly Per source per minute. A silently rejecting source is a source whose operators think it is logging. Files: `src/stats.go` ### Done when - [ ] One flooding source is contained without affecting others - [ ] An oversized event is rejected naming the limit - [ ] Rejections are recorded per source - [ ] Ingest requires a valid token ### 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. ## M2 · Storing what you will query Events are stored compactly and the fields you actually search are fast. ### Steps 1. Choose the indexed fields deliberately Time, level, service and a trace identifier cover most incident queries. Write down why. Files: `docs/schema.md` 2. Compress the rest One blob per event. Queryable slowly, which is the right cost for a rare query. Files: `src/store.go` 3. Generate ten million events and measure Bytes per event and query time. Every later claim depends on these numbers. Files: `scripts/generate.go` ### Done when - [ ] Ten million events store with measured bytes per event - [ ] An indexed query returns in under a second - [ ] An unindexed query completes slowly rather than failing - [ ] Timestamps are consistent across a clock change ### 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. ## M3 · Queries you can run during an incident Finding the relevant events takes seconds when it matters. ### Steps 1. Build the query interface Time, level, service, text and trace. Five filters cover most of what an incident needs. Files: `src/query.go` 2. Default the time range narrow Recent by default. Widening is a deliberate action with the cost shown. Files: `src/query.go` 3. Report scan volume So an expensive query is visibly expensive rather than mysteriously slow. Files: `src/query.go` ### Done when - [ ] A trace lookup returns all events for one request in order - [ ] Free text over ten million events returns usably fast - [ ] The default time range is recent - [ ] Every query reports events scanned ### 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. ## M4 · Retention that compacts rather than deletes Old data becomes small rather than absent. ### Steps 1. Compact to counts plus samples Hourly per service per level, with a bounded sample of full events. Files: `src/compact.go` 2. Delete originals only after compaction succeeds In one transaction, or the failure mode is losing both. Files: `src/compact.go` 3. Record every run What it processed and what it kept. The proof that retention happened. Files: `src/compact.go` ### Done when - [ ] Compaction reduces storage substantially - [ ] Historical error rates remain answerable - [ ] Retained samples stay queryable - [ ] Every compaction run is recorded ### 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. ## M5 · Alerts as saved queries A pattern you care about notifies you once rather than continuously. ### Steps 1. Evaluate saved queries on a schedule One query language for searching and alerting keeps both honest. Files: `src/alerts.go` 2. Fire on transitions only Into and out of the condition. Not every evaluation. Files: `src/alerts.go` 3. Include decisive context Query, count, window and a link. Enough to judge without opening anything. Files: `src/notify.go` ### Done when - [ ] An alert fires once on crossing and once on clearing - [ ] The alert carries query, count and link - [ ] A sustained condition does not repeat per evaluation - [ ] A failed delivery is retried then recorded ### 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. ## M6 · Access, secrets in logs, and backups (production only) Logs are readable by the right people and do not contain what they should not. ### Steps 1. Scope access per source Enforced in the query rather than the interface. Test with a direct request. Files: `src/access.go` 2. Redact on ingest Before storage, for patterns that look like credentials. After storage is too late. Files: `src/redact.go` 3. Audit what you already store Search for known secret formats. The results are usually surprising and worth acting on. Files: `scripts/audit.sh` ### Done when - [ ] A scoped user cannot query another source - [ ] Credential patterns are redacted before storage - [ ] An audit of the existing store has been run and recorded - [ ] A restore reproduces events and compacted history ### Watch out - Application logs routinely contain tokens, session identifiers and personal data that nobody intended to log, and once ingested they are searchable by everyone with access. Redact on ingest for the obvious patterns, and grep your own store for a known secret format to find out what you are already storing.
# Operations · Axiom ## Backup Compacted history is small and valuable, recent events are large and shorter lived. Back up the compacted data and the configuration religiously and treat recent events as more replaceable, which inverts the usual instinct. ## Restore Restore and run a trace lookup and a historical rate query. The second is what proves the compacted history survived, which is the part you would actually miss. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Ingest rejection rate per source, storage against the ceiling, query latency percentiles and compaction run success. A rising rejection rate on one source is usually the first sign of a loop, before anyone notices the service itself. ## Incident checklist If ingest stops, check the storage ceiling before anything else, since that is the designed refusal. If queries slow during an incident, the default window has probably been widened by someone; showing scan volume is what makes that visible. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A flooding source is confirmed contained without affecting others - [ ] An indexed query over ten million events is confirmed under a second - [ ] Compaction is confirmed to preserve historical rates - [ ] An alert is confirmed to fire once per transition - [ ] A search of the store for known secret patterns has been run ## Launch constraint Do not market omitted Axiom capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/logs # Required. Per source. Overridable per source, and the containment for a runaway service. DEFAULT_RATE_LIMIT_PER_MINUTE=6000 # Required. Per event. A single enormous event is a different failure from a flood. MAX_EVENT_BYTES=32768 # Required. Total. Reaching it evicts oldest compacted data rather than refusing ingest. STORAGE_CEILING_GB=200 # Required. Age at which events compact to counts and samples. COMPACT_AFTER_DAYS=14 # Required. Default time range, so a casual query never scans everything. QUERY_DEFAULT_WINDOW_MINUTES=60 # Required. Credential patterns redacted on ingest, before storage. REDACT_PATTERNS=./config/redact.json
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Axiom because monitoring must continue working during the exact outage it reports, which makes independent infrastructure and alert delivery the real product. The recurring cost buys probe geography, clocks, retries, deduplication, sampling, storage, paging, notification delivery, on-call rules, and its own uptime, not just the visible interface.
xhigh-performance cloud storage, query engine, integrations, and managed operations
xglobal probe network
xphone and SMS delivery
xmassive retention
xadvanced incident response and support
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Axiom →· no votes, no pay-to-list · just what's real
Axiom pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| personal | $0/workspace | — | 500 GB/month loading; 10 GB-hours/month querying; 25 GB stored; 30-day retention; unlimited users and integrations |
| cloud | $25/workspace | — | 1 TB/month loading allowance; 100 GB-hours/month querying; 100 GB stored; configurable retention |
| enterprise | custom | — | Custom volume, support, security and compliance terms |
free tier500 GB telemetry loading/month; 10 GB-hours querying/month; 25 GB stored; 30-day retention; unlimited users and integrations
billingmonthly usage billing; no public annual plan shown; prepaid credits can discount usage and do not expire
hidden costsCloud is $25/month before metered telemetry usage; optional enterprise controls are separately priced, including SSO ($100/month), directory sync ($100/month), RBAC ($50/month) and audit log access ($50/month)
verified 2026-08-14 · source ↗
Vibecode Axiom
Kinda. The core of Axiom is buildable in a weekend with the prompt on this page, but there are real gaps: high-performance cloud storage, query engine, integrations, and managed operations, global probe network. Read the honest list above before committing.
How much does Axiom cost?
Axiom costs about $25/month (Personal, checked 2026-07-31), which is $300 per year.
What do I lose by replacing Axiom?
Honestly: high-performance cloud storage, query engine, integrations, and managed operations; global probe network; phone and SMS delivery; massive retention; advanced incident response and support. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Axiom?
Yes: OpenObserve (Logs, traces and metrics in one binary; your disk gets the invoice.) HyperDX (A searchable event store with traces and dashboards attached; ClickHouse does the lifting.) VictoriaLogs (A fast log store and query UI; metrics and traces are somebody else's problem.) All 3 curated free alternatives are at vibecodeit.com/axiom/alternatives. The prompt is for when you want it exactly your way.