API de analytics de produto: recebe eventos de aplicações clientes e responde perguntas sobre eles — quantos visitantes únicos, como a conversão caiu ao longo do funil, quantos voltaram na semana seguinte.
A ideia central do projeto: a parte difícil de analytics não é guardar evento — é responder pergunta agregada sobre milhões deles sem que o usuário desista de esperar. Por isso o seed gera 2 milhões de eventos: em base de brinquedo toda consulta responde em milissegundos, todo plano de execução parece bom e nenhuma decisão de índice se justifica.
- O problema
- A solução
- Stack
- Arquitetura
- Banco de dados
- Autenticação — dois caminhos
- Ingestão
- Relatórios
- Endpoints
- Exemplos de uso
- Como executar
- Variáveis de ambiente
- Estrutura de pastas
- Testes
- Dependências e segurança
- Performance
- Decisões e trade-offs
- Melhorias futuras
Um produto quer saber o que os usuários fazem dentro dele. As saídas comuns têm custos que aparecem depois:
| Caminho | O que dá errado |
|---|---|
| Ferramenta pronta (GA e afins) | Os dados brutos não são seus; amostragem em volume alto; pergunta fora do padrão não tem resposta |
console.log + planilha |
Não escala, não agrega, não sobrevive a um mês de tráfego |
| Guardar tudo e consultar na hora | Funciona com dez mil linhas. Com dez milhões, o relatório do painel vira um timeout |
O terceiro é o interessante, porque é onde estão as decisões de verdade: qual índice, qual pré-agregação, o que rejeitar antes de chegar ao banco.
Uma API com duas metades bem separadas:
Ingestão — um endpoint que aceita lotes de eventos de qualquer aplicação, autenticado por chave de API, idempotente e barato por evento.
Relatórios — cinco consultas agregadas sobre esses eventos, todas filtráveis
pelas mesmas dimensões e por propriedades customizadas, com o SQL analítico
concentrado num arquivo só (src/modules/reports/reports.queries.ts).
| Camada | Escolha | Por quê |
|---|---|---|
| Runtime | Node 22+, TypeScript, ESM | Tipos onde mais importam: no formato dos eventos e dos filtros |
| HTTP | Fastify 5 | O schema faz parte da rota — a mesma definição valida a requisição e gera o OpenAPI |
| Validação | Zod, via fastify-type-provider-zod |
Um schema só; request.query já chega tipado no handler |
| Banco | PostgreSQL 16 | Funções de janela, date_trunc com fuso, JSONB com índice GIN |
| Acesso a dados | Drizzle ORM | Tipagem do schema sem esconder o SQL: os relatórios são SQL escrito à mão |
| Docs | @fastify/swagger + Scalar |
Referência viva em /docs, derivada dos schemas |
| Testes | Vitest contra Postgres real | Mockar o banco invalidaria justamente o que se quer testar |
requisição
│
├─ plugins/auth.ts ............ JWT (painel) ou chave bk_ (ingestão)
├─ modules/<x>/routes.ts ...... schema Zod: valida a entrada e descreve a saída
├─ modules/<x>/service.ts ..... regra de negócio, sem saber que existe HTTP
├─ modules/reports/queries.ts . o SQL analítico
└─ plugins/error-handler.ts ... erro tipado → resposta JSON consistente
src/app.ts monta a aplicação e registra plugins e rotas; src/server.ts só
escuta a porta. É essa separação que permite os testes subirem a API sem ocupar
porta.
src/env.ts valida o .env com Zod no start: o processo não sobe com
configuração inválida, em vez de quebrar na primeira requisição que usar a
variável faltante.
users ──< projects ──< api_keys
│
├──< events (tabela de fatos, cresce sem limite)
└──< daily_event_rollup (pré-agregação derivada de events)
users, projects e api_keys são cadastro: milhares de linhas no máximo.
events é de outra natureza — é ela que dita todas as decisões técnicas.
name, occurred_at, anonymous_id, user_id, session_id,
path, referrer, country, device, browser, os, -- dimensões conhecidas
properties jsonb -- o restoAs duas saídas óbvias são ruins: tudo em coluna faz de cada campo novo que um
cliente quer enviar uma migration; tudo em JSONB transforma "agrupar por
país" em extrair e converter texto a cada linha, sem tipo, sem CHECK e com
índice maior e mais lento.
O detalhamento de cada tabela, índice e constraint está em docs/DATABASE.md.
| Quem | Como | Onde |
|---|---|---|
| Pessoa no painel | JWT via /v1/auth/login |
projetos, chaves e relatórios |
| Aplicação cliente | Chave bk_… no header |
POST /v1/events |
Senha usa bcrypt; chave de API usa SHA-256. Não é inconsistência. Bcrypt é lento de propósito porque senha humana tem pouca entropia e cada tentativa de adivinhação precisa custar caro ao atacante. Uma chave aqui tem 256 bits vindos do CSPRNG do sistema — não existe dicionário para isso, e como ela é verificada em toda requisição de ingestão, bcrypt custaria ~100 ms de CPU por evento recebido e viraria o gargalo da API inteira. SHA-256 com comparação em tempo constante é a escolha certa para segredo de alta entropia. A regra não é "bcrypt sempre", é trabalho proporcional à fraqueza do segredo.
A chave em claro aparece uma única vez, na resposta da criação. Depois resta só o prefixo visível, para o usuário saber qual chave é qual.
POST /v1/events aceita um evento ou um lote ({ "events": [...] }), até
MAX_EVENTS_PER_BATCH (500 por padrão).
Três decisões que valem por todo o endpoint:
Um INSERT com N linhas, não N inserts. Cada ida ao banco custa mais que a
gravação em si; 500 comandos separados passam a maior parte do tempo esperando
rede.
Deduplicação por ON CONFLICT DO NOTHING sobre o índice único parcial de
idempotency_key. A alternativa — consultar antes o que já existe e inserir o
resto — tem uma janela de corrida entre ler e escrever: dois lotes simultâneos
com a mesma chave passariam os dois pela verificação. Deixar o índice decidir
elimina a janela. Um SDK que reenvia por falha de rede manda a mesma chave, e o
banco descarta a segunda gravação sem erro.
Relógio de cliente não é confiável. Evento com data mais de 30 minutos no
futuro tem a data ajustada para agora, em vez de derrubar o lote inteiro na
CHECK constraint: perder a precisão de um timestamp é melhor que descartar 499
eventos válidos por causa de um.
A resposta diz exatamente o que aconteceu: received, accepted, duplicates.
Valor de propriedade customizada é escalar (string, número, booleano ou nulo). Aceitar objeto aninhado parece generoso, mas significa aceitar documento de profundidade arbitrária na tabela que mais cresce — e nenhum relatório sabe agrupar por um objeto.
Todos aceitam o mesmo conjunto de filtros — from, to, timezone (nome IANA,
validado contra o runtime), event, path, country, device, browser, os
e properties (JSON, aplicado com o operador de contenção @>, o único coberto
pelo índice GIN jsonb_path_ops).
| Relatório | Responde | Parâmetros próprios |
|---|---|---|
overview |
KPIs do período e do período anterior de mesma duração, com a variação — um número sozinho não diz nada, a variação diz | — |
timeseries |
Evolução ao longo do tempo | interval (hour/day/week/month), metric (events/visitors/sessions) |
breakdown |
Ranking por dimensão | dimension, limit (≤ 100) |
funnel |
Quantos passaram por cada etapa, na ordem | steps (2 a 8), window (ex.: 24h, 7d) |
retention |
Coortes: quantos voltaram depois | granularity (day/week), periods (≤ 30), cohortEvent, returnEvent |
A lista de dimensões do breakdown é fechada de propósito (event, path,
country, device, browser, os, referrer): o nome da dimensão vira parte
da expressão SQL, então aceitar string livre seria abrir a porta para injeção. A
única forma dinâmica é prop:<chave> — e aí a chave viaja como parâmetro ligado,
nunca concatenada.
Retenção não aceita granularidade de mês: o cálculo do índice do período divide por uma duração fixa, e mês não tem uma.
Uma API de analytics aceita perguntas arbitrárias, e algumas são caras demais:
- Orçamento de pontos (1000): pedir seis anos em granularidade de hora
geraria mais de 50 mil pontos — rejeitado com
RANGE_TOO_LARGEantes de qualquer ida ao banco. statement_timeoutde 30 s no pool: o erro57014do Postgres é traduzido emQUERY_TIMEOUT, com a orientação de reduzir o intervalo.- Funil de no máximo 8 etapas: cada etapa é mais uma CTE e mais um join.
- Rate limit por tipo de tráfego: 300 req/min no painel (origem humana), 6.000 req/min por chave na ingestão (origem: SDK).
POST /v1/auth/register POST /v1/auth/login GET /v1/auth/me
GET /v1/projects POST /v1/projects
GET /v1/projects/:projectId DELETE /v1/projects/:projectId
POST /v1/projects/:projectId/keys GET /v1/projects/:projectId/keys
DELETE /v1/projects/:projectId/keys/:keyId
POST /v1/events ← ingestão, autenticada por chave bk_
GET /v1/projects/:projectId/reports/overview
GET /v1/projects/:projectId/reports/timeseries
GET /v1/projects/:projectId/reports/breakdown
GET /v1/projects/:projectId/reports/funnel
GET /v1/projects/:projectId/reports/retention
POST /v1/projects/:projectId/rollup ← recalcula a pré-agregação
GET /docs ← referência interativa (Scalar)
Enviar um lote de eventos:
curl -X POST http://localhost:3333/v1/events \
-H "Authorization: Bearer bk_sua_chave_de_ingestao" \
-H "Content-Type: application/json" \
-d '{"events":[{"name":"checkout_concluido","anonymousId":"a1b2c3","path":"/checkout/sucesso","country":"BR","device":"mobile","properties":{"plano":"pro","valor":89.9},"idempotencyKey":"pedido-10432"}]}'{ "received": 1, "accepted": 1, "duplicates": 0 }Funil de três etapas, só para quem está no plano pro:
curl -G http://localhost:3333/v1/projects/$PROJECT_ID/reports/funnel \
-H "Authorization: Bearer $JWT" \
--data-urlencode "from=2026-07-01" \
--data-urlencode "to=2026-08-01" \
--data-urlencode "timezone=America/Sao_Paulo" \
--data-urlencode "steps=produto_visto,carrinho_adicionado,checkout_concluido" \
--data-urlencode "window=24h" \
--data-urlencode 'properties={"plano":"pro"}'Pré-requisitos: Node 22+ e Docker (para o Postgres).
cp .env.example .env # ajuste JWT_SECRET: openssl rand -hex 32
npm install
npm run docker:up # Postgres 16 na porta 5433 do host
npm run db:migrate
npm run db:seed # ~2.000.000 de eventos, 25 mil visitantes, 90 dias
npm run dev # http://localhost:3333 · docs em /docsO container sobe com shared_buffers=256MB, work_mem=32MB e
max_parallel_workers_per_gather=4: analytics faz varredura grande, e o padrão
de 4 MB de work_mem derrubaria as ordenações para disco.
A porta 5433 no host é proposital, para não conflitar com um Postgres já instalado na 5432.
| Variável | Padrão | Observação |
|---|---|---|
NODE_ENV |
development |
development | test | production |
PORT / HOST |
3333 / 0.0.0.0 |
|
LOG_LEVEL |
info |
níveis do pino |
DATABASE_URL |
— | obrigatória |
JWT_SECRET |
— | mínimo 32 caracteres, validado no start |
JWT_EXPIRES_IN |
1h |
|
MAX_EVENTS_PER_BATCH |
500 |
teto de 5000 |
INGEST_RATE_LIMIT_PER_MINUTE |
6000 |
por chave |
src/
server.ts sobe o processo
app.ts monta a aplicação e registra plugins e rotas
env.ts valida o .env com Zod
db/ schema.ts (Drizzle), migrate.ts, migrations/
plugins/ auth.ts, error-handler.ts
lib/ credentials.ts, errors.ts
modules/
auth/ register, login, me
projects/ projetos e chaves de API
ingest/ routes, schemas, service
reports/ routes, schemas e reports.queries.ts (o SQL)
scripts/ seed.ts, bench.ts, rollup.ts
tests/ auth, ingest, reports + helpers e global-setup
docs/ DATABASE.md, PERFORMANCE.md
npm test # vitest run
npm run typecheckSão testes de integração contra um Postgres real (banco beacon_test na
5433). Trocá-lo por um mock invalidaria justamente o que eles verificam:
date_trunc, funções de janela e fronteiras de fuso são executados pelo banco,
não pela aplicação.
Rodam em fork único, sem paralelismo (vitest.config.ts): os arquivos
compartilham o mesmo banco, e em paralelo um truncaria a tabela enquanto o outro
consulta — falha intermitente, o pior tipo de teste que existe.
O CI (.github/workflows/ci.yml) sobe um Postgres 16 como serviço e roda a mesma
suíte a cada push e pull request.
npm audit # estado atual
npm run verifica:deps # confere que as trocas de versão não mudaram o comportamentoUma auditoria encontrou três falhas críticas e duas altas, todas resolvidas por atualizações de versão maior:
| Pacote | De | Para | O que corrigiu |
|---|---|---|---|
@fastify/jwt |
9.x | 10.2.2 | Seis falhas no fast-jwt, entre elas bypass de autenticação com segredo HMAC vazio, confusão de algoritmo com chave RSA prefixada por espaço e vazamento de claims entre tokens por colisão de cache |
drizzle-orm |
0.38.x | 0.45.2 | SQL injection por escape incorreto de identificadores |
vitest |
2.x | 4.1.11 | Leitura e execução de arquivo arbitrário quando o servidor de UI está escutando; path traversal no Vite |
Atualização de versão maior não é troca de número: npm audit diz que a falha
sumiu, não que o código continua funcionando. Como a suíte de integração exige
um Postgres no ar, scripts/verifica-dependencias.ts cobre o essencial sem
banco nenhum — ele registra o plugin de JWT de verdade e usa .toSQL() do
Drizzle para inspecionar o SQL gerado:
- token assinado é verificado e devolve o mesmo payload;
- token adulterado é rejeitado;
- token com
alg: noneé rejeitado; - identificadores saem entre aspas duplas;
- valores saem como
$1, nunca interpolados no texto da consulta; - um payload de injeção (
'; DROP TABLE users; --) vira parâmetro, e a string não aparece em lugar nenhum do SQL.
O último item é o que fecha o ciclo: era exatamente a geração de identificadores que estava vulnerável, e é ela que o teste inspeciona.
Sobraram quatro falhas moderadas, todas a mesma cadeia:
drizzle-kit → @esbuild-kit/esm-loader → esbuild.
A correção que o npm audit fix --force propõe é rebaixar o drizzle-kit
de 0.31 para 0.18 — quinze versões para trás, quebrando a geração de migrations.
Não vale a pena, por três motivos:
drizzle-kité dependência de desenvolvimento; não vai para produção;- a falha do
esbuildsó existe com o servidor de desenvolvimento dele no ar, e odrizzle-kitusa oesbuildapenas para empacotar o arquivo de configuração — nunca sobe servidor; - a cadeia vem de cima: não há versão do
drizzle-kitque a resolva hoje.
Saber quando não aplicar a correção sugerida faz parte do trabalho. Um
npm audit limpo obtido às custas de quinze versões de regressão seria um
número melhor e um projeto pior.
npm run bench roda cada consulta uma vez para aquecer o cache (a primeira
execução mede o disco, não a consulta), depois mede cinco vezes, registra a
mediana e imprime EXPLAIN (ANALYZE, BUFFERS). A metodologia, as três
decisões que mais importam (ordem do índice composto, escopo da pré-agregação e
ANALYZE depois da carga) e o que ficou de fora estão em
docs/PERFORMANCE.md.
A tabela de resultados desse documento ainda está com
_a medir_: o benchmark precisa rodar sobre a massa do seed para ser preenchido.
O rollup diário só é usado quando é honesto usá-lo. São duas perguntas diferentes, e as duas precisam ser respondidas com sim.
A primeira é sobre o formato da pergunta: granularidade ≥ dia, fuso UTC,
métrica diferente de sessions, únicos apenas em granularidade de dia
(visitantes únicos não são somáveis) e sem filtro por dimensão que ele não tem.
Fora dessas condições ele mentiria por construção.
A segunda é sobre os dados: o rollup tem esse período processado? A tabela
rollup_coverage registra todo dia que a pré-agregação percorreu, inclusive os
dias que não tiveram evento nenhum — e é essa distinção que importa. Olhar só as
linhas de daily_event_rollup não serviria, porque a ausência de linha lá é
ambígua entre "nunca processado" e "processado e vazio", e as duas situações
pedem respostas opostas.
Sem essa segunda verificação, um rollup vazio ou atrasado devolvia zeros, sem erro e sem aviso: o gráfico mostrava um período sem tráfego que na verdade estava cheio de eventos. Na dúvida, a consulta cai para a tabela bruta — mais lento e certo é melhor que rápido e errado.
from e to nomeiam os períodos; timezone diz onde cai a fronteira do dia.
Pedir from=2026-07-01, to=2026-08-01 com timezone=America/Sao_Paulo
devolve os dias de julho agrupados pela meia-noite de São Paulo — e não um
período de 30 de junho, que é o que sairia se os limites fossem convertidos de
fuso antes de virarem a régua do gráfico.
Limitação conhecida. O filtro de período (
WHERE occurred_at >= from AND < to) é aplicado sobre instantes, não sobre a fronteira local do dia. Num fuso diferente de UTC, isso deixa o último período do intervalo incompleto pela diferença do fuso — três horas, no caso de São Paulo. Os períodos do meio são exatos. Resolver isso significa converterfrometopara o fuso pedido em todos os relatórios, e não só na série temporal; está na lista de melhorias.
| Decisão | O que ganhamos | O que abrimos mão |
|---|---|---|
| Colunas + JSONB, em vez de um dos extremos | Agrupamento rápido nas dimensões conhecidas e liberdade no resto | Duas formas de guardar atributo — é preciso decidir em qual metade cada campo entra |
| SQL à mão nos relatórios, com Drizzle só no schema | Controle sobre plano de execução e uso de índice | Consulta analítica não ganha a checagem de tipo do ORM |
INSERT em vez de COPY na ingestão |
Deduplicação com ON CONFLICT |
COPY seria mais rápido em carga massiva — faz sentido para importação, não para tempo real |
| SHA-256 nas chaves de API | A autenticação de ingestão não vira gargalo | Só serve para segredo de alta entropia; não para senha escolhida por humano |
| Pré-agregação com escopo restrito | Resposta rápida no caso comum sem mentir nos números | Mais código: duas fontes possíveis para a mesma pergunta |
| Testes contra Postgres real | Testa o que de fato executa a lógica | Suíte mais lenta e dependente de banco no CI |
| Valor de propriedade só escalar | Índice GIN pequeno; toda propriedade é agrupável | Nada de objeto aninhado em properties |
- Preencher a tabela de resultados de docs/PERFORMANCE.md rodando
npm run bench - Particionamento de
eventspor mês, quando a tabela justificar - Rollup incremental agendado, em vez de recálculo sob demanda
- SDK JavaScript de ingestão, com fila e reenvio usando
idempotencyKey - Painel web consumindo os relatórios
- Exportação em CSV dos relatórios
- Aplicar
from/tona fronteira local do dia em todos os relatórios, não só na régua da série temporal
MIT.