API — sentry-storage
Referência do sentry-storage: pool Postgres, repositórios tipados, migrations, dedupe por payload_hash e LISTEN/NOTIFY
API — sentry-storage
sentry-storage é a persistência Postgres do Sentry: pool, migrations embutidas e repositórios tipados (events, incidents, ip_state, rules, routes, datasets) usados pelo daemon, pela CLI e pelo dashboard. Todas as queries usam sqlx::query() em runtime, então a crate compila sem um DATABASE_URL vivo. Declara #![forbid(unsafe_code)].
Pool — módulo pool
PgPool
pub struct PgPool { /* sqlx::PgPool */ }
pub async fn connect(cfg: &PostgresConfig) -> Result<Self>;
pub fn connect_lazy(url: &str) -> Result<Self>;
pub async fn listen(&self, channel: &str) -> Result<sqlx::postgres::PgListener>;
pub async fn notify(&self, channel: &str) -> Result<()>;PgPool respeita max_connections e acquire_timeout_secs do [storage.postgres]; connect_lazy adia a conexão (tooling/testes). listen usa uma conexão dedicada fora do pool — o LISTEN pode bloquear indefinidamente sem starvation. Erros ficam em StorageError (Config/Connect/Query/Migrate).
Repositórios — módulo repo
Repo
Repo carrega o pool e empresta os repos: events(), incidents(), ip_state(), rules(), routes(), datasets(). Todos os métodos de IP canonizam o endereço (Event::canonical_ip).
Tipos de linha
Cada tabela tem seu struct FromRow/serde — são eles que atravessam a fronteira daemon ↔ CLI ↔ dashboard:
| Tipo | Tabela | Notas |
|---|---|---|
EventRow | events | Linha completa: protocol/signals em JSONB, duration_ms/process_us |
EventInsert | events | Payload de insert em lote; payload_hash: Option<i64> alimenta o dedupe |
IncidentRow | incidents | client_ip, acknowledged_at, resolved, notes |
IpStateRow | ip_state | status, reason, expires_at (bloqueio com TTL) |
OffenderRow | ip_state | Projeção de strikes/total_violations/last_violation_at |
RuleRow | rules | match_expr textual (compilado pelo DSL no load_ruleset) |
RouteRow | routes | path + methods[]; implementa RouteLike |
DatasetRow | datasets | kind, source_url, action, enabled, entry_count |
EventRepo
Repositório da tabela events (EventRepo).
Escrita e dedupe cross-node:
pub async fn insert_with_hash(&self, evt: &Event, risk_score: u8, risk_level: RiskLevel,
verdict: Verdict, signals: &serde_json::Value, payload_hash: Option<i64>, process_us: Option<u64>) -> Result<()>;
pub async fn insert_batch_with_hash(&self, rows: &[EventInsert]) -> Result<()>;insert_with_hash faz INSERT condicional: quando payload_hash já foi persistido por um nó irmão na janela de dedupe (10 s), a linha é pulada — o LRU local não enxerga eventos de outros nós (F4.7). insert_batch_with_hash é a lane de persistência em lote do overload response (1 multi-INSERT por lote). EventInsert::from_event monta a linha; protocolo que falha ao serializar vira JSON null — lote não perde evento por um payload ruim.
Leitura e agregações:
| Método | Uso |
|---|---|
recent / recent_since | TUI e dashboard |
recent_for_ip | Popup de info de IP na TUI |
update_verdict | Feedback pós-AI/LLM |
count_by_level_since / count_by_verdict_since | /api/stats, relatórios |
queries_per_hour | Série temporal |
top_ips / top_paths / top_unknown_paths | Rodapé da TUI, report --unknown-paths |
posture_findings | sentry posture: agrega signals JSONB filtrando posture_advisory, group by host+detail (sem migration) |
incident_event_ids | Labels de sentry model export --confirmed |
IncidentRepo
Repositório da tabela incidents (IncidentRepo): create, get_or_create_for_event (idempotente por event_id — índice único parcial), open_incident_for_ip (coalescência: 1 incidente aberto por IP), unresolved, ack e resolve (round-trip dos webhooks com ack_url).
IpStateRepo
Repositório da tabela ip_state (IpStateRepo) — fonte da verdade de bloqueios e da memória de reincidentes:
| Método | Papel |
|---|---|
block / unblock | Escreve/remove bloqueio (com TTL) |
is_blocked / blocked | Consulta e listagem (pre-warm da BlockTable no startup) |
record_violation | Strike + total (janela configurável; strikes decaem fora da janela) |
offender / recent_offenders / reset_offender | sentry ip forgive e relatórios |
RuleRepo
Repositório da tabela rules (RuleRepo): load_ruleset compila as linhas (match_expr via DSL) num RuleSet carregado no startup; upsert, delete, list e set_enabled servem à CLI/dashboard.
RouteRepo
Repositório da tabela routes (RouteRepo): list, insert e delete. RouteRow implementa a trait RouteLike do core, então entra direto no RouteValidator::merge (config ∪ DB).
DatasetRepo
Repositório dos datasets DB-backed (F7.7, DatasetRepo) — listas curadas user_agent/path/ja3 que viram regras sintéticas feed:<name> e alimentam o prefilter dinâmico:
pub async fn upsert(&self, name: &str, kind: &str, source_url: Option<&str>,
action: &str, entries: &[String]) -> Result<()>;
pub async fn list(&self) -> Result<Vec<DatasetRow>>;
pub async fn entries(&self, name: &str) -> Result<Vec<String>>;
pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()>;
pub async fn delete(&self, name: &str) -> Result<()>;upsert substitui o dataset wholesale numa transação e dispara pg_notify no canal sentry_datasets_changed (constante); set_enabled e delete notificam igual — todos os nós hot-reloadam sem restart.
Migrations
Embutidas via sqlx::migrate! (migrations::run no startup). Arquivos em crates/sentry-storage/migrations/:
| Migration | Conteúdo |
|---|---|
20260101000000_init.sql | Tabelas events, incidents, rules, ip_state + índices base |
20260102000000_routes.sql | Tabela routes (hot-reloadable via LISTEN/NOTIFY) |
20260821000000_offenders.sql | strikes/total_violations/last_violation_at em ip_state (F2.12) |
20260930000000_incident_feedback.sql | incidents.client_ip + acknowledged_at + índice único em event_id (F4.5) |
20261001000000_ha.sql | events.payload_hash + índice parcial (dedupe cross-node, F4.7) |
20261002000000_datasets.sql | Tabelas datasets/dataset_entries (F7.7) |
20261003000000_event_timings.sql | events.duration_ms/process_us |
Dedupe e LISTEN/NOTIFY
O dedupe cross-node (F4.7) funciona assim: cada nó calcula o mesmo payload_hash que o LRU local usa (IP + método + path para HTTP, hash do raw caso contrário); o INSERT compara contra o índice parcial events_payload_hash_ts na janela de 10 s e pula duplicatas persistidas por irmãos. O índice é parcial (WHERE payload_hash IS NOT NULL) — eventos sem hash nunca pagam o lookup.
Canais de hot-reload por LISTEN/NOTIFY (produtores → tarefas LISTEN no daemon):
| Canal | Dispara |
|---|---|
sentry_rules_changed | Recompila o ruleset (daemon) |
sentry_datasets_changed | Reimporta datasets no pipeline e no prefilter (daemon; emitido pelo DatasetRepo) |
sentry_blocks_changed | Re-sincroniza a BlockTable entre nós (daemon) |
sentry_model_changed | Troca o ONNX a quente (daemon) |
O productor typical é a CLI/dashboard: mutação no Postgres + NOTIFY via PgPool::notify. A exceção é sentry_datasets_changed, emitido dentro do próprio DatasetRepo na mesma transação da escrita.
Notas de operação
EventRepo::inserté o wrapper single-row sem dedupe (delega ainsert_with_hashcompayload_hash = None) — caminhos de baixo volume; o caminho quente do daemon usa a lane em lote.RepoéClonebarato (só o handle do pool); cada repo emprestado também. Não há estado por repo — a fonte de verdade vive no Postgres, e caches locais (BlockTable, ruleset) são sincronizados pelos canais acima.- O módulo
errorre-exportaStorageError/Resultdefinidos empool.rs; toda falha de query viraStorageError::Querycom o erro do sqlx no texto.