Catalisa.Building Blocks
Catálogo/Plataforma/Analytics

Analytics

Alpha

Contar a jornada do cliente sem guardar quem ele é

9
Endpoints
8
Entidades
0
Provedores
Tenant
Escopo
3041
Porta
2026-09
Desde

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.

Para quem é
  • 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
Substitui
  • 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
O que não é
  • 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)
01

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".

02

O problema

negócio

O 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.

03

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.
04

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 banking e o baas já 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.* em PENDING até o banking publicar. O catálogo declara TODO evento de crédito que o banking pode publicar — os próprios e a resposta de erro de cada método (banking.credit.<assunto>.refused|throttled|unknown|failed) — e catalog.test.ts deriva essa lista do código do banking: evento novo lá sem declaração aqui quebra o teste, em vez de virar unknown_event na qualidade. O limite de pedidos atingido (application.throttled) está declarado como desfecho policy, 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 em exits.policy.total junto com "recusado pela regra" e só se separa pelo código, em byReason.
  • 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.
05

Mercado e diferenciais

negócio

Mixpanel, 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.

06

Modelo de cobrança e ROI

negócio

Precificaçã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.

07

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.ts e src/analytics/jobs/retention.scheduler.ts — a retenção e o agendador dela (molde src/shared/lib/locked-pass.ts, o mesmo das varreduras do baas).
  • 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).
08

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):

TabelaPara quê
analytics_event_catalogEspelho do catálogo; retired_at quando o nome sai do código
analytics_eventsEvento cru normalizado. Único (organization_id, client_event_id); índices (organization_id, name, occurred_at), (organization_id, journey, journey_ref) e received_at
analytics_quality_dailyRecusa e perda de atributo por dia, evento e motivo
analytics_consumed_messagesMensagem do barramento já processada: (stream, message_id), organização e instante. Nada do evento
analytics_retention_policiesPrazo do cru de uma organização, quando difere do padrão. CHECK de 7 a 3650 dias
analytics_event_dailyAgregado 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_dailyFunil por coorte: jornadas que começaram no dia e o passo/desfecho delas dentro da janela; detail é o motivo da saída
analytics_job_stateAté 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).

09

Referência da API

RotaO que faz
GET /analytics/healthVivo; o estado de banco e Redis vai no corpo
GET /analytics/health/readyPronto para tráfego; reprova sem banco. O consumo aparece no corpo e não reprova
GET /analytics/health/consumerSaú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/funnelsOs funis declarados (chave, versão, unidade, janela)
GET /analytics/api/v1/funnels/:key?from=AAAA-MM-DD&to=AAAA-MM-DDO 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):

CampoO 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
exitsAs 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
conversionendToEnd (null enquanto o primeiro passo for cego, com note) e fromFirstMeasured
partialCohortsCoortes 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
anonymousSempre true: o funil responde taxa, não nome
Rota (M5)O que faz
GET /analytics/api/v1/health?from&toSaú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-DDOs números do dia e do anterior (declarados em HEADLINES); evento PENDING vem null, nunca 0
GET /analytics/api/v1/eventsLista 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/alertsAs 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/catalogO 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).

10

Início rápido

bash
bun run dev:analytics                         # porta 3041
curl -s localhost:3041/analytics/health
bun run dev:analytics                         # porta 3041
curl -s localhost:3041/analytics/health

Pelo código, o gravador é o ponto de entrada:

typescript
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'
11

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 em FUNNELS. Todo evento medido tem de estar no catálogo (há teste); tela sem evento entra como passo blind com o nome do evento que vai medi-la. Mudou a definição? Suba o version.

  • 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 teste catalog.test.ts confere). Mudou a definição de um evento existente? Suba o version.

  • Guardar um valor: declare um atributo band com from: '<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 em attribute_value_unlisted e o evento não se perde.

12

Integração com outros building blocks

  • Emissores (banking, baas): nada muda neles. O analytics é mais um grupo de consumo no iam-events (analytics-consumers), no padrão do webhooks-engine. Nenhuma alteração em EventPublisher. Como cada mensagem vira evento:

    Do barramentoViraRegra
    typenome do eventoSó 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)escopoSó 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 nenhuma
    id da mensagemclientEventId = 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 app
    payload.accountIdsubjectRefA conta do baas, opaca. Funil por conta (§15)
    payload.sessionIdsessionRefSessão do banking, quando houver
    payload.intentId, senão payload.transactionIdjourneyRefA intenção liga os passos do banking; a transação, os do baas
    chaves declaradas no catálogoattributesNormalizadas (faixa no lugar do valor)
    todo o resto do corpocontextVarrido 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) em analytics_consumed_messages e depois grava o evento ou soma a recusa. Reentrega (réplica caiu antes do XACK) e corrida (duas réplicas com a mesma mensagem) batem na marca e saem como duplicate; 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 baas entrar no funil (proposta, não feita aqui): o journeyRef dos eventos baas.pix.transfer.* é a transação do baas, que não carrega a intenção do banking. Mas o banking já grava a intenção na transação: chama o baas com businessId = banking:intent:<intentId> (src/banking/constants.ts), e o baas guarda a coluna business_id. A menor mudança é o baas publicar businessId no corpo dos quatro pix.transfer.* (pix.service, hold.service, webhook-handler.service, reconciliation.service: a transação já está em mãos nos quatro) e o mapeador do analytics ler businessId com prefixo banking:intent: como journeyRef. Valor opaco, sem dado pessoal. Até lá o funil fecha pelo desfecho do banking, que é o que o titular viu; o do baas é 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.

13

Configuração e operação

VariávelPadrãoPara quê
ANALYTICS_MAX_EVENT_AGE_HOURS72Evento mais velho que isso é recusado (occurred_at_too_old)
ANALYTICS_MAX_FUTURE_SKEW_SECONDS300Tolerância de relógio adiantado
ANALYTICS_BATCH_MAX100Teto do lote
MODULE_ANALYTICS_PORT3041Porta standalone
ANALYTICS_CONSUMER_ENABLEDtrueConsumo do iam-events. Ligado por padrão, escolha nossa: o processo existe para consumir. Só false/0/no/off desligam
ANALYTICS_DEAD_LETTER_MAX10000Teto 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_DELIVERIES20Entregas 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_SECONDS60Pendência ociosa há mais que isso, de qualquer réplica, é reivindicada (XAUTOCLAIM)
ANALYTICS_BUS_MAX_EVENT_AGE_DAYS30Evento 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_ENABLEDtrueRetençã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_DAYS90Prazo padrão do cru (mínimo 7, máximo 3650)
ANALYTICS_RETENTION_INTERVAL_MINUTES60Intervalo da varredura. A primeira passada roda 1 minuto depois da subida
ANALYTICS_AGGREGATION_ENABLEDtrueAgregação agendada. Ligada por padrão, escolha nossa: as telas leem o agregado
ANALYTICS_AGGREGATION_INTERVAL_MINUTES15Intervalo 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:

bash
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 schema
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 schema
sql
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;
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.

bash
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):

sql
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):

bash
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

SintomaCausaSaída
/analytics/health 200 e nada contadoAté o M5, o health não olhava o consumoLeia 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údeANALYTICS_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 consumidorNormal fora do bb@analytics; a pendência (do grupo inteiro) continua valendo
Pendência que não baixa, lastFailureReason: schema_missingFase de schema do Launch não rodouRode 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 pessoalChave com palavra pessoal (hasAccount, phone, otp) no corpo, mesmo fora dos atributosRenomeie no emissor (o M5 renomeou hasAccount→matched e as chaves do STATE_CHANGED no banking)
Todo aceite de uma versão de termo recusadoSHA-256 com 10+ dígitos seguidos lia como CPF/telefoneResolvido no M5: resumo hex minúsculo de 40–128 caracteres sai da varredura de dígitos
Passo do funil pendingEvento 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çãoEvento banking.credit.* sem applicationId no corpoContrato do emissor (funnels/receivables.ts): aceite e crédito carregam o pedido
Número da subconta sem recusasA recusa é da organização (acontece antes de saber o dono)Por desenho: refusals: null na leitura de subconta
14

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 o cms deixava passar (phoneNumber, pixKey, documentNumber). Campo terminado em Id/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, Bearer e 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.purged no iam-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 o DELETE não conta a linha que outra transação já apagou.
15

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 banking já publique. Os eventos banking.credit.* nascem PENDING no catálogo: a saúde mostra pending mesmo com evento chegando, e o funil responde "ainda não emitido" passo a passo. Os NOMES e os CAMPOS já batem com o que o banking publica (credit-published-bodies.test.ts roda o serviço de crédito real contra o catálogo): a faixa de valor lê faceCents na nota e netCents no crédito, o canal do aceite é app/desktop, e o desfecho acceptance_refused lê banking.credit.proposal.accept.refused. O que ainda impede virar para EMITTED é o identificador do pedido (applicationId, o journeyRef), que só application.requested leva:
    • proposal.accepted, acceptance.unknown, operation.credited e as respostas de erro do aceite saem com proposalId/operationId. O banking nã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, e banking.credit_acceptances não tem a coluna. Fechar isso é o serviço de crédito devolver applicationId na proposta, o banking gravá-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 publica application.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.viewed nã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 ser unknown_event e passou a invalid_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 de CREDIT_REFUSAL_CODE é conferida contra credit.service.ts; o que vem da sessão, do envelope ou do roteador (UNAUTHENTICATED, CAPABILITY_PAUSED…) vira OTHER e gera attribute_value_unlisted.
  • Resposta de erro não é deduplicada. Uma rajada de RATE_LIMITED vira 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/lastPollAt são de quem atendeu a requisição; pendência e mortas são do grupo inteiro (Redis).
  • Alertas só no papel. GET /alerts devolve 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/health e na métrica): zera na subida. O que persiste é analytics_quality_daily e a lista de mortas.
  • Mensagem que falha vai para as mortas depois de ANALYTICS_CONSUMER_MAX_DELIVERIES entregas, 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 baas publica pix.transfer.completed no webhook do provedor e na conciliação), são duas linhas com o mesmo journeyRef — o funil (M4) conta jornadas distintas, não linhas.
  • Funil anônimo: responde taxa, não nome. O subjectRef de evento de bloco será o id da conta no baas (opaco, sorteado por ele); a correspondência mora lá.
  • Os eventos de Pix do baas não trazem a intenção do banking, 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.
16

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.