Files
cairnobs/api
jcoffey-dev 5ff2e5bb60 Scope log retention deletion and floors to (host, service), not host alone
logs rows carry a real per-record `service` (nginx, smtp, ufw, ...) --
already true of the schema (storage/migrations/0001) and wire protocol,
not something this feature invents. Both the deletion picker and the
retention floor now operate on (host, service) pairs instead of whole
hosts, so an operator can delete just one noisy log type from an agent
without touching everything else it ships, and can protect one service
(e.g. keep smtp a year) longer than the rest of that host's default.

api/agents.ConfigOverride gains ServiceLogRetentionDays (map[string]int),
owner-only to change like LogRetentionDays -- a service listed there
overrides the host's LogRetentionDays default for that service only.
Agent config page gets a matching "Per-service log retention overrides"
add/remove list next to the existing host-level field.

api/logretention: Store's count/delete now take []HostService and build
a ClickHouse tuple IN ((?,?),...) over (host, service); AgentRetentionStore.
FloorsByHost returns each host's default plus its per-service map, with
HostFloor.Effective(service) resolving which one applies. preview/delete
moved from GET/DELETE-with-query-params to POST-with-JSON-body (a list of
targets needs a real body, not a repeated compound query param), and
partitionTargets checks the floor per target so one protected service
never blocks deleting a different, unprotected one in the same request.

Settings' Log retention section is a two-level picker now: each host
row (with a "select all services" checkbox and its default floor badge)
expands to its services, each with its own count and effective
protected-days badge.

Verified live against real ClickHouse/Postgres and in-browser: a host
with a 7-day default plus a 365-day smtp override -- deleting nginx+
smtp+ufw together correctly removed nginx and ufw, left smtp's 10
records untouched, and confirmed via a follow-up owner delete that
bypassing the floor works. Also verified the full click-through (add a
service override on the agent page, see it reflected in Settings'
picker, select/preview/cancel) and confirmed no regression from the
prior host-only version's tests.
2026-08-21 15:54:58 -07:00
..

api

Sentry's query API: a single POST /query endpoint accepting either the pipe syntax or raw SQL, compiled and routed across ClickHouse and Tantivy by internal/querylang. Replaces Phase 0/1's two separate placeholder endpoints (raw-SQL-only /query, free-text-only /search) — see /docs/query-language-design.md for the grammar, IR, and routing design, and /docs/query-language-reference.md for the user-facing syntax.

Why plain REST, not gRPC + REST gateway

CLAUDE.md pins the control plane to "Go, gRPC + REST gateway." This service is plain net/http instead — a deliberate simplification, not a change to the pinned stack. Wiring up a .proto service, google.api.http annotations, and protoc-gen-grpc-gateway codegen for one endpoint doesn't buy much at this size. api does speak gRPC internally — to /search — this simplification is about the public-facing surface only.

Endpoint

POST /query — body {"query": "...", "language": ""}, response {"columns": [...], "rows": [[...], ...]} or {"error": "..."}.

  • query is either pipe syntax (service=api | where status>=500 | stats count by host) or raw SQL (SELECT ...). Auto-detected by whether the query starts with SELECT (case-insensitive).
  • language optionally overrides detection: "sql" or "spl". Exists for the rare case a pipe query legitimately starts with the literal word "select" as a bare search term.
  • Both syntaxes compile to the same querylang/ir.Plan and execute through the same code path — see internal/querylang/executor for the four routing cases (pure ClickHouse; Tantivy prefilter + ClickHouse rows; Tantivy prefilter + ClickHouse aggregation; raw SQL passthrough).

GET /healthz — for docker-compose/k8s liveness checks.

No auth. Not scoped yet — don't expose this beyond a trusted dev/homelab network.

Configuration

Environment variables (see internal/config/config.go):

Var Default Purpose
HTTP_LISTEN_ADDR :8080
CLICKHOUSE_ADDR localhost:9000 Native protocol port
CLICKHOUSE_DATABASE / _USERNAME / _PASSWORD sentry / default / ``
SEARCH_GRPC_ADDR localhost:50052 Must match /search's GRPC_LISTEN_ADDR
QUERY_TIMEOUT_SECONDS 30 Per-request timeout
CORS_ALLOWED_ORIGIN * Wide open by default since there's no auth yet; tighten together

searchclient.Dial connects to /search over plain TCP, no TLS — same trust boundary as api's existing plain-TCP connection to ClickHouse. mTLS in this project is specifically the agent↔ingest edge boundary, not every internal hop.

Building & testing

go build ./...
go vet ./...
go test ./...
# from the repo root, not api/
docker build -f api/Dockerfile -t sentry-api .

Testing notes

internal/queryapi's HTTP handler depends on ClickHouse and /search only through the narrow interfaces querylang/executor defines (SQLRunner, SearchClient), so routing, compilation, JSON encoding, and error-status mapping are all unit-tested against fakes — no live ClickHouse or /search instance needed, and the real lexer/parser/ planner run unmocked in these tests, only the backends are faked. See internal/querylang's own package docs for how compilation and execution are tested independently of each other. executor.ChRunner (the reflection-based row scanning against ClickHouse's driver.Rows) and internal/searchclient's actual gRPC dial are not unit-tested — the former because faking driver.Rows fully would be significant test-only scaffolding the driver's own docs say isn't meant to be implemented by adopters; the latter because it's a thin wrapper with nothing but wiring to test. Both are exercised end-to-end via the docker-compose flow in /docs/phase-2-runbook.md.