# ADR-047 — Métricas privacy-first para o painel

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-23) |
| **Data** | 2026-10-23 |
| **Plano de origem** | `wiki/implementation-phases/P4-estatisticas.md` |
| **Relacionadas** | ADR-001, ADR-018, ADR-019, ADR-024, ADR-035 |

## Contexto

O painel precisa mostrar uso do portal, ranking de páginas, requisições por módulo, erros 404 e
métricas de conteúdo. Hoje só existe o dashboard com usuário/versão; Google Analytics é opt-in e não
serve como métrica própria. A trilha de auditoria (`core_audit_logs`) é evidência, não analytics.

O risco é criar um APM ou uma tabela de vigilância: IP, user-agent, URL com query string, cookie de
analytics, fingerprint ou sessão gravada. A LGPD e a ADR-019 exigem minimização, finalidade,
retenção e transparência.

## Decisão

1. **Agregados diários no PostgreSQL.** `core.core_metrics_daily`: `day`, `route_name`, `module`,
   `locale`, `status_class` (`2xx|3xx|4xx|5xx`), `requests`, `duration_sum_ms`, `duration_max_ms`,
   `bot_requests`; unique `(day, route_name, module, locale, status_class)` e índices por `day`,
   `(module, day)`, `(route_name, day)`.
2. **Únicos sem PII.** HyperLogLog no Valkey com chave diária e TTL (padrão 90 dias). O input é um
   HMAC de `IP + UA + day` com pepper versionado; o hash **nunca** vai para o PostgreSQL. Se um
   relatório precisar de únicos históricos, materializar apenas o inteiro no fechamento do dia.
3. **Dimensões fechadas.** Rota nomeada, módulo, locale, classe de status, duração e `is_bot`.
   **Nunca** IP cru, UA cru, query string, path com slug/ID pessoal, corpo, e-mail, protocolo ou
   identificador entre dias.
4. **Ingestão no fim da resposta.** Middleware `CollectRequestMetric` em `terminate`, após o
   response; `INSERT ... ON CONFLICT DO UPDATE` atômico; falha de métrica é **fail-open** (log, sem
   quebrar a resposta). Sem chamada de rede no hot path.
5. **Cards e relatórios.** `MetricRegistry`/`MetricCard` no Core; cada card declara permissão e
   módulo; card sem permissão ou de módulo inativo não aparece. Consulta com cache curto e query
   indexada; exceção de card não derruba o dashboard.
6. **Retenção e transparência.** `metrics:expunge` via `RetentionRegistry` (padrão 400 dias para
   agregados; HLL 90 dias), com `--dry-run` e relatório. Política de privacidade declara métricas
   agregadas, sem cookie de analytics; GA continua opt-in.
7. **Estatística pública (P12).** Agregados, sem PII e com supressão de células pequenas (ex.: <5)
   para evitar reidentificação. Não é a trilha de auditoria e não permite consulta individual.
8. **Não é APM.** Sem tracing de request, profiling, heatmap, gravação de sessão, fingerprint,
   cross-site tracking ou logs de acesso completos.

## Justificativa — alternativas descartadas

| Alternativa | Por que não |
|---|---|
| Parsear logs do servidor web | Não tem módulo/rota/locale confiáveis; retém IP/UA e exige pipeline externo. |
| Só Google Analytics | Terceiro, consentimento, amostragem e sem dados por módulo/permissão; não é métrica própria. |
| APM/session replay | PII e custo desproporcional; viola minimização. |
| Contar no request de forma síncrona | Adiciona latência e acopla disponibilidade da métrica à resposta. |
| Guardar hash/IP no banco | Desnecessário para agregado; aumenta risco e retenção. |

## Consequências

**Positivas**
- Métrica própria, barata e independente de terceiros.
- Nenhuma PII no banco; retenção e expurgo explícitos.
- Cards por permissão e relatórios previsíveis.

**Custos / riscos**
- Únicos são estimativa HLL; documentar que não servem para decisão financeira.
- `terminate` ainda executa trabalho; medir e manter leve.
- Supressão de célula pequena pode reduzir detalhe público; é intencional.

## Exemplo mínimo

```php
// ✅ agregado + HLL; nada de IP/URL no banco
$metric = MetricBucket::from($request, $response, $this->clock->now());
$this->metrics->increment($metric);
$this->uniques->add($day, hash_hmac('sha256', $ip.'|'.$ua, $pepper));
```

## Verificação

- Request público gera agregado; banco de métricas fora não quebra a resposta (fail-open).
- Nenhuma coluna/tabela contém IP, UA, query string ou e-mail.
- Card de módulo desativado/sem permissão não aparece; exceção é isolada.
- `metrics:expunge --dry-run` relata linhas; expurgo real respeita retenção.
- Relatório público não permite reidentificar usuário; supressão testada.
- `make check` 2× e `E2E=1` sem flaky.

## Referências

- `wiki/implementation-phases/P4-estatisticas.md`, `P12-transparencia-dados.md`;
- `wiki/adr/ADR-019-lgpd-programa.md`, `ADR-024-observabilidade-nativa-json-rfc7807.md`,
  `ADR-035-trilha-de-auditoria-e-evidencias.md`.
