diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2490ffa
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,78 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ vet:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
+ - run: go vet ./...
+ - name: gofmt
+ run: |
+ unformatted=$(gofmt -l .)
+ if [ -n "$unformatted" ]; then
+ echo "Run gofmt -w on:"; echo "$unformatted"; exit 1
+ fi
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
+ - uses: golangci/golangci-lint-action@v9
+ with:
+ version: v2.14.0
+
+ test:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: logengine
+ POSTGRES_PASSWORD: logengine
+ POSTGRES_DB: logengine
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U logengine"
+ --health-interval 2s
+ --health-timeout 5s
+ --health-retries 15
+ env:
+ PGTEST_DATABASE_URL: postgres://logengine:logengine@localhost:5432/logengine?sslmode=disable
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
+ - run: go test -race ./...
+
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: docker/setup-buildx-action@v4
+ # No QEMU: the build stage runs on $BUILDPLATFORM and cross-compiles,
+ # and the runtime stage has no RUN steps.
+ - uses: docker/build-push-action@v7
+ with:
+ context: .
+ file: deploy/docker/Dockerfile
+ platforms: linux/amd64,linux/arm64
+ push: false
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/DESIGN.md b/DESIGN.md
deleted file mode 100644
index 88bcd11..0000000
--- a/DESIGN.md
+++ /dev/null
@@ -1,548 +0,0 @@
----
-version: alpha
-name: Linear-design-analysis
-description: "A near-black product-focused marketing canvas built around #010102 (the deepest dark surface of any tool in this collection), light gray text (#f7f8f8), and the signature Linear lavender-blue (#5e6ad2) used as the single chromatic accent. The system reads as software-craft documentation: dense, technical, and quietly luxurious. Display type is set in the Linear custom sans (SF Pro Display fallback) at 500–700 with measured negative tracking. Cards live as charcoal panels (#0f1011) with hairline borders. The accent lavender appears on the brand mark, focus rings, and a few intentional CTAs — never decoratively. Page rhythm leans on product UI screenshots framed in dark panels rather than atmospheric color."
-
-colors:
- primary: "#5e6ad2"
- on-primary: "#ffffff"
- primary-hover: "#828fff"
- primary-focus: "#5e69d1"
- ink: "#f7f8f8"
- ink-muted: "#d0d6e0"
- ink-subtle: "#8a8f98"
- ink-tertiary: "#62666d"
- canvas: "#010102"
- surface-1: "#0f1011"
- surface-2: "#141516"
- surface-3: "#18191a"
- surface-4: "#191a1b"
- hairline: "#23252a"
- hairline-strong: "#34343a"
- hairline-tertiary: "#3e3e44"
- inverse-canvas: "#ffffff"
- inverse-surface-1: "#f5f6f6"
- inverse-surface-2: "#f6f7f7"
- inverse-ink: "#000000"
- brand-secure: "#7a7fad"
- semantic-success: "#27a644"
- semantic-overlay: "#000000"
-
-typography:
- display-xl:
- fontFamily: Linear Display
- fontSize: 80px
- fontWeight: 600
- lineHeight: 1.05
- letterSpacing: -3.0px
- display-lg:
- fontFamily: Linear Display
- fontSize: 56px
- fontWeight: 600
- lineHeight: 1.10
- letterSpacing: -1.8px
- display-md:
- fontFamily: Linear Display
- fontSize: 40px
- fontWeight: 600
- lineHeight: 1.15
- letterSpacing: -1.0px
- headline:
- fontFamily: Linear Display
- fontSize: 28px
- fontWeight: 600
- lineHeight: 1.20
- letterSpacing: -0.6px
- card-title:
- fontFamily: Linear Display
- fontSize: 22px
- fontWeight: 500
- lineHeight: 1.25
- letterSpacing: -0.4px
- subhead:
- fontFamily: Linear Display
- fontSize: 20px
- fontWeight: 400
- lineHeight: 1.40
- letterSpacing: -0.2px
- body-lg:
- fontFamily: Linear Text
- fontSize: 18px
- fontWeight: 400
- lineHeight: 1.50
- letterSpacing: -0.1px
- body:
- fontFamily: Linear Text
- fontSize: 16px
- fontWeight: 400
- lineHeight: 1.50
- letterSpacing: -0.05px
- body-sm:
- fontFamily: Linear Text
- fontSize: 14px
- fontWeight: 400
- lineHeight: 1.50
- letterSpacing: 0
- caption:
- fontFamily: Linear Text
- fontSize: 12px
- fontWeight: 400
- lineHeight: 1.40
- letterSpacing: 0
- button:
- fontFamily: Linear Text
- fontSize: 14px
- fontWeight: 500
- lineHeight: 1.20
- letterSpacing: 0
- eyebrow:
- fontFamily: Linear Text
- fontSize: 13px
- fontWeight: 500
- lineHeight: 1.30
- letterSpacing: 0.4px
- mono:
- fontFamily: Linear Mono
- fontSize: 13px
- fontWeight: 400
- lineHeight: 1.50
- letterSpacing: 0
-
-rounded:
- xs: 4px
- sm: 6px
- md: 8px
- lg: 12px
- xl: 16px
- xxl: 24px
- pill: 9999px
- full: 9999px
-
-spacing:
- xxs: 4px
- xs: 8px
- sm: 12px
- md: 16px
- lg: 24px
- xl: 32px
- xxl: 48px
- section: 96px
-
-components:
- button-primary:
- backgroundColor: "{colors.primary}"
- textColor: "{colors.on-primary}"
- typography: "{typography.button}"
- rounded: "{rounded.md}"
- padding: 8px 14px
- button-primary-pressed:
- backgroundColor: "{colors.primary-focus}"
- textColor: "{colors.on-primary}"
- typography: "{typography.button}"
- rounded: "{rounded.md}"
- button-primary-hover:
- backgroundColor: "{colors.primary-hover}"
- textColor: "{colors.on-primary}"
- typography: "{typography.button}"
- rounded: "{rounded.md}"
- button-secondary:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.button}"
- rounded: "{rounded.md}"
- padding: 8px 14px
- button-tertiary:
- backgroundColor: "{colors.canvas}"
- textColor: "{colors.ink}"
- typography: "{typography.button}"
- rounded: "{rounded.md}"
- padding: 8px 14px
- button-inverse:
- backgroundColor: "{colors.inverse-canvas}"
- textColor: "{colors.inverse-ink}"
- typography: "{typography.button}"
- rounded: "{rounded.md}"
- padding: 8px 14px
- pricing-card:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.body}"
- rounded: "{rounded.lg}"
- padding: 24px
- pricing-card-featured:
- backgroundColor: "{colors.surface-2}"
- textColor: "{colors.ink}"
- typography: "{typography.body}"
- rounded: "{rounded.lg}"
- padding: 24px
- feature-card:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.body}"
- rounded: "{rounded.lg}"
- padding: 24px
- product-screenshot-card:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.body}"
- rounded: "{rounded.xl}"
- padding: 24px
- testimonial-card:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.body-lg}"
- rounded: "{rounded.lg}"
- padding: 32px
- customer-logo-tile:
- backgroundColor: "{colors.canvas}"
- textColor: "{colors.ink-subtle}"
- typography: "{typography.caption}"
- rounded: "{rounded.xs}"
- padding: 16px
- text-input:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.body}"
- rounded: "{rounded.md}"
- padding: 8px 12px
- text-input-focused:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.body}"
- rounded: "{rounded.md}"
- padding: 8px 12px
- pricing-tab-default:
- backgroundColor: "{colors.canvas}"
- textColor: "{colors.ink-subtle}"
- typography: "{typography.button}"
- rounded: "{rounded.pill}"
- padding: 6px 14px
- pricing-tab-selected:
- backgroundColor: "{colors.surface-2}"
- textColor: "{colors.ink}"
- typography: "{typography.button}"
- rounded: "{rounded.pill}"
- padding: 6px 14px
- cta-banner:
- backgroundColor: "{colors.surface-1}"
- textColor: "{colors.ink}"
- typography: "{typography.headline}"
- rounded: "{rounded.lg}"
- padding: 48px
- changelog-row:
- backgroundColor: "{colors.canvas}"
- textColor: "{colors.ink}"
- typography: "{typography.body}"
- rounded: "{rounded.xs}"
- padding: 24px 0
- status-badge:
- backgroundColor: "{colors.surface-2}"
- textColor: "{colors.ink-muted}"
- typography: "{typography.caption}"
- rounded: "{rounded.pill}"
- padding: 2px 8px
- top-nav:
- backgroundColor: "{colors.canvas}"
- textColor: "{colors.ink}"
- typography: "{typography.body-sm}"
- rounded: "{rounded.xs}"
- height: 56px
- footer:
- backgroundColor: "{colors.canvas}"
- textColor: "{colors.ink-subtle}"
- typography: "{typography.caption}"
- rounded: "{rounded.xs}"
- padding: 64px 32px
----
-
-## Overview
-
-Linear's marketing canvas is the deepest dark surface in this collection — `{colors.canvas}` is #010102, essentially pure black with a faint blue tint. On top sits a four-step surface ladder (`{colors.surface-1}` through `{colors.surface-4}`) for cards, panels, and lifted tiles, with hairline borders running from `{colors.hairline}` (#23252a) up through `{colors.hairline-strong}` and `{colors.hairline-tertiary}`. Light gray text (`{colors.ink}` #f7f8f8) carries the body and headlines.
-
-The single chromatic accent is **Linear lavender-blue** `{colors.primary}` (#5e6ad2) — used on the brand mark, focus rings, and the primary CTA button. A lighter hover state (`{colors.primary-hover}` #828fff) and a focus-tinted variant (`{colors.primary-focus}` #5e69d1) extend the same hue. Linear avoids saturated greens, oranges, reds, etc. on the marketing canvas — the only semantic color is `{colors.semantic-success}` (#27a644) for status pills and the rare success indicator.
-
-Display type runs Linear's custom sans (with `SF Pro Display` fallback) at weight 500–700 with negative letter-spacing scaling from -3.0px at 80px down to 0 at body. The body family is Linear's text cut, and a Linear Mono is reserved for code snippets in product screenshots.
-
-The page rhythm is **dense product screenshots** — Linear's marketing leads with high-fidelity captures of the product UI (issue list, project view, dashboard) framed in `{colors.surface-1}` panels with `{rounded.xl}` 16px corners. The chrome is intentionally minimal so the app screenshots can do the heavy lifting.
-
-**Key Characteristics:**
-- **Dark-canvas marketing system** — `{colors.canvas}` (#010102) is the deepest dark in this collection.
-- **Lavender-blue brand accent** (`{colors.primary}` #5e6ad2) — used scarcely on brand mark, focus, and the primary CTA.
-- Four-step surface ladder (canvas → surface-1 → surface-2 → surface-3 → surface-4) carries hierarchy without shadow.
-- Display tracking pulls aggressively negative (-3.0px at 80px); body holds at -0.05px.
-- Cards use `{rounded.lg}` 12px corners with 1px hairline borders — never pill, rarely 16px.
-- **Product UI screenshots** dominate the page. The marketing chrome is a dark frame for the app.
-- No second chromatic color. No atmospheric gradients. No spotlight cards.
-
-## Colors
-
-> Source pages: linear.app (home), /intake, /pricing, /contact/sales, /build.
-
-### Brand & Accent
-- **Lavender-Blue** ({colors.primary}): The signature Linear accent — primary CTA, brand mark, link emphasis.
-- **Lavender Hover** ({colors.primary-hover}): Lighter lavender (#828fff) — hovered state of the primary CTA.
-- **Lavender Focus** ({colors.primary-focus}): Focus-ring tint (#5e69d1) — focused inputs, focused buttons.
-- **Brand Secure** ({colors.brand-secure}): Muted lavender-gray (#7a7fad) — used in "Linear Security" surfaces.
-
-### Surface
-- **Canvas** ({colors.canvas}): Default page background — #010102, near-pure black with a faint blue tint.
-- **Surface 1** ({colors.surface-1}): One step above canvas — feature cards, pricing cards, product screenshot panels.
-- **Surface 2** ({colors.surface-2}): Two steps above — featured pricing card, hovered cards.
-- **Surface 3** ({colors.surface-3}): Three steps above — line-tertiary backgrounds, sub-nav.
-- **Surface 4** ({colors.surface-4}): Four steps above — bg-level-3, deepest lifted surface.
-- **Hairline** ({colors.hairline}): 1px borders on cards and dividers.
-- **Hairline Strong** ({colors.hairline-strong}): Stronger 1px borders — input focus rings.
-- **Hairline Tertiary** ({colors.hairline-tertiary}): Tertiary borders for nested surfaces.
-- **Inverse Canvas** ({colors.inverse-canvas}): Pure white — surface of the inverse pill CTA on a small set of section openers.
-- **Inverse Surface 1** ({colors.inverse-surface-1}): One step above inverse canvas.
-- **Inverse Surface 2** ({colors.inverse-surface-2}): Two steps above inverse canvas.
-
-### Text
-- **Ink** ({colors.ink}): All headlines and emphasized body type — light gray #f7f8f8.
-- **Ink Muted** ({colors.ink-muted}): Secondary type at #d0d6e0 — meta info on hero panels.
-- **Ink Subtle** ({colors.ink-subtle}): Tertiary type at #8a8f98 — deselected pricing tabs, footer columns.
-- **Ink Tertiary** ({colors.ink-tertiary}): Quaternary at #62666d — disabled, footnotes.
-
-### Semantic
-- **Success Green** ({colors.semantic-success}): Status pills, success indicators. The only semantic color on marketing.
-- **Overlay** ({colors.semantic-overlay}): Pure black overlay scrim for modals.
-
-## Typography
-
-### Font Family
-
-- **Linear Display** — Linear's custom display sans; fallback `SF Pro Display, -apple-system, system-ui, Segoe UI, Roboto`. Carries display-xl through subhead.
-- **Linear Text** — Linear's custom text sans (a slightly different cut tuned for body sizes); same fallback stack. Carries body sizes, button labels, captions.
-- **Linear Mono** — Linear's custom mono; fallback `ui-monospace, SF Mono, Menlo`. Used for code snippets in product screenshots and for status / ID tokens.
-
-The marketing surface treats Display and Text as one continuous voice; the family change is silent.
-
-### Hierarchy
-
-| Token | Size | Weight | Line Height | Letter Spacing | Use |
-|---|---|---|---|---|---|
-| `{typography.display-xl}` | 80px | 600 | 1.05 | -3.0px | Largest hero headline |
-| `{typography.display-lg}` | 56px | 600 | 1.10 | -1.8px | Section opener headlines |
-| `{typography.display-md}` | 40px | 600 | 1.15 | -1.0px | Sub-section headlines |
-| `{typography.headline}` | 28px | 600 | 1.20 | -0.6px | Pricing tier titles, CTA banner heading |
-| `{typography.card-title}` | 22px | 500 | 1.25 | -0.4px | Feature card title |
-| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs |
-| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs |
-| `{typography.body}` | 16px | 400 | 1.50 | -0.05px | Default body |
-| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer columns |
-| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta, status |
-| `{typography.button}` | 14px | 500 | 1.20 | 0 | All button labels |
-| `{typography.eyebrow}` | 13px | 500 | 1.30 | 0.4px | Section eyebrow (slight positive tracking) |
-| `{typography.mono}` | 13px | 400 | 1.50 | 0 | Linear Mono for code in product screenshots |
-
-### Principles
-
-- **Aggressive negative tracking on display** (-3.0px at 80px ≈ 4% of size).
-- **Single voice from display to body.** Display-xl at 600 → body at 400 — same family, narrower weights.
-- **Eyebrow uses positive tracking** (+0.4px) — contrast against the negative-tracked display marks the eyebrow as taxonomy.
-- **Mono only in code contexts.** Linear Mono lives inside product screenshots — not on marketing chrome.
-
-### Note on Font Substitutes
-
-Linear's custom typeface isn't publicly distributed; the documented fallback `SF Pro Display, -apple-system, system-ui` is the recommended substitute on macOS. For cross-platform implementation, **Inter** at weight 500 / 600 / 700 is the closest free substitute. **Geist Sans** is also viable. For mono, **JetBrains Mono** or **Geist Mono** at weight 400 closely approximates Linear Mono.
-
-## Layout
-
-### Spacing System
-
-- **Base unit**: 4px.
-- **Tokens (front matter)**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 96px.
-- Card interior padding: `{spacing.lg}` 24px on feature/pricing cards; `{spacing.xl}` 32px on testimonial cards; `{spacing.xxl}` 48px on CTA banners.
-- Pill button padding: 8px vertical · 14px horizontal — Linear's compact button spec.
-- Form input padding: 8px vertical · 12px horizontal.
-
-### Grid & Container
-
-- Max content width sits around 1280px.
-- Card grids are 3-up at desktop, 2-up at tablet, 1-up at mobile.
-- Pricing tier grid is 3-up; comparison strip below shows checkmarks per tier.
-- Product screenshot panels span full content width — they're the protagonist.
-
-### Whitespace Philosophy
-
-The dark canvas IS the whitespace. Sections separate by lift onto surface-1 panels, not by gaps in white. Within a panel, generous `{spacing.lg}` 24px gaps between content blocks; `{spacing.section}` 96px between sections.
-
-## Elevation & Depth
-
-| Level | Treatment | Use |
-|---|---|---|
-| 0 (flat) | No shadow, no border | Default for body type, hero text, footer |
-| 1 (charcoal lift) | `{colors.surface-1}` background on canvas, 1px `{colors.hairline}` | Default cards, product panels |
-| 2 (surface-2 lift) | `{colors.surface-2}` background, 1px `{colors.hairline-strong}` | Featured pricing card, hovered cards |
-| 3 (surface-3 lift) | `{colors.surface-3}` background | Sub-nav, dropdown menus |
-| 4 (focus ring) | 2px `{colors.primary-focus}` outline at 50% opacity | Focused input, focused button |
-
-Linear's depth is carried by surface ladder + hairline borders. The brand resists drop shadows on dark almost entirely.
-
-### Decorative Depth
-
-- **Product UI screenshots** dominate as decorative depth.
-- **No atmospheric gradients, no spotlight cards.**
-- **Subtle white edge highlight** on the top edge of lifted panels — gives the dark surface a faint "pixel rendered" feel.
-
-## Shapes
-
-### Border Radius Scale
-
-| Token | Value | Use |
-|---|---|---|
-| `{rounded.xs}` | 4px | Small chips, status badges |
-| `{rounded.sm}` | 6px | Inline tags |
-| `{rounded.md}` | 8px | All buttons, form inputs |
-| `{rounded.lg}` | 12px | Pricing cards, feature cards, testimonial cards |
-| `{rounded.xl}` | 16px | Product screenshot panels |
-| `{rounded.xxl}` | 24px | Oversized CTA banners (rare) |
-| `{rounded.pill}` | 9999px | Pricing tab toggles, status pills |
-| `{rounded.full}` | 9999px | Avatar circles |
-
-### Photography & Illustration Geometry
-
-- Product UI screenshots dominate; they sit in `{rounded.xl}` 16px tiles with `{spacing.lg}` 24px outer padding.
-- Customer logo tiles render at small sizes (~24px logo height) on `{colors.canvas}` with no border.
-- Avatar circles in testimonial cards use `{rounded.full}` at 32–40px sizes.
-
-## Components
-
-### Buttons
-
-**`button-primary`** — Lavender CTA. The default primary CTA across all pages.
-- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button}`, padding 8px 14px, rounded `{rounded.md}`.
-- Pressed state lives in `button-primary-pressed` (background shifts to `{colors.primary-focus}`).
-- Hover state lives in `button-primary-hover` (background shifts to `{colors.primary-hover}` lighter lavender).
-
-**`button-secondary`** — Charcoal button. Used for secondary CTAs ("Sign in", "Read changelog").
-- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.button}`, padding 8px 14px, rounded `{rounded.md}`. 1px `{colors.hairline}` border.
-
-**`button-tertiary`** — Plain text button.
-- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.button}`, rounded `{rounded.md}`, padding 8px 14px.
-
-**`button-inverse`** — White-on-dark inverse CTA.
-- Background `{colors.inverse-canvas}`, text `{colors.inverse-ink}`, type `{typography.button}`, rounded `{rounded.md}`, padding 8px 14px.
-
-### Pricing Tabs
-
-**`pricing-tab-default`** + **`pricing-tab-selected`** — Pill-toggle on `/pricing`.
-- Default: `{colors.canvas}` background, `{colors.ink-subtle}` text, rounded `{rounded.pill}`, padding 6px 14px.
-- Selected: `{colors.surface-2}` background, `{colors.ink}` text — selected = surface lift.
-
-### Cards & Containers
-
-**`pricing-card`** — Each tier on `/pricing`.
-- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px. 1px `{colors.hairline}` border.
-
-**`pricing-card-featured`** — Recommended tier — surface lift to surface-2.
-- Background `{colors.surface-2}`, otherwise identical structure.
-
-**`feature-card`** — Generic feature highlight tile.
-- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px.
-
-**`product-screenshot-card`** — The dominant card type — frames a high-fidelity Linear app UI screenshot.
-- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.xl}`, padding 24px.
-
-**`testimonial-card`** — Customer quote with avatar + name + role.
-- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body-lg}`, rounded `{rounded.lg}`, padding 32px.
-
-**`customer-logo-tile`** — Small tile in the customer marquee.
-- Background `{colors.canvas}`, text `{colors.ink-subtle}`, type `{typography.caption}`, rounded `{rounded.xs}`, padding 16px.
-
-**`cta-banner`** — Closing CTA panel near page bottom.
-- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.headline}`, rounded `{rounded.lg}`, padding 48px.
-
-### Inputs & Forms
-
-**`text-input`** + **`text-input-focused`** — Form fields on `/contact/sales` and signup overlays.
-- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 8px 12px.
-- Focused state retains the same surface; the focus ring is a 2px `{colors.primary-focus}` outline at 50% opacity.
-
-### Status & Build Page
-
-**`changelog-row`** — Each row in `/build` (changelog page) listing version, date, and changes.
-- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.xs}`, padding 24px 0. 1px `{colors.hairline}` bottom rule.
-
-**`status-badge`** — Small status pill.
-- Background `{colors.surface-2}`, text `{colors.ink-muted}`, type `{typography.caption}`, rounded `{rounded.pill}`, padding 2px 8px.
-
-### Navigation
-
-**`top-nav`** — Sticky dark bar with the Linear wordmark left, primary nav links centered, and a `button-secondary` ("Sign in") + `button-primary` ("Get started") pair right.
-- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-sm}`, height 56px.
-
-### Footer
-
-**`footer`** — Dense link grid on `{colors.canvas}` with the Linear wordmark left.
-- Background `{colors.canvas}`, text `{colors.ink-subtle}`, type `{typography.caption}`, padding 64px 32px.
-
-## Do's and Don'ts
-
-### Do
-
-- Reserve `{colors.canvas}` (#010102) as the system's anchor surface — the faint blue tint is intentional.
-- Use `{colors.primary}` lavender ONLY for: brand mark, primary CTA, focus ring, link emphasis.
-- Use the four-step surface ladder for hierarchy. Avoid skipping levels.
-- Pair display weight 600 with body weight 400 — Linear resists 700+ display weights.
-- Apply negative letter-spacing aggressively on display.
-- Use product UI screenshots as the protagonist of every section.
-- Compose CTAs as `{rounded.md}` 8px corners.
-
-### Don't
-
-- Don't ship a light-mode marketing page.
-- Don't use lavender as a section background or card fill.
-- Don't introduce a second chromatic accent (orange, pink, green for marketing).
-- Don't add atmospheric gradients or spotlight cards.
-- Don't pill-round CTAs.
-- Don't use `#000000` true black as the canvas.
-- Don't combine multiple bright accents in product screenshot mockups.
-
-## Responsive Behavior
-
-### Breakpoints
-
-| Name | Width | Key Changes |
-|---|---|---|
-| Desktop-XL | 1440px | Default desktop layout |
-| Desktop | 1280px | Card grid 3-up maintained |
-| Tablet | 1024px | Card grid 3-up → 2-up |
-| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger |
-| Mobile | 480px | Single-column; display-xl scales 80px → ~36px |
-
-### Touch Targets
-
-- CTAs hold ≥40px tap height across viewports.
-- Pricing tab pills hold ≥36px tap height; touch viewports grow to ≥44px.
-- Form inputs hold ≥44px tap target on touch.
-
-### Collapsing Strategy
-
-- **Top nav**: links collapse to hamburger below 768px.
-- **Card grids**: 3-up → 2-up at 1024px → 1-up below 768px.
-- **Pricing comparison**: per-tier accordion below 768px.
-- **Display type**: `{typography.display-xl}` 80px scales toward `{typography.display-md}` 40px on mobile.
-
-### Image Behavior
-
-- Product UI screenshots maintain aspect ratio and never crop.
-- Customer logos in the marquee may collapse from 6-up to 3-up below 768px.
-
-## Iteration Guide
-
-1. Focus on ONE component at a time and reference it by its `components:` token name.
-2. When introducing a section, decide first which surface lift it lives on.
-3. Default body to `{typography.body}` at weight 400.
-4. Run `npx @google/design.md lint DESIGN.md` after edits.
-5. Add new variants as separate component entries.
-6. Treat lavender as scarce: brand mark, primary CTA, focus, link emphasis.
-7. Lead every section with a product UI screenshot.
-
-## Known Gaps
-
-- The four-step surface ladder values are extracted directly from Linear's `--color-bg-level-3`, `--color-line-tint`, etc. CSS variables; they are Linear's canonical surface spec.
-- Form-field error and validation styling is not visible on the inspected pages.
-- Light mode is not documented because the marketing site does not ship a light theme.
-- Linear's actual product UI uses a richer color-tag palette (red, orange, yellow, green, blue, purple) for issue priorities and project labels — those colors live in the in-product surfaces shown in mockups.
-- The custom display, text, and mono families are proprietary; an open-source substitute is acceptable.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..30d6019
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 MoFadel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 665bf54..e3ab0f0 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ exits non-zero — losing records is never a silent, zero-exit outcome.
| Path | Contents |
|---|---|
| `cmd/api/` | Service entrypoint |
+| `cmd/loadtest/` | Load generator for `POST /v1/logs` |
| `internal/` | Handler, queue, worker pool, pgx store, config, metrics, embedded UI |
| `db/migrations/` | golang-migrate up/down pairs |
| `db/seed/` | Synthetic data generator |
@@ -59,7 +60,8 @@ Then apply the schema and create partitions:
```bash
kubectl -n logengine-local port-forward svc/postgres 55432:5432 &
-export DATABASE_URL='postgres://logengine:logengine@127.0.0.1:55432/logengine?sslmode=disable'\nmigrate -path db/migrations -database "$DATABASE_URL" up
+export DATABASE_URL='postgres://logengine:logengine@127.0.0.1:55432/logengine?sslmode=disable'
+migrate -path db/migrations -database "$DATABASE_URL" up
psql "$DATABASE_URL" -c 'SELECT logs_create_partitions_between(CURRENT_DATE - 2, CURRENT_DATE + 2);'
```
@@ -108,7 +110,7 @@ Navigate to `http://localhost:8080/` (or `/ui/`) in any browser:
### Query API
`GET /v1/logs` accepts the following query parameters:
-- `tenant_id`: UUID (e.g. `00000000-0000-0000-0000-000000000001`)
+- `tenant_id`: UUID, required (e.g. `00000000-0000-0000-0000-000000000001`). Missing or invalid returns `400`.
- `level`: Log level (`INFO`, `WARN`, `ERROR`, `DEBUG`)
- `source`: Originating service name
- `q`: Case-insensitive substring match on message
@@ -116,7 +118,7 @@ Navigate to `http://localhost:8080/` (or `/ui/`) in any browser:
- `limit`: Number of rows to return (default 100, max 1000)
```bash
-curl "http://localhost:8080/v1/logs?level=ERROR&from=24h&limit=50"
+curl "http://localhost:8080/v1/logs?tenant_id=00000000-0000-0000-0000-000000000001&level=ERROR&from=24h&limit=50"
```
### Ingest API
@@ -177,7 +179,8 @@ is the right call at that selectivity and table size.
Structured JSON logs via `log/slog`. Prometheus metrics on `/metrics`:
| Metric | Meaning |
-|---|---|\n| `logengine_queue_depth` | Current queue occupancy |
+|---|---|
+| `logengine_queue_depth` | Current queue occupancy |
| `logengine_logs_accepted_total` | Records queued |
| `logengine_logs_dropped_total` | Records refused — queue full |
| `logengine_logs_lost_total` | Accepted records abandoned unwritten |
@@ -199,6 +202,61 @@ Integration tests start a throwaway Postgres with testcontainers and apply
`db/migrations` directly, so they cannot pass against a schema that has drifted
from the migrations.
+To skip Docker, set `PGTEST_DATABASE_URL` to an existing server (e.g.
+`postgres://user@localhost:5432/postgres?sslmode=disable`); each test creates
+its own database there and drops it afterwards.
+
+## Load test
+
+`cmd/loadtest` sends JSON arrays of records to `POST /v1/logs` and reports
+status codes, accepted/dropped counts, client latency, and rows/sec persisted
+(from the server's `logengine_rows_written_total`), then waits for the queue to
+drain and prints the server counter deltas.
+
+```bash
+export DATABASE_URL='postgres://user@localhost:5432/logengine_loadtest?sslmode=disable'
+createdb logengine_loadtest
+migrate -path db/migrations -database "$DATABASE_URL" up
+ROWS=1000000 ./db/seed/seed.sh
+psql "$DATABASE_URL" -c 'SELECT logs_create_partitions_between(CURRENT_DATE, CURRENT_DATE + 1);'
+go run ./cmd/api & # or QUEUE_SIZE=1000 go run ./cmd/api
+go run ./cmd/loadtest -duration 30s -c 20 -batch 100 -rate 600 # fixed rate
+go run ./cmd/loadtest -duration 30s -c 50 -batch 100 # as fast as possible
+```
+
+Setup: Apple M1 Pro (10 cores, 16 GB), PostgreSQL 18.6 from Postgres.app with
+its default config — not the PostgreSQL 16 the Kubernetes overlay runs. Client,
+API and database all ran on the same machine and shared its CPU. The table
+started at 1M seeded rows and grew to 17.6M over the runs below, in the order
+listed. Each run lasted 30 s with 100 records per request, `WORKER_COUNT=4`.
+
+| Load | `QUEUE_SIZE` | Rows/sec persisted | p50 | p99 | 503s |
+|---|---|---|---|---|---|
+| 250 req/s | 10000 | 24,850 | 0.42 ms | 3.16 ms | 0 |
+| 500 req/s | 10000 | 49,716 | 0.34 ms | 2.58 ms | 0 |
+| 600 req/s | 10000 | 59,832 | 0.32 ms | 1.27 ms | 0 |
+| 750 req/s | 10000 | 71,182 | 0.36 ms | 8.53 ms | 51 of 21,414 (0.24%) |
+| 50 senders, no limit | 10000 | 133,238 | 4.38 ms | 18.75 ms | 197,207 of 235,343 (83.8%) |
+| 50 senders, no limit | 1000 | 105,387 | 4.42 ms | 20.99 ms | 206,793 of 236,753 (87.3%) |
+| 500 req/s | 1000 | 49,800 | 0.35 ms | 0.89 ms | 47 of 14,998 (0.31%) |
+
+Latency is client-side, for the whole request. The highest load with no 503s
+was 600 req/s (about 60,000 rows/sec). That limit moves: in an earlier pass,
+with the table at about 20M rows, 600 and 500 req/s got 36 and 14 503s.
+Postgres logged a checkpoint every 8–9 s during the runs ("checkpoints are
+occurring too frequently", `max_wal_size` 1GB). At 50,000 rows/sec a
+10,000-record queue holds 0.2 s of traffic, so a short write stall is enough to
+fill it.
+
+Under overload every 503 carried `Retry-After: 1` and its body reported
+`accepted`/`dropped` counts. The database kept absorbing 133,238 rows/sec with
+the default queue and 105,387 with `QUEUE_SIZE=1000`. `/healthz` answered 200
+within 6 ms throughout, and RSS stayed between 36 and 45 MB. After each run
+drained, `logengine_logs_accepted_total` equalled `logengine_rows_written_total`
+(4,009,500 and 3,165,000), `logengine_logs_lost_total` was 0, and `count(*)` on
+`logs` grew by exactly that many rows. Each refused request logs a WARN line;
+the default-queue overload run wrote 26 MB of server log in 30 s.
+
## Building
```bash
diff --git a/cmd/loadtest/main.go b/cmd/loadtest/main.go
new file mode 100644
index 0000000..52a3ea5
--- /dev/null
+++ b/cmd/loadtest/main.go
@@ -0,0 +1,223 @@
+// Command loadtest drives POST /v1/logs with batches of synthetic records and
+// reports client-side latency, status codes, accepted/dropped counts and the
+// rows/sec the server persisted, read from its /metrics endpoint.
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+var (
+ levels = []string{"DEBUG", "INFO", "WARN", "ERROR"}
+ sources = []string{"svc-api", "svc-worker", "svc-gateway", "svc-auth", "svc-billing"}
+)
+
+type record struct {
+ TenantID string `json:"tenant_id"`
+ TS string `json:"ts"`
+ Level string `json:"level"`
+ Source string `json:"source"`
+ Message string `json:"message"`
+ Attrs map[string]any `json:"attrs"`
+}
+
+// result is what one worker observed.
+type result struct {
+ latencies []time.Duration
+ status map[int]int
+ retryAfter int // 503 responses carrying a Retry-After header
+ errors int // transport errors
+ accepted int
+ dropped int
+}
+
+func main() {
+ base := flag.String("url", "http://localhost:8080", "base URL of the API")
+ duration := flag.Duration("duration", 30*time.Second, "how long to send load")
+ conc := flag.Int("c", 10, "concurrent senders")
+ batch := flag.Int("batch", 100, "records per request")
+ rate := flag.Float64("rate", 0, "target requests/sec across all senders (0 = as fast as possible)")
+ drain := flag.Duration("drain", 60*time.Second, "max wait for the server queue to drain after the run")
+ flag.Parse()
+
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{MaxIdleConnsPerHost: *conc},
+ }
+ metricsURL := *base + "/metrics"
+ before := scrape(client, metricsURL)
+
+ var ticks <-chan time.Time
+ if *rate > 0 {
+ t := time.NewTicker(time.Duration(float64(time.Second) / *rate))
+ defer t.Stop()
+ ticks = t.C
+ }
+
+ start := time.Now()
+ deadline := start.Add(*duration)
+ results := make([]result, *conc)
+ var wg sync.WaitGroup
+ for i := range results {
+ wg.Add(1)
+ go func(r *result, worker int) {
+ defer wg.Done()
+ r.status = map[int]int{}
+ for seq := 0; time.Now().Before(deadline); seq++ {
+ if ticks != nil {
+ select {
+ case <-ticks:
+ case <-time.After(time.Until(deadline)):
+ return
+ }
+ }
+ send(client, *base+"/v1/logs", body(*batch, worker, seq), r)
+ }
+ }(&results[i], i)
+ }
+ wg.Wait()
+ elapsed := time.Since(start)
+ atEnd := scrape(client, metricsURL)
+
+ // Drain: wait for the queue to empty and for rows_written to stop moving,
+ // so batches already pulled off the queue by workers are flushed too. The
+ // poll interval is longer than the server's 500ms flush interval. drained
+ // is when the last increase was seen, to 1s resolution.
+ drainStart := time.Now()
+ after := atEnd
+ drained := elapsed
+ for time.Since(drainStart) < *drain {
+ time.Sleep(time.Second)
+ next := scrape(client, metricsURL)
+ moved := next["logengine_rows_written_total"] != after["logengine_rows_written_total"]
+ after = next
+ if moved {
+ drained = time.Since(start)
+ } else if next["logengine_queue_depth"] == 0 {
+ break
+ }
+ }
+
+ report(results, elapsed, drained, before, atEnd, after)
+}
+
+func body(n, worker, seq int) []byte {
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ recs := make([]record, n)
+ for i := range recs {
+ k := worker*7 + seq*13 + i
+ recs[i] = record{
+ TenantID: fmt.Sprintf("00000000-0000-0000-0000-%012d", 1+k%10),
+ TS: now,
+ Level: levels[k%len(levels)],
+ Source: sources[k%len(sources)],
+ Message: fmt.Sprintf("loadtest worker=%d seq=%d i=%d", worker, seq, i),
+ Attrs: map[string]any{"worker": worker, "seq": seq, "duration_ms": k % 500},
+ }
+ }
+ b, err := json.Marshal(recs)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return b
+}
+
+func send(client *http.Client, url string, payload []byte, r *result) {
+ t0 := time.Now()
+ resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
+ if err != nil {
+ r.errors++
+ return
+ }
+ raw, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ r.latencies = append(r.latencies, time.Since(t0))
+ r.status[resp.StatusCode]++
+
+ var counts struct{ Accepted, Dropped int }
+ _ = json.Unmarshal(raw, &counts)
+ r.accepted += counts.Accepted
+ r.dropped += counts.Dropped
+ if resp.StatusCode == http.StatusServiceUnavailable && resp.Header.Get("Retry-After") != "" {
+ r.retryAfter++
+ }
+}
+
+// scrape reads the unlabelled logengine_* samples from a Prometheus text page.
+func scrape(client *http.Client, url string) map[string]float64 {
+ resp, err := client.Get(url)
+ if err != nil {
+ log.Fatalf("scrape %s: %v", url, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ out := map[string]float64{}
+ sc := bufio.NewScanner(resp.Body)
+ for sc.Scan() {
+ name, val, ok := strings.Cut(sc.Text(), " ")
+ if !ok || !strings.HasPrefix(name, "logengine_") {
+ continue
+ }
+ if v, err := strconv.ParseFloat(val, 64); err == nil {
+ out[name] = v
+ }
+ }
+ return out
+}
+
+func report(results []result, elapsed, drained time.Duration, before, atEnd, after map[string]float64) {
+ var all []time.Duration
+ status := map[int]int{}
+ var retryAfter, errs, accepted, dropped int
+ for _, r := range results {
+ all = append(all, r.latencies...)
+ for code, n := range r.status {
+ status[code] += n
+ }
+ retryAfter += r.retryAfter
+ errs += r.errors
+ accepted += r.accepted
+ dropped += r.dropped
+ }
+ slices.Sort(all)
+ pct := func(p float64) time.Duration {
+ if len(all) == 0 {
+ return 0
+ }
+ return all[int(p*float64(len(all)-1))]
+ }
+ other := 0
+ for code, n := range status {
+ if code != http.StatusAccepted && code != http.StatusServiceUnavailable {
+ other += n
+ }
+ }
+ delta := func(m map[string]float64, name string) float64 { return m[name] - before[name] }
+ written := delta(after, "logengine_rows_written_total")
+
+ w := os.Stdout
+ _, _ = fmt.Fprintf(w, "duration %s (last rows written at %s)\n", elapsed.Round(time.Millisecond), drained.Round(time.Millisecond))
+ _, _ = fmt.Fprintf(w, "requests %d (%.0f req/s), transport errors %d\n", len(all)+errs, float64(len(all)+errs)/elapsed.Seconds(), errs)
+ _, _ = fmt.Fprintf(w, "status 202=%d 503=%d other=%d (503 with Retry-After: %d)\n",
+ status[http.StatusAccepted], status[http.StatusServiceUnavailable], other, retryAfter)
+ _, _ = fmt.Fprintf(w, "records accepted=%d dropped=%d\n", accepted, dropped)
+ _, _ = fmt.Fprintf(w, "latency p50=%s p95=%s p99=%s max=%s\n",
+ pct(0.50).Round(time.Microsecond), pct(0.95).Round(time.Microsecond), pct(0.99).Round(time.Microsecond), pct(1).Round(time.Microsecond))
+ _, _ = fmt.Fprintf(w, "rows/sec persisted %.0f during run, %.0f including drain\n",
+ delta(atEnd, "logengine_rows_written_total")/elapsed.Seconds(), written/drained.Seconds())
+ _, _ = fmt.Fprintf(w, "server deltas accepted=%.0f rows_written=%.0f dropped=%.0f lost=%.0f write_errors=%.0f queue_depth=%.0f\n",
+ delta(after, "logengine_logs_accepted_total"), written, delta(after, "logengine_logs_dropped_total"),
+ delta(after, "logengine_logs_lost_total"), delta(after, "logengine_batch_write_errors_total"), after["logengine_queue_depth"])
+}
diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md
index 4e97ae2..bdfa2eb 100644
--- a/deploy/k8s/README.md
+++ b/deploy/k8s/README.md
@@ -6,16 +6,6 @@ overlays/local/ -- kind/k3d overlay: dev namespace, smaller replica
counts, IfNotPresent pull policy
```
-## Shared network namespace
-
-The `logengine-api` Deployment runs two containers per pod: `api` and
-`log-forwarder`. Containers in the same pod share a single network
-namespace -- one loopback interface and one port space -- so the
-`log-forwarder` sidecar reaches the API at `http://localhost:8080`
-directly, without going through the `Service`/ClusterIP. This only works
-because they're co-located in the same pod; containers in different pods
-cannot reach each other over `localhost`.
-
## Rendering / validating
```sh
diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml
index 0f2d90b..885adfa 100644
--- a/deploy/k8s/base/deployment.yaml
+++ b/deploy/k8s/base/deployment.yaml
@@ -87,26 +87,3 @@ spec:
limits:
cpu: 500m
memory: 256Mi
- # Sidecar: forwards/scrapes API logs+metrics over localhost.
- # Containers in the same pod share one network namespace (same
- # loopback interface, same port space), so the sidecar can reach
- # the api container at localhost:8080 without going through the
- # Service/ClusterIP. This is also why the api and log-forwarder
- # containers must not both try to bind the same port.
- - name: log-forwarder
- image: busybox:1.36
- command: ["/bin/sh", "-c"]
- args:
- - |
- while true; do
- wget -q -O- http://localhost:8080/healthz >/dev/null 2>&1 \
- && echo "$(date -Iseconds) api reachable via localhost:8080 (shared pod network namespace)"
- sleep 15
- done
- resources:
- requests:
- cpu: 10m
- memory: 16Mi
- limits:
- cpu: 50m
- memory: 32Mi
diff --git a/internal/app/app.go b/internal/app/app.go
index b64d478..a676a4b 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -114,8 +114,9 @@ func (a *App) Run(ctx context.Context) error {
select {
case err := <-serveErr:
- // The server stopped on its own; still drain the pipeline.
- a.shutdown()
+ // The server stopped on its own; still drain the pipeline. shutdown
+ // logs its own failures, and the serve error is the one returned.
+ _ = a.shutdown()
if err != nil {
return fmt.Errorf("http server: %w", err)
}
diff --git a/internal/app/shutdown_test.go b/internal/app/shutdown_test.go
index 38cba44..fe56fb2 100644
--- a/internal/app/shutdown_test.go
+++ b/internal/app/shutdown_test.go
@@ -189,7 +189,7 @@ func postLog(client *http.Client, base, tenant, msg string) (int, error) {
if err != nil {
return 0, err
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
@@ -201,7 +201,7 @@ func waitReady(t *testing.T, client *http.Client, base string) {
resp, err := client.Get(base + "/readyz")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
- resp.Body.Close()
+ _ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go
index 7fadcb9..f7c0a5d 100644
--- a/internal/httpapi/api.go
+++ b/internal/httpapi/api.go
@@ -164,14 +164,17 @@ func (a *API) getLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
var params pgstore.QueryParams
- if tenantStr := strings.TrimSpace(q.Get("tenant_id")); tenantStr != "" {
- id, err := uuid.Parse(tenantStr)
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid tenant_id: must be a valid UUID"})
- return
- }
- params.TenantID = &id
+ tenantStr := strings.TrimSpace(q.Get("tenant_id"))
+ if tenantStr == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": "tenant_id is required"})
+ return
+ }
+ id, err := uuid.Parse(tenantStr)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid tenant_id: must be a valid UUID"})
+ return
}
+ params.TenantID = &id
params.Level = strings.TrimSpace(q.Get("level"))
params.Source = strings.TrimSpace(q.Get("source"))
diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go
index b024dc4..16ea240 100644
--- a/internal/httpapi/api_test.go
+++ b/internal/httpapi/api_test.go
@@ -138,15 +138,15 @@ func TestInvalidPayloadRejectedWith400(t *testing.T) {
api, _ := newAPI(t, 4)
for name, body := range map[string]string{
- "not_json": "not json",
- "empty_body": "",
- "empty_array": "[]",
- "missing_tenant": `{"ts":"2026-09-22T10:00:00Z","message":"x"}`,
- "bad_uuid": `{"tenant_id":"bad","ts":"2026-09-22T10:00:00Z"}`,
- "missing_ts": `{"tenant_id":"` + tenant + `"}`,
- "bad_ts": `{"tenant_id":"` + tenant + `","ts":"yesterday"}`,
- "attrs_not_obj": `{"tenant_id":"` + tenant + `","ts":"2026-09-22T10:00:00Z","attrs":[1,2]}`,
- "array_one_bad": fmt.Sprintf("[%s, {\"bad\":true}]", record("ok")),
+ "not_json": "not json",
+ "empty_body": "",
+ "empty_array": "[]",
+ "missing_tenant": `{"ts":"2026-09-22T10:00:00Z","message":"x"}`,
+ "bad_uuid": `{"tenant_id":"bad","ts":"2026-09-22T10:00:00Z"}`,
+ "missing_ts": `{"tenant_id":"` + tenant + `"}`,
+ "bad_ts": `{"tenant_id":"` + tenant + `","ts":"yesterday"}`,
+ "attrs_not_obj": `{"tenant_id":"` + tenant + `","ts":"2026-09-22T10:00:00Z","attrs":[1,2]}`,
+ "array_one_bad": fmt.Sprintf("[%s, {\"bad\":true}]", record("ok")),
} {
t.Run(name, func(t *testing.T) {
rec := post(t, api, body)
@@ -272,16 +272,32 @@ func TestGetLogsValidationErrors(t *testing.T) {
querier := &fakeQuerier{}
api := New(q, m, reg, slog.New(slog.DiscardHandler), querier)
- // Invalid UUID
- req := httptest.NewRequest(http.MethodGet, "/v1/logs?tenant_id=invalid-uuid", nil)
+ // Missing tenant_id
+ req := httptest.NewRequest(http.MethodGet, "/v1/logs?level=ERROR", nil)
rec := httptest.NewRecorder()
api.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("expected 400 for missing tenant_id, got %d", rec.Code)
+ }
+
+ // Blank tenant_id
+ req = httptest.NewRequest(http.MethodGet, "/v1/logs?tenant_id=%20", nil)
+ rec = httptest.NewRecorder()
+ api.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("expected 400 for blank tenant_id, got %d", rec.Code)
+ }
+
+ // Invalid UUID
+ req = httptest.NewRequest(http.MethodGet, "/v1/logs?tenant_id=invalid-uuid", nil)
+ rec = httptest.NewRecorder()
+ api.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 for bad tenant_id, got %d", rec.Code)
}
// Invalid From
- req = httptest.NewRequest(http.MethodGet, "/v1/logs?from=not-a-date", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v1/logs?tenant_id="+tenant+"&from=not-a-date", nil)
rec = httptest.NewRecorder()
api.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
@@ -296,7 +312,7 @@ func TestGetLogsQuerierError(t *testing.T) {
querier := &fakeQuerier{err: errors.New("db down")}
api := New(q, m, reg, slog.New(slog.DiscardHandler), querier)
- req := httptest.NewRequest(http.MethodGet, "/v1/logs", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v1/logs?tenant_id="+tenant, nil)
rec := httptest.NewRecorder()
api.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
diff --git a/internal/pgtest/pgtest.go b/internal/pgtest/pgtest.go
index 45cc7f2..003af1e 100644
--- a/internal/pgtest/pgtest.go
+++ b/internal/pgtest/pgtest.go
@@ -1,4 +1,4 @@
-// Package pgtest starts a throwaway Postgres for integration tests.
+// Package pgtest provides a throwaway Postgres database for integration tests.
//
// The schema is applied from db/migrations, the same files golang-migrate runs
// in production, so these tests cannot pass against a schema that has drifted
@@ -8,13 +8,16 @@ package pgtest
import (
"context"
"fmt"
+ "net/url"
"os"
"path/filepath"
"runtime"
"sort"
+ "strings"
"testing"
"time"
+ "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
@@ -70,9 +73,10 @@ func applyMigrations(ctx context.Context, conn *pgx.Conn) error {
return nil
}
-// Start boots a Postgres container with db/migrations applied and partitions
-// created around today, and returns its DSN. The container is terminated when
-// the test finishes.
+// Start returns the DSN of a fresh database with db/migrations applied and
+// partitions created around today. If PGTEST_DATABASE_URL is set, the database
+// is created on that server and dropped when the test finishes; otherwise a
+// Postgres container is started and terminated when the test finishes.
func Start(t *testing.T) string {
t.Helper()
if testing.Short() {
@@ -80,24 +84,11 @@ func Start(t *testing.T) string {
}
ctx := context.Background()
- ctr, err := postgres.Run(ctx, "postgres:16-alpine",
- postgres.WithDatabase("logengine"),
- postgres.WithUsername("logengine"),
- postgres.WithPassword("logengine"),
- postgres.BasicWaitStrategies(),
- )
- if err != nil {
- t.Fatalf("start postgres container: %v", err)
- }
- t.Cleanup(func() {
- if err := testcontainers.TerminateContainer(ctr); err != nil {
- t.Logf("terminate postgres container: %v", err)
- }
- })
-
- dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
- if err != nil {
- t.Fatalf("connection string: %v", err)
+ var dsn string
+ if baseURL := os.Getenv("PGTEST_DATABASE_URL"); baseURL != "" {
+ dsn = createDatabase(ctx, t, baseURL)
+ } else {
+ dsn = startContainer(ctx, t)
}
setupCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
@@ -106,7 +97,7 @@ func Start(t *testing.T) string {
if err != nil {
t.Fatalf("connect: %v", err)
}
- defer conn.Close(setupCtx)
+ defer func() { _ = conn.Close(setupCtx) }()
// CURRENT_DATE below is session-dependent; pin it to UTC so partitions
// line up with the UTC timestamps the tests write.
@@ -126,3 +117,65 @@ func Start(t *testing.T) string {
}
return dsn
}
+
+// startContainer boots a Postgres container, terminates it when the test
+// finishes, and returns its DSN.
+func startContainer(ctx context.Context, t *testing.T) string {
+ t.Helper()
+ ctr, err := postgres.Run(ctx, "postgres:16-alpine",
+ postgres.WithDatabase("logengine"),
+ postgres.WithUsername("logengine"),
+ postgres.WithPassword("logengine"),
+ postgres.BasicWaitStrategies(),
+ )
+ if err != nil {
+ t.Fatalf("start postgres container: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := testcontainers.TerminateContainer(ctr); err != nil {
+ t.Logf("terminate postgres container: %v", err)
+ }
+ })
+
+ dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
+ if err != nil {
+ t.Fatalf("connection string: %v", err)
+ }
+ return dsn
+}
+
+// createDatabase creates a uniquely named database on the server at baseURL,
+// drops it when the test finishes, and returns its DSN. The random name keeps
+// parallel tests and packages from colliding on a shared server.
+func createDatabase(ctx context.Context, t *testing.T, baseURL string) string {
+ t.Helper()
+ u, err := url.Parse(baseURL)
+ if err != nil {
+ t.Fatalf("parse PGTEST_DATABASE_URL: %v", err)
+ }
+ name := "pgtest_" + strings.ReplaceAll(uuid.NewString(), "-", "")
+ ident := pgx.Identifier{name}.Sanitize()
+
+ conn, err := pgx.Connect(ctx, baseURL)
+ if err != nil {
+ t.Fatalf("connect to PGTEST_DATABASE_URL: %v", err)
+ }
+ defer func() { _ = conn.Close(ctx) }()
+ if _, err := conn.Exec(ctx, "CREATE DATABASE "+ident); err != nil {
+ t.Fatalf("create database %s: %v", name, err)
+ }
+ t.Cleanup(func() {
+ conn, err := pgx.Connect(ctx, baseURL)
+ if err != nil {
+ t.Logf("connect to drop database %s: %v", name, err)
+ return
+ }
+ defer func() { _ = conn.Close(ctx) }()
+ if _, err := conn.Exec(ctx, "DROP DATABASE IF EXISTS "+ident+" WITH (FORCE)"); err != nil {
+ t.Logf("drop database %s: %v", name, err)
+ }
+ })
+
+ u.Path = "/" + name
+ return u.String()
+}
diff --git a/internal/ui/assets/index.html b/internal/ui/assets/index.html
index 1c95a86..ab3db0f 100644
--- a/internal/ui/assets/index.html
+++ b/internal/ui/assets/index.html
@@ -8,7 +8,7 @@