06 — Tracking de Comportamento e Atribuição de Marketing
Guia técnico completo: arquitetura Opção B (GTM no website, GA4 + Hotjar no app), setup prático e atribuição cross-domain.
Arquitetura — Opção B (padrão do boilerplate)
| Superfície | Loader no código | Tags configuradas onde | Env (build-time) |
|---|---|---|---|
| Website (marketing) | GTM container | GA4, Google Ads, Hotjar dentro do GTM | NEXT_PUBLIC_GTM_ID |
| App (produto) | GA4 gtag direto | Eventos de funil, user_id, billing | VITE_GA4_ID |
| App (UX) | Hotjar direto | Heatmaps e gravações no dashboard | VITE_HOTJAR_ID |
| Ambos | Código first-party | UTMs + signup_attribution no Postgres | — |
Website App (dashboard)
──────── ───────────────
GTM-XXXX (1 snippet) gtag.js → GA4 G-app
├─ GA4 G-website Hotjar (site ID separado)
├─ Google Ads AW-…
└─ Hotjar (site ID website)
│
│ dataLayer: page_view, cta_click, utm_captured…
▼
buildSignUpUrl() ──redirect──▶ sign_up, purchase, user_id
│ │
└─ signup_attribution ─────────┘ → Postgres (fonte comercial)
Por quê separar website e app?
- Website: marketing muda tags (Ads, pixels) sem deploy → GTM.
- App: eventos críticos (
sign_up,purchase) precisam ser confiáveis → GA4 no código. - Hotjar nos dois: website = landing/pricing; app = onboarding, billing, UX logado (IDs separados no painel Hotjar).
- Postgres
signup_attribution: imune a adblock; fonte de verdade para vendas/CRM.
Setup prático — passo a passo
1. Contas e properties
| Ferramenta | O que criar | Quantidade |
|---|---|---|
| GA4 | Property Website + Property App | 2 (IDs G-… diferentes) |
| GTM | Container Web para o domínio do website | 1 (GTM-…) |
| Hotjar | Site Website + Site App | 2 (IDs numéricos diferentes) |
| Google Ads | Conta + conversões importadas do GA4 App | 1 |
2. Website — GTM
- tagmanager.google.com → Create container → Web → domínio do website.
- Copie
GTM-XXXXXXX→ Dokploy Build Arguments:NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX. - Deploy do website; confirme no DevTools:
window.dataLayerexiste egtm.jscarregou. - Dentro do GTM, crie tags:
| Tag | Tipo | ID / config | Trigger |
|---|---|---|---|
| GA4 Config | Google Tag | Measurement ID G-website | All Pages |
| GA4 Event — page_view | GA4 Event | event page_view | Custom Event = page_view |
| GA4 Event — cta_click | GA4 Event | event cta_click | Custom Event = cta_click |
| GA4 Event — utm_captured | GA4 Event | event utm_captured | Custom Event = utm_captured |
| Google Ads Linker | Conversion Linker | — | All Pages |
| Hotjar | Custom HTML ou template | Site ID do website | All Pages |
-
Cross-domain: na tag GA4 Config → Fields to Set →
linkercom domínio do app (brandConfig.domains.app). O código já enviacross_domain_domainsno dataLayer inicial. -
Variáveis GTM úteis (Data Layer Variables):
utm_source,utm_campaign,page_type,funnel_step,session_id. -
Publish container → teste com Tag Assistant.
Eventos que o código envia ao dataLayer (website):
page_view, utm_captured, session_start, cta_click, pricing_page_view, lead, scroll, page_context, ads_conversion, etc.
Crie um trigger Custom Event por evento que quiser no GA4. Para começar, configure pelo menos: page_view, cta_click, utm_captured.
3. App — GA4 + Hotjar
- GA4 → nova property App → copie
G-…→ Dokploy Build Arguments:VITE_GA4_ID=G-…. - Hotjar → New site → URL do app → copie Site ID →
VITE_HOTJAR_ID=1234567. - Deploy do app.
- No GA4 App, marque como conversões:
sign_up,begin_checkout,purchase. - No Google Ads → Import → conversions from GA4 property App.
- Hotjar app: ative heatmaps em
/onboarding,/settings/billing, fluxo de signup.
O app identifica usuário logado no Hotjar (hj('identify', userId)) automaticamente.
4. Campanhas — como a atribuição funciona
Links de campanha sempre com UTMs:
https://framework.forestbit.io/pricing?utm_source=google&utm_medium=cpc&utm_campaign=launch_q3
- GA4/GTM: lê UTMs +
gclid(Google Ads preenche automaticamente). - Código: first-touch 30 dias em
localStorage→ repassa no sign-up →users.signup_attribution. - Não coloque GA4/Ads/Hotjar IDs no código do website — só
GTM-XXXX.
5. Checklist de validação
[ ] Website: GTM carrega (Network → gtm.js)
[ ] Website: dataLayer recebe page_view com UTMs (?utm_source=test)
[ ] Website → App: link de sign-up inclui UTMs (buildSignUpUrl)
[ ] App: GA4 Realtime mostra sign_up após cadastro teste
[ ] App: Hotjar grava sessão no dashboard
[ ] Postgres: signup_attribution preenchido após register
[ ] Google Ads: conversão importada do GA4 App (após 24–48h)
6. Variáveis de ambiente (resumo)
| App | Variável | Onde configurar |
|---|---|---|
| website | NEXT_PUBLIC_GTM_ID | Dokploy Build Arguments |
| app | VITE_GA4_ID | Dokploy Build Arguments |
| app | VITE_HOTJAR_ID | Dokploy Build Arguments |
Templates: apps/website/.env.example, apps/app/.env.example, cloud/dokploy/environments/*.env.example.
Visão geral do fluxo
Website (anônimo) App (logado)
───────────────────────────────── ──────────────────────────────
1. Visitante chega via busca/anúncio
2. UTMTracking.tsx captura utm_* (first-touch)
→ localStorage fb_utm_attribution (30 dias)
3. ReferrerTracking.tsx salva referrer
→ sessionStorage (sessão GA4 ~30 min)
→ localStorage first-touch (30 dias)
4. GA4 eventos: page_view, scroll,
pricing_page_view, cta_click
5. Usuário clica em "Criar conta"
→ buildSignUpUrl() anexa UTMs +
visitor_id + referrer como query params
────────── redirect ──────────────────▶
6. /sign-up monta:
captureAndGetAttribution()
lê URL params → localStorage
(TTL 30 dias)
7. Usuário preenche o form
8. onSubmit:
- getStoredAttribution()
- POST /auth/register + attribution
- gtag('set', 'user_id', id)
- trackSignUp('email') → GA4
- clearAttribution()
9. Service persiste
users.signup_attribution
Componentes do sistema
Website (apps/website)
| Arquivo | Responsabilidade |
|---|---|
src/components/GoogleTagManager.tsx | Carrega GTM (NEXT_PUBLIC_GTM_ID) |
src/components/WebsiteTracking.tsx | Bundle: GTM + UTM/referrer/session tracking |
src/lib/tracking.ts | dataLayer.push — eventos para triggers GTM |
src/lib/attribution.ts | First-touch UTMs em localStorage (fb_utm_attribution, TTL 30 dias); visitor_id; migração do legado sessionStorage |
src/components/UTMTracking.tsx | Captura UTMs → dataLayer + localStorage (30 dias) |
src/components/ReferrerTracking.tsx | Referrer de sessão em sessionStorage (GA4 ~30 min) + first-touch em localStorage (30 dias) |
src/lib/build-signup-url.ts | Lê attribution do localStorage + visitor_id → monta URL de sign-up com todos os params |
src/hooks/useGoogleAnalytics.ts | Hook para disparar eventos GA4 no website |
src/hooks/useSalesFunnelTracking.ts | Eventos de funil: lead, cta_click, pricing_page_view, demo_request, etc. |
App (apps/app)
| Arquivo | Responsabilidade |
|---|---|
src/config/analytics.ts | VITE_GA4_ID, VITE_HOTJAR_ID, cross-domain domains |
src/shared/components/tracking/google-analytics.tsx | Carrega GA4 gtag (produto) |
src/shared/components/tracking/hotjar.tsx | Hotjar in-app + identify após login |
index.html | Shell — scripts injetados via React |
src/shared/lib/attribution.ts | Lê params da URL, persiste/lê de localStorage (TTL 30 dias), exporta SignupAttribution |
src/features/auth/routes/(auth)/sign-up.tsx | Chama captureAndGetAttribution() ao montar → salva attribution no localStorage |
src/features/auth/components/sign-up-form.tsx | No submit: lê attribution, envia para service, chama trackSignUp(), seta user_id no GA4 |
src/features/auth/api/auth-endpoints.ts | Endpoint register aceita attribution?: SignupAttribution |
src/shared/hooks/use-google-analytics.ts | Seta user_id, company_id, plan_name no GA4 após login |
src/shared/hooks/use-sales-funnel-tracking.ts | Eventos: sign_up, login, plan_selected, begin_checkout, purchase, etc. |
Service (apps/service)
| Arquivo | Responsabilidade |
|---|---|
src/modules/auth/dto/register.dto.ts | attribution?: Record<string, string> — campo opcional |
src/modules/auth/auth.service.ts | Persiste signup_attribution na mutation insert_users_one |
Banco de dados (apps/hasura)
| Tabela/Coluna | Tipo | Descrição |
|---|---|---|
users.signup_attribution | jsonb | Attribution capturada no momento do cadastro |
Migration: apps/hasura/migrations/default/00000000000002_add_signup_attribution/
Estrutura de dados da attribution
interface SignupAttribution {
utm_source?: string // ex.: "google", "facebook", "newsletter"
utm_medium?: string // ex.: "cpc", "organic", "email"
utm_campaign?: string // ex.: "pricing_launch_q3"
utm_content?: string // ex.: "banner_hero", "link_blog"
utm_term?: string // ex.: "saas boilerplate"
visitor_id?: string // UUID gerado no primeiro acesso ao website
landing_page?: string // ex.: "/pricing"
referrer?: string // URL de onde o usuário veio antes do website
captured_at: string // ISO timestamp
}
Janelas de atribuição (TTL)
| Camada | Storage | TTL | Modelo | Uso |
|---|---|---|---|---|
| UTMs no website | localStorage (fb_utm_attribution) | 30 dias | First-touch — não sobrescreve campanha já salva | CTAs, buildSignUpUrl(), GA4 page views |
| Referrer comercial | localStorage (mesmo blob) | 30 dias | First-touch | Persistido no signup |
| Referrer sessão GA4 | sessionStorage (ga_page_referrer) | 30 min | Sessão GA4 | Apenas contexto de page_view no website |
| Attribution pré-signup (app) | localStorage (fb_signup_attribution) | 30 dias | Last URL params recebidos em /sign-up | Até concluir cadastro |
| Signup definitivo | Postgres users.signup_attribution | Permanente | Snapshot no momento do register | Vendas, SQL, CRM |
Por que 30 dias? Alinhado ao padrão de mercado para SaaS PLG (Google Ads default, HubSpot first-touch, funis B2B). O antigo sessionStorage com 30 min era adequado só para sessão GA4, não para atribuição comercial.
First-touch no website: se o usuário chega via utm_source=google e depois via utm_source=facebook dentro de 30 dias, a campanha creditada continua sendo Google.
Website (via GTM → GA4 property website)
| Evento | Quando | Onde |
|---|---|---|
page_view | Toda navegação | UTMTracking.tsx |
utm_captured | UTMs presentes na URL | UTMTracking.tsx |
session_start | Início de sessão | HeroSection.tsx |
first_visit | Primeira visita | HeroSection.tsx |
cta_click | Clique em botão de CTA | CTASection, HeroSection, etc. |
pricing_page_view | Acesso à página de preços | PricingSection.tsx |
demo_request | Formulário de demo | useSalesFunnelTracking |
lead | Preenchimento de formulário | useSalesFunnelTracking |
scroll | Profundidade de scroll | UTMTracking.tsx |
App (GA4 property app — VITE_GA4_ID)
| Evento | Quando | Onde |
|---|---|---|
sign_up | Cadastro concluído | sign-up-form.tsx |
login | Login concluído | user-auth-form.tsx, google-auth-button.tsx |
company_created | Criação de empresa no onboarding | useSalesFunnelTracking |
pricing_page_view | Acesso ao pricing interno | useSalesFunnelTracking |
plan_selected | Seleção de plano | useSalesFunnelTracking |
begin_checkout | Início do checkout | useSalesFunnelTracking |
purchase | Assinatura confirmada (Stripe) | use-subscription-callback.ts |
subscription_canceled | Cancelamento | useSalesFunnelTracking |
User properties no GA4 (app)
Após login, use-google-analytics.ts seta:
user_id, user_email, company_id, company_name,
subscription_status, plan_name, plan_cycle
Cross-domain: como website e app se conectam
Mecanismo 1 — GA4 Cross-Domain Linker
O app configura linker.domains com o website (brandConfig.domains). O GTM/GA4 no website deve configurar linker para o domínio do app. Ao clicar em links entre domínios, o GA4 anexa ?_gl=....
Nota: Website e app usam properties GA4 separadas. O linker ajuda na continuidade de sessão por property; a jornada unificada para vendas vem do mecanismo 2 (Postgres).
Mecanismo 2 — Attribution por URL (principal, backend)
É a abordagem confiável e source-of-truth para vendas:
website → buildSignUpUrl() → app/sign-up?utm_source=google&visitor_id=abc...
↓
captureAndGetAttribution() → localStorage
↓
POST /auth/register { attribution: {...} }
↓
users.signup_attribution = { utm_source: 'google', ... }
Esse mecanismo funciona independentemente do GA4 e persiste no banco de dados.
Mecanismo 3 — visitor_id first-party
buildSignUpUrl() gera um visitor_id (UUID) e o persiste no localStorage do website. Ele é passado como query param para o app. Se o mesmo usuário visitar o website múltiplas vezes antes de cadastrar, o mesmo ID é reusado.
Onde obter os dados
1. Banco de dados (Postgres) — mais rico e confiável
-- Usuários por fonte (utm_source)
SELECT
signup_attribution->>'utm_source' AS source,
COUNT(*) AS total_signups
FROM users
WHERE signup_attribution IS NOT NULL
GROUP BY 1
ORDER BY 2 DESC;
-- Usuários que vieram de campanha específica
SELECT id, email, name, created_at, signup_attribution
FROM users
WHERE signup_attribution->>'utm_campaign' = 'pricing_launch_q3'
ORDER BY created_at DESC;
-- Conversão por landing page
SELECT
signup_attribution->>'landing_page' AS landing_page,
COUNT(*) AS signups,
COUNT(s.id) AS paid_conversions
FROM users u
LEFT JOIN subscriptions s ON s.company_id IN (
SELECT company_id FROM company_members WHERE user_id = u.id
) AND s.status = 'active'
WHERE u.signup_attribution IS NOT NULL
GROUP BY 1
ORDER BY 2 DESC;
-- Attribution de um usuário específico
SELECT signup_attribution FROM users WHERE email = 'cliente@exemplo.com';
Comandos utilitários:
# Local
pnpm db:psql:local
# Produção (via SSH — ver server-ssh.mdc)
pnpm db:query -- "SELECT signup_attribution->>'utm_source', COUNT(*) FROM users GROUP BY 1"
2. GA4 — comportamento no website e funil de conversão
Relatórios úteis:
| Relatório | Caminho no GA4 |
|---|---|
| Aquisição por canal | Relatórios → Aquisição → Aquisição de usuário |
| Funil website | Explorar → Exploração de funil |
| Eventos de CTA | Relatórios → Engajamento → Eventos |
| Conversões (sign_up, purchase) | Relatórios → Engajamento → Conversões |
| Usuários por user_id | Explorar → Exploração do usuário |
Para acessar o GA4:
- Website: property configurada dentro do GTM (Measurement ID
G-website) - App: property
VITE_GA4_ID— funil de produto e conversões
3. Metabase (dashboards internos)
Conecta diretamente ao Postgres e permite visualizar dados de attribution, funil e receita juntos.
Metabase → link interno TBD após setup (ver product/06-product-metrics.md)
Exemplo de dashboard: "Signups por canal × Conversão para pago × Churn MRR"
4. Stripe (receita e billing)
Dados de MRR, churn, upgrades — ver apps/app/src/features/settings/billing/.
Como usar os dados para decisão
Identificar o canal com melhor ROI
SELECT
signup_attribution->>'utm_source' AS source,
signup_attribution->>'utm_medium' AS medium,
COUNT(DISTINCT u.id) AS signups,
COUNT(DISTINCT s.id) AS paid,
ROUND(COUNT(DISTINCT s.id)::numeric / NULLIF(COUNT(DISTINCT u.id),0) * 100, 1) AS conv_pct
FROM users u
LEFT JOIN company_members cm ON cm.user_id = u.id
LEFT JOIN subscriptions s ON s.company_id = cm.company_id AND s.status = 'active'
WHERE u.signup_attribution IS NOT NULL
GROUP BY 1, 2
ORDER BY paid DESC;
Decisão: Aumentar budget no canal com maior
conv_pcte menor CAC estimado.
Detectar landing pages que convertem melhor
SELECT
signup_attribution->>'landing_page' AS page,
COUNT(*) AS signups
FROM users
WHERE signup_attribution IS NOT NULL
GROUP BY 1
ORDER BY 2 DESC;
Decisão: Otimizar CTA e copy nas páginas com mais tráfego mas baixa conversão.
Lead scoring automático (webhook produto → CRM)
O signup_attribution é o ponto de entrada: usuários vindos de utm_source=google&utm_campaign=pricing têm intenção maior do que utm_source=organic. Use essa informação para ajustar o score inicial no HubSpot.
Fluxo sugerido:
- Stripe webhook
customer.subscription.created→ service - Service busca
users.signup_attributionpelouser_id - POST para HubSpot API com
signup_attribution+mrr
Monitorar saúde do funil semanalmente
| Métrica | Fonte | Alerta |
|---|---|---|
| Signups/semana | Postgres users.created_at | Queda > 20% semana a semana |
| % signups com attribution | COUNT WHERE signup_attribution IS NOT NULL / total | < 70% indica CTA sem UTM |
| Sign_up com UTM → Paid 30d | Join users + subscriptions | Conversão < 15% |
sign_up no GA4 | GA4 → Conversões | Divergência > 10% com Postgres = bug de tracking |
Operação e manutenção
Aplicar a migration em produção
# Local: subir ambiente
pnpm cloud:up
# Aplicar migration via Hasura Console ou CLI
# (ver cloud/apps/hasura.md para deploy)
Adicionar novo evento de funil
- Definir o evento em
docs/product/06-product-metrics.md(nomenclatura em inglês) - Implementar chamada ao hook
useSalesFunnelTrackingno componente relevante - Marcar como conversão no GA4 Admin se for evento de receita/ativação
Adicionar novo campo de attribution
- Atualizar interface
SignupAttributionemapps/app/src/shared/lib/attribution.ts - Atualizar
buildSignUpUrl()emapps/website/src/lib/build-signup-url.tspara incluir o campo na URL - Atualizar
RegisterDtono service se o campo precisar de validação - Não é necessária migration — o campo
signup_attributioné JSONB
Limitações conhecidas
| Limitação | Impacto | Mitigação |
|---|---|---|
| Dois GA4 properties distintos | Dashboards GA4 separados website vs app | Comportamento intencional (Opção B); Postgres + visitor_id unificam vendas |
| Hotjar website vs app | IDs separados | Criar 2 sites no Hotjar; website via GTM, app via VITE_HOTJAR_ID |
| First-touch fixa campanha por 30 dias | Campanhas posteriores no mesmo browser não substituem a primeira | Comportamento intencional; usar last-touch no CRM se necessário |
| Google Sign-In não usa o form | trackSignUp e attribution não são enviados no fluxo Google | Implementar no google-auth-callback quando o fluxo de criação de usuário via Google for concluído |
| Bloqueadores de anúncio (adblockers) | GA4 pode perder eventos | Attribution via DB é imune a isso — é server-side |
Links relacionados
-
cloud/MCP.md — MCP GA4 e Google Ads no Cursor (consultas no chat; global
~/.cursor/mcp.json) -
docs/product/06-product-metrics.md— North Star, KPIs, nomenclatura de eventos -
docs/sales/01-icp-qualification.md— Lead scoring, sinais PLG -
docs/sales/02-sales-process.md— Pipeline de vendas -
docs/sales/06-sales-metrics.md— MRR, funil, LTV/CAC