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 @@