Analytics
AlphaContar a jornada do cliente sem guardar quem ele é
Saber em que passo o cliente desiste, sem transformar a telemetria numa segunda base de dados pessoais. O Analytics conta eventos declarados, recusa o que tem cara de CPF, telefone ou e-mail, e diz quando a medição está quebrada em vez de desenhar uma queda que não houve.
- Banco digital ou fintech que precisa ler os funis do Pix e da abertura de conta sem ferramenta externa
- Operação regulada que não pode mandar evento de cliente para um SaaS de analytics estrangeiro
- Ferramenta externa de product analytics recebendo evento com dado do cliente
- Consulta ad hoc sobre a trilha de auditoria para montar funil
- Planilha de conversão montada à mão a partir de logs
- Trilha de auditoria — o que precisa ser provado continua no Audit Trail
- Data warehouse ou console de SQL livre
- Ferramenta de teste A/B
- Monitoramento técnico de latência e erro (isso é telemetria)
Resumo executivo
O Analytics responde "quantas pessoas chegaram a cada passo" — sessões abertas, intenções de Pix, quantas pediram selfie, quantas saíram, quantas foram recusadas — sem guardar nome, documento, contato, IP ou valor exato. Cada evento precisa estar declarado num catálogo, com a razão de ser contagem e não prova. O que tem cara de dado pessoal é recusado antes de gravar, e a recusa é contada, para que "ninguém usou" não se confunda com "o emissor manda o que não devia".
O problema
negócioO protótipo do aplicativo mandava os 40 eventos da jornada para a trilha de auditoria, inclusive "viu o saldo" e "viu o extrato". A trilha é cara de propósito: serializa por organização, guarda IP e agente de usuário, não agrega. Pôr o evento mais frequente do app atrás dela é pagar a prova para obter contagem — e ainda criar uma base de telemetria com dado pessoal.
A alternativa de mercado, mandar os eventos para um SaaS de analytics, tira o dado do cliente da nossa infraestrutura e deixa a guarda de dado pessoal na mão de quem instrumenta o app.
Proposta de valor
negócio- Prova e contagem separadas por regra escrita. Vai para a trilha o ato que alguém vai precisar provar; vem para cá o que alguém vai precisar contar. Evento que é os dois é publicado uma vez só, e os dois consumidores o leem.
- Nada de dado pessoal, por construção. Guarda no servidor e ausência de coluna.
- Recusa individual e contada. Um evento ruim não derruba o lote, e a recusa aparece por motivo.
- Sem valor exato. O valor de uma operação é gravado como faixa.
- O cru expira sozinho. Varredura agendada, por organização, desde a primeira versão que grava — não um método que alguém um dia vai chamar.
Casos de uso reais
negócio- Funil do Pix do banco digital (primeira leitura, M4): sessão aberta → intenção criada (com
ou sem selfie) → enviado, recusado ou desconhecido, com os eventos que o
bankinge obaasjá publicam. - Funil da abertura de conta (M5): CPF informado → dados → telefone confirmado → termos → selfie aprovada → conta aberta, com recusa por regra, código bloqueado e parada técnica separados do abandono; o revínculo (mesmo evento de início) fica fora.
- Funil da antecipação (M5, declarado): pedido → proposta vista → aceite → crédito na conta,
com os eventos
banking.credit.*emPENDINGaté obankingpublicar. O catálogo declara TODO evento de crédito que obankingpode publicar — os próprios e a resposta de erro de cada método (banking.credit.<assunto>.refused|throttled|unknown|failed) — ecatalog.test.tsderiva essa lista do código dobanking: evento novo lá sem declaração aqui quebra o teste, em vez de virarunknown_eventna qualidade. O limite de pedidos atingido (application.throttled) está declarado como desfechopolicy, mas não conta na coorte hoje: a resposta de erro não leva o identificador do pedido. É gravado e aparece no diário por evento e na saúde. Quando contar, soma emexits.policy.totaljunto com "recusado pela regra" e só se separa pelo código, embyReason. - Saúde da instrumentação (M5): quais eventos declarados chegam, quanto atrasam, o que foi recusado e por quê, se o consumo está vivo. É a única leitura com valor antes de haver cliente real.
- Lista operacional sem expor pessoa: o bloco devolve o identificador pseudônimo; a tradução para pessoa acontece no backoffice, pelo crachá do operador, e vai para a trilha.
Mercado e diferenciais
negócioMixpanel, Amplitude e PostHog resolvem funil e retenção com interface madura. O que nenhum deles faz por padrão é separar prova de contagem, recusar dado pessoal no servidor e afirmar o passo decidido pelo servidor a partir do próprio servidor. Aqui o catálogo é contrato (evento sem declaração é recusado), e a ausência de coluna para IP, agente de usuário, valor exato e texto livre é decisão documentada.
Modelo de cobrança e ROI
negócioPrecificação em definição. Os direcionadores prováveis são eventos gravados por mês e jornadas declaradas. O retorno vem de não contratar ferramenta externa por volume de evento e de não carregar uma segunda base de dado pessoal sob LGPD.
Arquitetura
src/analytics/catalog/— o catálogo em código (fonte da verdade), os tipos e a normalização.src/analytics/utils/personal-data.ts— a guarda.src/analytics/services/event-recorder.service.ts— o gravador, usado pelas portas de entrada.src/analytics/services/catalog-sync.service.ts— copia o catálogo para a tabela na subida.src/analytics/consumers/bus-event.ts— de mensagem do stream a evento (função pura): quais emissores se escutam, de onde vem a organização, onde moram sujeito, sessão e jornada no corpo.src/analytics/services/bus-ingest.service.ts— uma mensagem, exatamente uma vez.src/analytics/consumers/bus-consumer.ts— o laço do grupo de consumo.src/analytics/services/retention.service.tsesrc/analytics/jobs/retention.scheduler.ts— a retenção e o agendador dela (moldesrc/shared/lib/locked-pass.ts, o mesmo das varreduras dobaas).- Registro no container em
src/shared/container/modules/analytics.ts, sem@Service()em classe nenhuma do bloco (PR #488: classe não registrada vira erro na subida, não container injetado no lugar da dependência).
Conceitos e modelo de dados
Envelope. Todo evento traz clientEventId (chave do dedupe, por organização), name,
origin (APP ou BLOCK), occurredAt, subjectRef (opaco, obrigatório), sessionRef e
journeyRef (opcionais) e attributes. A organização e a subconta não vêm do evento: vêm do
token, da credencial ou do metadata do barramento.
Catálogo. Cada entrada tem nome, versão, classificação (TRAIL, ANALYTICS, BOTH), motivo,
origem esperada, bloco emissor, jornada, passo, estado (EMITTED, PENDING) e atributos tipados.
Os tipos de atributo são quatro: enumeração (com otherwise opcional), número com limites,
booleano e faixa. Não existe texto livre. Nesta fase o catálogo tem só eventos de bloco (DM1):
banking.session.opened|refused, banking.pix.intent.created,
banking.pix.transfer.completed|refused|unknown e baas.pix.transfer.initiated|completed|failed.
Tabelas (schema analytics):
| Tabela | Para quê |
|---|---|
analytics_event_catalog | Espelho do catálogo; retired_at quando o nome sai do código |
analytics_events | Evento cru normalizado. Único (organization_id, client_event_id); índices (organization_id, name, occurred_at), (organization_id, journey, journey_ref) e received_at |
analytics_quality_daily | Recusa e perda de atributo por dia, evento e motivo |
analytics_consumed_messages | Mensagem do barramento já processada: (stream, message_id), organização e instante. Nada do evento |
analytics_retention_policies | Prazo do cru de uma organização, quando difere do padrão. CHECK de 7 a 3650 dias |
analytics_event_daily | Agregado diário: organização, dono, dia (UTC do occurred_at), evento, dimensão ('' = total; senão um atributo declarado e o valor), eventos, sujeitos e jornadas distintos no dia |
analytics_funnel_daily | Funil por coorte: jornadas que começaram no dia e o passo/desfecho delas dentro da janela; detail é o motivo da saída |
analytics_job_state | Até onde o agregador leu (received_at) e quando passou por último |
Funil por coorte. Um funil é declarado em código (src/analytics/funnels/): evento de
início, janela de conversão, passos (medidos ou cegos) e desfechos com precedência e tipo
(success, policy, failure, unknown; sem desfecho é abandoned). A unidade é a jornada
(journeyRef); a coorte é o dia em que ela começou. Quem completa depois da janela não converte
— fica como abandono na coorte dele. Desfecho cuja jornada não tem início é órfão e marca o
passo de início como instrumentação quebrada.
O que expira e o que fica. Expira: o evento cru (analytics_events), no prazo da organização
(padrão 90 dias, contado do occurred_at), e a marca de mensagem do barramento, depois da janela
de reentrega. Fica para sempre: analytics_quality_daily, o catálogo e, quando existir (M4), o
agregado diário — nenhum deles identifica ninguém.
Motivos de recusa (um por evento): invalid_envelope, personal_data, unknown_event,
not_analytics (declarado só como trilha), origin_mismatch, occurred_at_too_old,
occurred_at_in_future, attribute_type, scope_mismatch (a organização do corpo diverge da do
metadata do barramento). Notas de qualidade (o evento entra):
attribute_undeclared (chave fora do catálogo, descartada) e attribute_value_unlisted (código
fora da lista que virou OTHER).
Referência da API
| Rota | O que faz |
|---|---|
GET /analytics/health | Vivo; o estado de banco e Redis vai no corpo |
GET /analytics/health/ready | Pronto para tráfego; reprova sem banco. O consumo aparece no corpo e não reprova |
GET /analytics/health/consumer | Saúde do consumo: 503 com consumo ligado e parado ou dez falhas seguidas. Para monitor/alerta — nunca como sonda do Launch |
GET /analytics/api/v1/funnels | Os funis declarados (chave, versão, unidade, janela) |
GET /analytics/api/v1/funnels/:key?from=AAAA-MM-DD&to=AAAA-MM-DD | O funil no período (coortes, UTC; padrão 30 dias, máximo 92) |
Leitura: JWT ou chave de API, permissão ANALYTICS_READ (papéis ADMIN e VIEWER) e
organização no token. O escopo é o do crachá — dono exato, como em people: a organização lê o
que é dela; a subconta (chave de subconta ou X-Subaccount-Id) lê o dela. Parâmetro de
organização na requisição é ignorado. Nenhuma rota lê o evento cru.
A resposta do funil (data):
| Campo | O que é |
|---|---|
steps[] | Cada passo: origin, optional, instrumented, journeys (null no passo cego — nunca 0 nem 100%), conversionFromPrevious, status (ok, no_instrument, instrumentation_broken) e note |
exits | As saídas separadas: policy (recusa da regra, por motivo), failure (falha técnica, por motivo), unknown e abandoned. Política não é abandono. Motivo com menos de minReasonCell (5) jornadas no período sai somado em OTHER_SUPPRESSED (k-anonimato: com período de um dia, "1 recusa por DAY_LIMIT" diz o motivo de alguém); o total não muda |
conversion | endToEnd (null enquanto o primeiro passo for cego, com note) e fromFirstMeasured |
partialCohorts | Coortes cuja janela ainda não fechou: o desfecho delas pode mudar |
context[] | Contagem de outra unidade ao lado (sessões abertas), nunca dividida pelo funil |
anonymous | Sempre true: o funil responde taxa, não nome |
| Rota (M5) | O que faz |
|---|---|
GET /analytics/api/v1/health?from&to | Saúde da instrumentação: evento a evento (ok, missing = declarado e nada no período, pending = declarado e ninguém publica), recusas por motivo (sem o backfill), backfill à parte, estado do consumo, agregação parada, possiblyIncomplete |
GET /analytics/api/v1/overview?day=AAAA-MM-DD | Os números do dia e do anterior (declarados em HEADLINES); evento PENDING vem null, nunca 0 |
GET /analytics/api/v1/events | Lista buscável do agregado: q (trecho do nome), journey, emitter, granularity=period|day, dimension (atributo declarado; célula < 5 vai para OTHER_SUPPRESSED), limit (≤ 200), offset, período. Filtro inexistente é 400 nomeando os que existem. Sujeitos e jornadas só por dia: entre dias não se somam |
GET /analytics/api/v1/alerts | As regras de alerta desenhadas e desarmadas (armed: false) e quantos dias de agregado faltam para os 90 da linha de base. Nada avalia nem envia |
GET /analytics/api/v1/catalog | O catálogo (nome, jornada, passo, estado, dimensões) |
O /analytics/health do processo agora inclui checks.consumer (última leitura, última mensagem,
falhas seguidas e motivo, pendência do grupo e idade da mais antiga, mortas): consumo ligado e
parado, ou dez falhas seguidas, é error e deixa o corpo degraded — sem reprovar o
/health/ready. O Launch sonda o ready e tira da rota a réplica que reprova: um consumo parado
(fase de schema que não rodou, por exemplo) derrubaria as rotas de leitura, inclusive a saúde da
instrumentação que mostra o problema (revisão do #545). O 503 do consumo mora em
/analytics/health/consumer. No monolito (src/main.ts), que não liga o consumidor, o check diz
hosted: false e só degrada.
Ainda não publicada: POST /analytics/ingest/v1/events (app, quando houver app emitindo).
Início rápido
bun run dev:analytics # porta 3041
curl -s localhost:3041/analytics/healthbun run dev:analytics # porta 3041
curl -s localhost:3041/analytics/healthPelo código, o gravador é o ponto de entrada:
const recorder = Container.get(AnalyticsEventRecorder)
const result = await recorder.record({ organizationId }, [{
clientEventId: 'stream:1759158000000-0',
name: 'banking.pix.intent.created',
origin: 'BLOCK',
occurredAt: new Date(),
subjectRef: accountId,
journeyRef: intentId,
attributes: { amountCents: 25_000, stepUp: 'selfie', recipientKnown: false },
}])
// → { accepted: 1, duplicated: 0, rejected: [] }; grava amountBand: '200_to_1000'const recorder = Container.get(AnalyticsEventRecorder)
const result = await recorder.record({ organizationId }, [{
clientEventId: 'stream:1759158000000-0',
name: 'banking.pix.intent.created',
origin: 'BLOCK',
occurredAt: new Date(),
subjectRef: accountId,
journeyRef: intentId,
attributes: { amountCents: 25_000, stepUp: 'selfie', recipientKnown: false },
}])
// → { accepted: 1, duplicated: 0, rejected: [] }; grava amountBand: '200_to_1000'Receitas
Ler o funil do Pix da última semana:
curl -H "Authorization: Bearer $TOKEN" "$BB/analytics/api/v1/funnels/pix?from=2026-10-01&to=2026-10-07".Declarar um funil: um arquivo em
src/analytics/funnels/, registrado emFUNNELS. Todo evento medido tem de estar no catálogo (há teste); tela sem evento entra como passoblindcom o nome do evento que vai medi-la. Mudou a definição? Suba oversion.Refazer um dia (erro de agregação corrigido):
Container.get(AnalyticsAggregationService) .recomputeDay(organizationId, dia). É recusado se o cru do dia já começou a vencer.Declarar evento novo: acrescente a entrada em
src/analytics/catalog/catalog.ts, com classificação e motivo. Se for de bloco, o nome tem de existir nas constantes do emissor (o testecatalog.test.tsconfere). Mudou a definição de um evento existente? Suba oversion.Guardar um valor: declare um atributo
bandcomfrom: '<campo>Cents'e as bordas; o valor exato nunca é gravado.Motivo que cresce no emissor: enumeração com
otherwise: 'OTHER'; o código novo é contado emattribute_value_unlistede o evento não se perde.
Integração com outros building blocks
Emissores (
banking,baas): nada muda neles. Oanalyticsé mais um grupo de consumo noiam-events(analytics-consumers), no padrão dowebhooks-engine. Nenhuma alteração emEventPublisher. Como cada mensagem vira evento:Do barramento Vira Regra typenome do evento Só emissores com evento no catálogo são escutados ( banking,baas). Mensagem de outro bloco é confirmada sem tocar no banco. Evento de emissor escutado fora do catálogo é recusado e contado (unknown_event)metadata.organizationId(+subaccountId)escopo Só do metadata. Sem organização válida: descartado, com log, sem gravar nem contar. Corpo com outra organização:scope_mismatch, não grava em nenhumaid da mensagem clientEventId=iam-events:<id>A mesma mensagem entregue de novo tem o mesmo id metadata.timestampoccurredAtHorizonte de ANALYTICS_BUS_MAX_EVENT_AGE_DAYS, não a janela de relógio do apppayload.accountIdsubjectRefA conta do baas, opaca. Funil por conta (§15)payload.sessionIdsessionRefSessão do banking, quando houverpayload.intentId, senãopayload.transactionIdjourneyRefA intenção liga os passos do banking; a transação, os dobaaschaves declaradas no catálogo attributesNormalizadas (faixa no lugar do valor) todo o resto do corpo contextVarrido pela guarda e nunca gravado. CPF num campo que ninguém lê recusa o evento do mesmo jeito Exatamente uma vez. Cada mensagem é processada numa transação que primeiro marca
(stream, message_id)emanalytics_consumed_messagese depois grava o evento ou soma a recusa. Reentrega (réplica caiu antes doXACK) e corrida (duas réplicas com a mesma mensagem) batem na marca e saem comoduplicate; se a transação cai, a marca cai junto e a reentrega refaz.Audit Trail: a fronteira é a regra de §3. Nada é derivado da trilha.
O que falta para o
baasentrar no funil (proposta, não feita aqui): ojourneyRefdos eventosbaas.pix.transfer.*é a transação dobaas, que não carrega a intenção dobanking. Mas obankingjá grava a intenção na transação: chama obaascombusinessId = banking:intent:<intentId>(src/banking/constants.ts), e obaasguarda a colunabusiness_id. A menor mudança é obaaspublicarbusinessIdno corpo dos quatropix.transfer.*(pix.service,hold.service,webhook-handler.service,reconciliation.service: a transação já está em mãos nos quatro) e o mapeador doanalyticslerbusinessIdcom prefixobanking:intent:comojourneyRef. Valor opaco, sem dado pessoal. Até lá o funil fecha pelo desfecho dobanking, que é o que o titular viu; o dobaasé a confirmação do provedor.CMS: a guarda de dado pessoal é um porte da varredura dele (
src/cms/utils/pii.ts), com os acréscimos de §14.Backoffice (painel): lê agregação pronta (M4, M5) e traduz pseudônimo para pessoa pelo crachá do operador.
Configuração e operação
| Variável | Padrão | Para quê |
|---|---|---|
ANALYTICS_MAX_EVENT_AGE_HOURS | 72 | Evento mais velho que isso é recusado (occurred_at_too_old) |
ANALYTICS_MAX_FUTURE_SKEW_SECONDS | 300 | Tolerância de relógio adiantado |
ANALYTICS_BATCH_MAX | 100 | Teto do lote |
MODULE_ANALYTICS_PORT | 3041 | Porta standalone |
ANALYTICS_CONSUMER_ENABLED | true | Consumo do iam-events. Ligado por padrão, escolha nossa: o processo existe para consumir. Só false/0/no/off desligam |
ANALYTICS_DEAD_LETTER_MAX | 10000 | Teto da lista de mortas. Cheia, a mensagem não entra nem é confirmada: fica pendente e volta a ser tentada (stats.deadLetterFull), e a saúde do consumo diz "lista de mortas cheia" |
ANALYTICS_CONSUMER_MAX_DELIVERIES | 20 | Entregas até a mensagem ir para a lista de mortas (analytics:dead-letter, com o teto de ANALYTICS_DEAD_LETTER_MAX). Com a reivindicação a cada 60 s, ~20 min tentando |
ANALYTICS_CONSUMER_CLAIM_IDLE_SECONDS | 60 | Pendência ociosa há mais que isso, de qualquer réplica, é reivindicada (XAUTOCLAIM) |
ANALYTICS_BUS_MAX_EVENT_AGE_DAYS | 30 | Evento do barramento mais velho que isso é recusado (occurred_at_too_old). Maior que a janela do app porque atraso aqui é fila parada, não relógio errado |
ANALYTICS_RETENTION_ENABLED | true | Retenção agendada do cru. Ligada por padrão, escolha nossa: o cru é pseudônimo e o agregado fica. Só false/0/no/off desligam — e desligada, o log diz em warn a cada subida |
ANALYTICS_RAW_RETENTION_DAYS | 90 | Prazo padrão do cru (mínimo 7, máximo 3650) |
ANALYTICS_RETENTION_INTERVAL_MINUTES | 60 | Intervalo da varredura. A primeira passada roda 1 minuto depois da subida |
ANALYTICS_AGGREGATION_ENABLED | true | Agregação agendada. Ligada por padrão, escolha nossa: as telas leem o agregado |
ANALYTICS_AGGREGATION_INTERVAL_MINUTES | 15 | Intervalo da agregação. A primeira passada roda 30 segundos depois da subida |
Permissão nova. ANALYTICS_READ entra nos papéis ADMIN e VIEWER. As permissões viajam no
JWT: quem já estava logado antes do deploy recebe 403 até renovar o token.
Schema no ambiente. As migrations 20261001090000_analytics_event_catalog,
20261001160000_analytics_consumed_messages e 20261001210000_analytics_retention são SQL cru,
aditivas e idempotentes. Elas não chegam ao ambiente sozinhas: rode a fase de schema do Launch (scripts/launch/). Sem as tabelas,
o serviço sobe e loga analytics: falha ao sincronizar o catálogo (a fase de schema rodou?), e
cada mensagem do barramento falha e fica pendente (analytics: mensagem não processada; fica pendente) — nada se perde; entra quando a tabela existir.
O consumo está rodando? Três conferências, nesta ordem:
journalctl -u bb@analytics | grep 'consumo do barramento' # "iniciado" (warn) ou "DESLIGADO"
redis-cli XINFO GROUPS iam-events # analytics-consumers: lag e pending
redis-cli XPENDING iam-events analytics-consumers # pendência que não baixa = banco ou schemajournalctl -u bb@analytics | grep 'consumo do barramento' # "iniciado" (warn) ou "DESLIGADO"
redis-cli XINFO GROUPS iam-events # analytics-consumers: lag e pending
redis-cli XPENDING iam-events analytics-consumers # pendência que não baixa = banco ou schemaSELECT count(*), max(consumed_at) FROM analytics.analytics_consumed_messages;
SELECT event_name, reason, count FROM analytics.analytics_quality_daily ORDER BY day DESC, count DESC;SELECT count(*), max(consumed_at) FROM analytics.analytics_consumed_messages;
SELECT event_name, reason, count FROM analytics.analytics_quality_daily ORDER BY day DESC, count DESC;A retenção está rodando? A subida diz em warn se ela foi agendada ou está DESLIGADA. Toda
passada deixa UMA linha (sweep: analytics.retention), inclusive quando não varreu — mas em info:
com o nível padrão de produção (warn) só aparecem falha, teto estourado e exceção. Para ver a
passada, LOG_LEVEL=info. O sinal que não depende do nível de log é a trilha: cada organização
com cru apagado publica analytics.retention.purged (organização, quantos, prazo e corte) no
iam-events — que o audit-trail grava quando o consumidor dele está ligado
(AUDIT_CONSUMER_ENABLED). A trilha guarda que a retenção rodou, não o que foi apagado.
journalctl -u bb@analytics | grep -E 'retenção|analytics.retention'journalctl -u bb@analytics | grep -E 'retenção|analytics.retention'Prazo próprio de uma organização (sem rota nesta versão; por SQL, na fase de schema ou à mão):
INSERT INTO analytics.analytics_retention_policies (organization_id, raw_event_days)
VALUES ('<uuid-da-organização>', 30)
ON CONFLICT (organization_id) DO UPDATE SET raw_event_days = EXCLUDED.raw_event_days, updated_at = now();INSERT INTO analytics.analytics_retention_policies (organization_id, raw_event_days)
VALUES ('<uuid-da-organização>', 30)
ON CONFLICT (organization_id) DO UPDATE SET raw_event_days = EXCLUDED.raw_event_days, updated_at = now();Primeira subida (e grupo recriado). O grupo nasce no começo do stream (0), como os outros
consumidores: lê o que foi publicado antes de o serviço existir. Mensagem de emissor não escutado é
confirmada sem ir ao banco. Mensagem publicada (o id do Redis é o instante) antes de
ANALYTICS_BUS_MAX_EVENT_AGE_DAYS é backfill: contada como backfill_skipped, que a saúde
mostra à parte e não soma às recusas. Mensagem recente com evento velho continua sendo
occurred_at_too_old (relógio ou fila do emissor).
Mensagens mortas. Mensagem que falha ANALYTICS_CONSUMER_MAX_DELIVERIES vezes é copiada para
analytics:dead-letter (id de origem, entregas, motivo, type/payload/metadata) e só então
confirmada: nada se perde. A lista tem teto (ANALYTICS_DEAD_LETTER_MAX, 10 000): cheia, a
mensagem não é copiada nem confirmada — fica pendente no grupo e volta a ser tentada, e a saúde diz
"lista de mortas cheia". Limitada e sem perda; o que a limita de fato é o MAXLEN do iam-events. A contagem aparece em /analytics/health (deadLetters) e na métrica.
Reprocessar, depois de consertar a causa (idempotente: o que já entrou sai como duplicate):
redis-cli XLEN analytics:dead-letter # quantas
redis-cli XRANGE analytics:dead-letter - + COUNT 5 # quais (tipo, entregas, motivo)redis-cli XLEN analytics:dead-letter # quantas
redis-cli XRANGE analytics:dead-letter - + COUNT 5 # quais (tipo, entregas, motivo)O reprocessamento não tem rota nesta versão: é pelo container, como o recomputeDay (§11) —
await Container.get(AnalyticsDeadLetterService).replay(100) devolve { examined, replayed, stillFailing }; a que falha de novo fica na lista.
Log com teto e métrica. Sem a fase de schema cada mensagem falhava igual e escrevia uma linha
(~600/min). Agora cada motivo (schema_missing, database_unreachable, unexpected, falha do laço)
escreve no máximo 5 linhas por minuto, e uma linha de resumo diz quantas foram suprimidas. A
contagem completa vai para a métrica analytics.consumer.messages{outcome, reason} (OpenTelemetry)
e para /analytics/health.
Armadilhas e troubleshooting
| Sintoma | Causa | Saída |
|---|---|---|
/analytics/health 200 e nada contado | Até o M5, o health não olhava o consumo | Leia checks.consumer.status ou /analytics/health/consumer (503 com consumo parado). O Launch continua sondando /analytics/health/ready, que não reprova pelo consumo: reprovar tiraria a leitura do ar |
| "lista de mortas cheia" na saúde | ANALYTICS_DEAD_LETTER_MAX atingido (fase de schema fora por horas, por exemplo) | Conserte a causa e reprocesse as mortas; as excedentes estão pendentes e entram sozinhas |
checks.consumer.problems diz "este processo não consome" | Rota servida pelo monolito, que não liga o consumidor | Normal fora do bb@analytics; a pendência (do grupo inteiro) continua valendo |
Pendência que não baixa, lastFailureReason: schema_missing | Fase de schema do Launch não rodou | Rode a fase de schema; a pendência entra sozinha. Passou de 20 entregas: reprocesse as mortas |
Recusa personal_data num evento que não tem dado pessoal | Chave com palavra pessoal (hasAccount, phone, otp) no corpo, mesmo fora dos atributos | Renomeie no emissor (o M5 renomeou hasAccount→matched e as chaves do STATE_CHANGED no banking) |
| Todo aceite de uma versão de termo recusado | SHA-256 com 10+ dígitos seguidos lia como CPF/telefone | Resolvido no M5: resumo hex minúsculo de 40–128 caracteres sai da varredura de dígitos |
Passo do funil pending | Evento declarado no catálogo, nenhum bloco publica (antecipação) | Nada a fazer aqui: quando o banking publicar, conta. A saúde lista os pending |
| Desfecho órfão na antecipação | Evento banking.credit.* sem applicationId no corpo | Contrato do emissor (funnels/receivables.ts): aceite e crédito carregam o pedido |
| Número da subconta sem recusas | A recusa é da organização (acontece antes de saber o dono) | Por desenho: refusals: null na leitura de subconta |
Segurança e compliance
A guarda (src/analytics/utils/personal-data.ts) recusa o evento inteiro — não mascara:
evento mascarado continua gravado e esconde que o emissor manda o que não devia.
- Por nome de campo, palavra a palavra (
camelCase,snake_case,kebab-case): documento, contato, nome, endereço, nascimento, conta, cartão, chave Pix, IP, agente de usuário e credencial (token, segredo, senha, nonce, assinatura, código de verificação). Pega o que ocmsdeixava passar (phoneNumber,pixKey,documentNumber). Campo terminado emId/Refé referência opaca e passa pelo nome, mas o valor continua varrido. - Chave em qualquer caixa ou colada (
CPFcliente,clientecpf,CPF,cpfId): as palavras fortes (documento, contato, nascimento, senha, segredo…) valem como trecho da chave compactada. - Por formato de valor, em qualquer campo — e na própria chave: CPF, CNPJ e telefone (10 a 14
dígitos com pontuação, inteiros ou embutidos num texto como
cliente 123.456.789-09), e-mail, JWT,Bearere segredo longo de caracteres mistos. Número também: CPF ou CNPJ com dígito verificador válido e celular com DDD. UUID e instante em milissegundos não contam como documento. - Até a profundidade 6, como o
cms. - Identificadores opacos (
clientEventId,subjectRef,sessionRef,journeyRef): formato fechado^[A-Za-z0-9._:-]{1,128}$, e nada com cara de e-mail, CPF ou telefone, nem embutido (cpf:12345678909). Hash de CPF não é pseudônimo (é reversível por dicionário); o sujeito é sorteado por quem é dono da identidade. - O log da recusa leva o caminho do campo, nunca o valor (chave que é o próprio dado vira
<key>). Na contagem de qualidade, nome fora do catálogo só entra inteiro se não tiver dígito nem palavra pessoal; senão vira<emissor>.(undeclared), e sem forma de nome,(invalid). - Não há coluna de IP, agente de usuário, valor exato ou texto livre (há teste de integração
que lê o
information_schema). - Retenção em dois tempos (LGPD, minimização). O cru pseudônimo vence no prazo da organização
(padrão 90 dias, piso de 7), apagado por varredura agendada; o que fica é contagem que não
identifica ninguém. Cada apagamento publica
analytics.retention.purgednoiam-events, para a trilha. Duas réplicas não apagam nem contam a mesma linha duas vezes: cada lote é reivindicado por trava consultiva da organização, e oDELETEnão conta a linha que outra transação já apagou.
Limitações conhecidas
- Dia é UTC. A coorte e o diário cortam à meia-noite UTC (21h em Brasília). Fuso por tenant é decisão em aberto.
- Antecipação declarada, não medida — embora o
bankingjá publique. Os eventosbanking.credit.*nascemPENDINGno catálogo: a saúde mostrapendingmesmo com evento chegando, e o funil responde "ainda não emitido" passo a passo. Os NOMES e os CAMPOS já batem com o que obankingpublica (credit-published-bodies.test.tsroda o serviço de crédito real contra o catálogo): a faixa de valor lêfaceCentsna nota enetCentsno crédito, o canal do aceite éapp/desktop, e o desfechoacceptance_refusedlêbanking.credit.proposal.accept.refused. O que ainda impede virar paraEMITTEDé o identificador do pedido (applicationId, ojourneyRef), que sóapplication.requestedleva:proposal.accepted,acceptance.unknown,operation.creditede as respostas de erro do aceite saem comproposalId/operationId. Obankingnão sabe de qual pedido vieram: a proposta e a operação que o serviço de crédito devolve não trazem o pedido, ebanking.credit_acceptancesnão tem a coluna. Fechar isso é o serviço de crédito devolverapplicationIdna proposta, obankinggravá-lo no aceite (coluna nova, com migration) e publicá-lo nos quatro eventos;- as respostas de erro do PEDIDO (
application.refused|throttled|failed) também saem sem ele, e aí há uma decisão de desenho antes do código: o pedido recusado nunca publicaapplication.requested, então o desfecho chegaria sem início (órfão). Ou o início passa a ser a tentativa, ou a recusa do pedido fica fora da coorte de propósito; proposal.viewednão é publicado por ninguém (a leitura da proposta não gera evento).
- Erro de crédito sem sessão ainda conta na qualidade. Quando o dono é resolvido só pelo host, o
evento sai sem
accountId: deixou de serunknown_evente passou ainvalid_envelope(sem sujeito), que segue na contagem de recusas. Há teste fixando isso (bus-ingest.test.ts). - Código de erro nascido fora do serviço de crédito cai em
OTHER. A lista deCREDIT_REFUSAL_CODEé conferida contracredit.service.ts; o que vem da sessão, do envelope ou do roteador (UNAUTHENTICATED,CAPABILITY_PAUSED…) viraOTHERe geraattribute_value_unlisted. - Resposta de erro não é deduplicada. Uma rajada de
RATE_LIMITEDvira uma linha por resposta. - Marco da jornada não tem ordem. Um passo-marco (dados, código, termos, selfie) conta se aconteceu na janela, antes ou depois do anterior; a invariante pega o marco que chega a menos.
- Saúde do consumo é da réplica que responde. Com duas réplicas,
running/lastPollAtsão de quem atendeu a requisição; pendência e mortas são do grupo inteiro (Redis). - Alertas só no papel.
GET /alertsdevolve as regras desenhadas; nada avalia nem entrega até haver 90 dias de agregado, retroteste e destinatário nomeado (spec, PR 11 a 13). - Evento que chega para um dia cujo cru já começou a vencer não entra no agregado. Refazer o
dia a partir de um cru incompleto apagaria o que o agregado guarda; o evento fica no cru até
vencer, e a passada conta o dia em
skippedExpired. - A coorte de um dia é montada em memória (uma linha por jornada). Serve ao volume de hoje; com milhões de intenções por organização e dia, a classificação vai para o SQL.
- Prazo por organização só por SQL. Não há rota para o tenant mudar o próprio prazo; quando houver, a mudança vira evento na trilha como o resto.
- A retenção conta do
occurred_at, não do recebimento: evento que chega atrasado vence no prazo de quando aconteceu. - Contagem do consumo é por processo (
stats, em/analytics/healthe na métrica): zera na subida. O que persiste éanalytics_quality_dailye a lista de mortas. - Mensagem que falha vai para as mortas depois de
ANALYTICS_CONSUMER_MAX_DELIVERIESentregas, nunca para o lixo. Numa queda longa de banco, tudo o que falhar 20 vezes vai para lá: reprocesse depois (idempotente). - Reentrega conta uma; republicação conta duas. A idempotência é por mensagem do stream. Se o
emissor publica o mesmo fato duas vezes (o
baaspublicapix.transfer.completedno webhook do provedor e na conciliação), são duas linhas com o mesmojourneyRef— o funil (M4) conta jornadas distintas, não linhas. - Funil anônimo: responde taxa, não nome. O
subjectRefde evento de bloco será o id da conta nobaas(opaco, sorteado por ele); a correspondência mora lá. - Os eventos de Pix do
baasnão trazem a intenção dobanking, então não entram no funil (proposta da menor mudança em §12). A intenção também não traz a sessão: as sessões abertas aparecem como contexto, não como passo. - Chave com palavra pessoal é recusada mesmo inocente (
fileName,displayName): o preço de varrer por palavra. O emissor renomeia; a recusa aparece na contagem de qualidade. - Número só é reconhecido como CPF/CNPJ pelo dígito verificador, ou como celular — e um valor em centavos de 10+ dígitos que por acaso feche o dígito é recusado. Abaixo de 10 dígitos, nada.
- Sequência de mais de 14 dígitos não é varrida (um CPF escondido no meio de 20 dígitos passa pelo formato). Não chega a ser gravado como atributo — só enumeração, número com limites, booleano e faixa são gravados —, mas num identificador opaco passaria.
- Nome de pessoa em identificador opaco (
maria.souza) não tem formato que o denuncie: a guarda é quem sorteia o identificador, não a varredura. - Catálogo global, não por tenant. É o contrato dos eventos de bloco; catálogo por tenant só se a ingestão do app pedir.
Perguntas frequentes
Por que não usar a trilha de auditoria? Porque ela é cara de propósito (lock por organização,
IP guardado, sem agregação). Tudo o que se derivaria dela já passa pelo iam-events antes.
Por que recusar em vez de mascarar? Máscara esconde o defeito do emissor. Recusa contada aparece na saúde da instrumentação e alguém conserta o emissor.
Onde está o catálogo, no banco ou no código? No código, versionado no repositório. A tabela é espelho para quem lê em SQL; o gravador não depende dela.
O que a retenção apaga? Só o evento cru, no prazo da organização (padrão 90 dias), e a marca
de mensagem do barramento. A contagem de recusa e, depois, o agregado diário ficam: não identificam
ninguém. E a retenção roda sozinha, agendada — no cms e no audit-trail ela existia como método
sem chamador.
Por que o funil diz "sem instrumento" em vez de um número? Porque a tela existe e nenhum evento a mede. Mostrar 100% ali seria mentir por omissão; mostrar 0, inventar uma queda.
Por que a conversão ponta a ponta vem null? A spec manda o denominador vir do app (quem
começou) e o numerador do bloco (quem terminou). O app ainda não emite; a conversão a partir do
primeiro passo medido vem em fromFirstMeasured.
Por que alertas não estão aqui? Alerta precisa de linha de base, e linha de base precisa de 90 dias de agregado (decisão DM3). Alerta ligado antes dispara sobre ruído.