mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
Security:
- Graceful shutdown: http.Server + signal.NotifyContext, 10s drain,
retention sweeper cancelled before pool close
- Scoped API keys (migration 00006): ingest/read/write/admin enforced in
auth middleware; scope picker + badges in Settings UI; scopeless legacy
keys act as admin. Read-only keys now get 403 on ingest (verified live)
- nginx: baseline security headers on all responses, CSP scoped to SPA;
fixed add_header inheritance shadowing
- better-auth rate limit enabled unconditionally (was gated on NODE_ENV,
which the shipped .env sets to development)
- Mailpit moved to docker-compose.dev.yml; prod compose cannot expose it
- setup.sh generates PRIMORA_ENCRYPTION_KEY
Reliability/ops:
- Retention: per-project retention_{events,audit,webhook}_days
(migration 00007, 0=off) + hourly RetentionSweeper; edit UI in Projects
- GET /api/v1/metrics Prometheus exposition
- compose: healthchecks + restart policies everywhere, dragonfly pinned
to v2.0.0, depends_on service_healthy
- backup.sh/restore.sh (pg_dump -Fc + storage volume); restore verified
against scratch postgres+volume
- .dockerignore: exclude target dirs (build context was 4.7GB)
Correctness/testing:
- DBX transfer endpoint: POST /db-connections/:id/transfer — SQL or Redis
target, 1000-row bound, server-side creds (verified live pg→dragonfly)
- Pagination audit: ListComponents capped at 500 (auto-registers)
- CI: goose up/reset/up job + compose e2e-smoke job (smoke-e2e.sh
verified locally: signup, bootstrap, scoped keys, ingest, metrics)
- k6 load sanity script: 6650 reqs, ~217/s, 0 real failures, p95 221ms
(429s = configured rate limiter working)
Docs: DEPLOYMENT_GUIDE.md, PRODUCTION_READINESS.md; verify script
repaired (stale refs + set -e arithmetic abort). ROADMAP: phase 2 +
phase 6 marked complete.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
110 lines
4.2 KiB
SQL
110 lines
4.2 KiB
SQL
-- name: UpsertComponent :one
|
|
INSERT INTO core.components (project_id, name, kind)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (project_id, name) DO UPDATE SET name = EXCLUDED.name
|
|
RETURNING *;
|
|
|
|
-- name: GetComponentByName :one
|
|
SELECT * FROM core.components
|
|
WHERE project_id = $1 AND name = $2;
|
|
|
|
-- name: ListComponents :many
|
|
SELECT * FROM core.components
|
|
WHERE project_id = $1
|
|
ORDER BY name
|
|
LIMIT 500;
|
|
|
|
-- name: DeleteComponent :exec
|
|
DELETE FROM core.components
|
|
WHERE id = $1 AND project_id = $2;
|
|
|
|
-- name: HasFingerprintSeen :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM core.events
|
|
WHERE project_id = $1 AND fingerprint = $2 AND type = 'error'
|
|
) AS seen;
|
|
|
|
-- name: InsertEvent :one
|
|
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *;
|
|
|
|
-- name: ListEvents :many
|
|
SELECT e.*, co.name AS component_name
|
|
FROM core.events e
|
|
LEFT JOIN core.components co ON co.id = e.component_id
|
|
WHERE e.project_id = $1
|
|
AND (sqlc.narg('type')::text IS NULL OR e.type = sqlc.narg('type'))
|
|
AND (sqlc.narg('component')::text IS NULL OR co.name = sqlc.narg('component'))
|
|
AND (sqlc.narg('fingerprint')::text IS NULL OR e.fingerprint = sqlc.narg('fingerprint'))
|
|
AND (sqlc.narg('before')::bigint IS NULL OR e.id < sqlc.narg('before'))
|
|
ORDER BY e.id DESC
|
|
LIMIT sqlc.arg('limit');
|
|
|
|
-- name: ListErrorGroups :many
|
|
SELECT e.fingerprint,
|
|
(array_agg(e.message ORDER BY e.ts DESC))[1]::text AS message,
|
|
coalesce((array_agg(e.component_id::text ORDER BY e.ts DESC))[1], '')::text AS component_id,
|
|
coalesce((array_agg(co.name ORDER BY e.ts DESC))[1], '')::text AS component_name,
|
|
count(*) AS count,
|
|
min(e.ts)::timestamptz AS first_seen,
|
|
max(e.ts)::timestamptz AS last_seen,
|
|
(array_agg(e.severity ORDER BY e.ts DESC))[1]::text AS severity
|
|
FROM core.events e
|
|
LEFT JOIN core.components co ON co.id = e.component_id
|
|
WHERE e.project_id = $1 AND e.type = 'error' AND e.fingerprint <> ''
|
|
AND e.ts > now() - (sqlc.narg('days')::text || ' days')::interval
|
|
GROUP BY e.fingerprint
|
|
ORDER BY max(e.ts) DESC
|
|
LIMIT 200;
|
|
|
|
-- name: EventTypeSeries :many
|
|
SELECT to_timestamp(floor(extract(epoch FROM e.ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
|
|
e.type,
|
|
count(*) AS count
|
|
FROM core.events e
|
|
WHERE e.project_id = $1 AND e.ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
|
|
GROUP BY bucket, e.type
|
|
ORDER BY bucket;
|
|
|
|
-- name: ComponentHealthRows :many
|
|
SELECT co.id, co.name, co.kind, co.meta,
|
|
max(e.ts)::timestamptz AS last_seen_at,
|
|
coalesce(((array_agg(e.payload->>'status' ORDER BY e.ts DESC)
|
|
FILTER (WHERE e.type = 'heartbeat'))[1]), '')::text AS last_status,
|
|
count(*) FILTER (WHERE e.type = 'error' AND e.ts > now() - interval '24 hours') AS errors_24h,
|
|
count(*) FILTER (WHERE e.ts > now() - interval '24 hours') AS events_24h
|
|
FROM core.components co
|
|
LEFT JOIN core.events e ON e.component_id = co.id
|
|
WHERE co.project_id = $1
|
|
GROUP BY co.id
|
|
ORDER BY co.name;
|
|
|
|
-- name: EventTotals :one
|
|
SELECT count(*) FILTER (WHERE type = 'error') AS errors,
|
|
count(*) AS events,
|
|
count(*) FILTER (WHERE type = 'metric') AS metrics
|
|
FROM core.events
|
|
WHERE project_id = $1 AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint);
|
|
|
|
-- name: MetricNames :many
|
|
SELECT DISTINCT (payload->>'name')::text AS name
|
|
FROM core.events
|
|
WHERE project_id = $1 AND type = 'metric' AND payload ? 'name'
|
|
ORDER BY 1
|
|
LIMIT 50;
|
|
|
|
-- name: MetricSeries :many
|
|
SELECT to_timestamp(floor(extract(epoch FROM ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
|
|
avg((payload->>'value')::float8)::float8 AS avg,
|
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p50,
|
|
percentile_cont(0.95) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p95,
|
|
max((payload->>'value')::float8)::float8 AS max,
|
|
count(*) AS count
|
|
FROM core.events
|
|
WHERE project_id = $1 AND type = 'metric' AND payload->>'name' = sqlc.arg('name')::text
|
|
AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
|
|
AND jsonb_typeof(payload->'value') = 'number'
|
|
GROUP BY bucket
|
|
ORDER BY bucket;
|