Docs

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ícieLoader no códigoTags configuradas ondeEnv (build-time)
Website (marketing)GTM containerGA4, Google Ads, Hotjar dentro do GTMNEXT_PUBLIC_GTM_ID
App (produto)GA4 gtag diretoEventos de funil, user_id, billingVITE_GA4_ID
App (UX)Hotjar diretoHeatmaps e gravações no dashboardVITE_HOTJAR_ID
AmbosCódigo first-partyUTMs + 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

FerramentaO que criarQuantidade
GA4Property Website + Property App2 (IDs G-… diferentes)
GTMContainer Web para o domínio do website1 (GTM-…)
HotjarSite Website + Site App2 (IDs numéricos diferentes)
Google AdsConta + conversões importadas do GA4 App1

2. Website — GTM

  1. tagmanager.google.com → Create container → Web → domínio do website.
  2. Copie GTM-XXXXXXX → Dokploy Build Arguments: NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX.
  3. Deploy do website; confirme no DevTools: window.dataLayer existe e gtm.js carregou.
  4. Dentro do GTM, crie tags:
TagTipoID / configTrigger
GA4 ConfigGoogle TagMeasurement ID G-websiteAll Pages
GA4 Event — page_viewGA4 Eventevent page_viewCustom Event = page_view
GA4 Event — cta_clickGA4 Eventevent cta_clickCustom Event = cta_click
GA4 Event — utm_capturedGA4 Eventevent utm_capturedCustom Event = utm_captured
Google Ads LinkerConversion Linker—All Pages
HotjarCustom HTML ou templateSite ID do websiteAll Pages
  1. Cross-domain: na tag GA4 Config → Fields to Set → linker com domínio do app (brandConfig.domains.app). O código já envia cross_domain_domains no dataLayer inicial.

  2. Variáveis GTM úteis (Data Layer Variables): utm_source, utm_campaign, page_type, funnel_step, session_id.

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

  1. GA4 → nova property App → copie G-… → Dokploy Build Arguments: VITE_GA4_ID=G-….
  2. Hotjar → New site → URL do app → copie Site ID → VITE_HOTJAR_ID=1234567.
  3. Deploy do app.
  4. No GA4 App, marque como conversões: sign_up, begin_checkout, purchase.
  5. No Google Ads → Import → conversions from GA4 property App.
  6. 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)

AppVariávelOnde configurar
websiteNEXT_PUBLIC_GTM_IDDokploy Build Arguments
appVITE_GA4_IDDokploy Build Arguments
appVITE_HOTJAR_IDDokploy 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)

ArquivoResponsabilidade
src/components/GoogleTagManager.tsxCarrega GTM (NEXT_PUBLIC_GTM_ID)
src/components/WebsiteTracking.tsxBundle: GTM + UTM/referrer/session tracking
src/lib/tracking.tsdataLayer.push — eventos para triggers GTM
src/lib/attribution.tsFirst-touch UTMs em localStorage (fb_utm_attribution, TTL 30 dias); visitor_id; migração do legado sessionStorage
src/components/UTMTracking.tsxCaptura UTMs → dataLayer + localStorage (30 dias)
src/components/ReferrerTracking.tsxReferrer de sessão em sessionStorage (GA4 ~30 min) + first-touch em localStorage (30 dias)
src/lib/build-signup-url.tsLê attribution do localStorage + visitor_id → monta URL de sign-up com todos os params
src/hooks/useGoogleAnalytics.tsHook para disparar eventos GA4 no website
src/hooks/useSalesFunnelTracking.tsEventos de funil: lead, cta_click, pricing_page_view, demo_request, etc.

App (apps/app)

ArquivoResponsabilidade
src/config/analytics.tsVITE_GA4_ID, VITE_HOTJAR_ID, cross-domain domains
src/shared/components/tracking/google-analytics.tsxCarrega GA4 gtag (produto)
src/shared/components/tracking/hotjar.tsxHotjar in-app + identify após login
index.htmlShell — scripts injetados via React
src/shared/lib/attribution.tsLê params da URL, persiste/lê de localStorage (TTL 30 dias), exporta SignupAttribution
src/features/auth/routes/(auth)/sign-up.tsxChama captureAndGetAttribution() ao montar → salva attribution no localStorage
src/features/auth/components/sign-up-form.tsxNo submit: lê attribution, envia para service, chama trackSignUp(), seta user_id no GA4
src/features/auth/api/auth-endpoints.tsEndpoint register aceita attribution?: SignupAttribution
src/shared/hooks/use-google-analytics.tsSeta user_id, company_id, plan_name no GA4 após login
src/shared/hooks/use-sales-funnel-tracking.tsEventos: sign_up, login, plan_selected, begin_checkout, purchase, etc.

Service (apps/service)

ArquivoResponsabilidade
src/modules/auth/dto/register.dto.tsattribution?: Record<string, string> — campo opcional
src/modules/auth/auth.service.tsPersiste signup_attribution na mutation insert_users_one

Banco de dados (apps/hasura)

Tabela/ColunaTipoDescrição
users.signup_attributionjsonbAttribution 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)

CamadaStorageTTLModeloUso
UTMs no websitelocalStorage (fb_utm_attribution)30 diasFirst-touch — não sobrescreve campanha já salvaCTAs, buildSignUpUrl(), GA4 page views
Referrer comerciallocalStorage (mesmo blob)30 diasFirst-touchPersistido no signup
Referrer sessão GA4sessionStorage (ga_page_referrer)30 minSessão GA4Apenas contexto de page_view no website
Attribution pré-signup (app)localStorage (fb_signup_attribution)30 diasLast URL params recebidos em /sign-upAté concluir cadastro
Signup definitivoPostgres users.signup_attributionPermanenteSnapshot no momento do registerVendas, 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)

EventoQuandoOnde
page_viewToda navegaçãoUTMTracking.tsx
utm_capturedUTMs presentes na URLUTMTracking.tsx
session_startInício de sessãoHeroSection.tsx
first_visitPrimeira visitaHeroSection.tsx
cta_clickClique em botão de CTACTASection, HeroSection, etc.
pricing_page_viewAcesso à página de preçosPricingSection.tsx
demo_requestFormulário de demouseSalesFunnelTracking
leadPreenchimento de formuláriouseSalesFunnelTracking
scrollProfundidade de scrollUTMTracking.tsx

App (GA4 property app — VITE_GA4_ID)

EventoQuandoOnde
sign_upCadastro concluídosign-up-form.tsx
loginLogin concluídouser-auth-form.tsx, google-auth-button.tsx
company_createdCriação de empresa no onboardinguseSalesFunnelTracking
pricing_page_viewAcesso ao pricing internouseSalesFunnelTracking
plan_selectedSeleção de planouseSalesFunnelTracking
begin_checkoutInício do checkoutuseSalesFunnelTracking
purchaseAssinatura confirmada (Stripe)use-subscription-callback.ts
subscription_canceledCancelamentouseSalesFunnelTracking

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órioCaminho no GA4
Aquisição por canalRelatórios → Aquisição → Aquisição de usuário
Funil websiteExplorar → Exploração de funil
Eventos de CTARelatórios → Engajamento → Eventos
Conversões (sign_up, purchase)Relatórios → Engajamento → Conversões
Usuários por user_idExplorar → 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_pct e 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:

  1. Stripe webhook customer.subscription.created → service
  2. Service busca users.signup_attribution pelo user_id
  3. POST para HubSpot API com signup_attribution + mrr

Monitorar saúde do funil semanalmente

MétricaFonteAlerta
Signups/semanaPostgres users.created_atQueda > 20% semana a semana
% signups com attributionCOUNT WHERE signup_attribution IS NOT NULL / total< 70% indica CTA sem UTM
Sign_up com UTM → Paid 30dJoin users + subscriptionsConversão < 15%
sign_up no GA4GA4 → ConversõesDivergê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

  1. Definir o evento em docs/product/06-product-metrics.md (nomenclatura em inglês)
  2. Implementar chamada ao hook useSalesFunnelTracking no componente relevante
  3. Marcar como conversão no GA4 Admin se for evento de receita/ativação

Adicionar novo campo de attribution

  1. Atualizar interface SignupAttribution em apps/app/src/shared/lib/attribution.ts
  2. Atualizar buildSignUpUrl() em apps/website/src/lib/build-signup-url.ts para incluir o campo na URL
  3. Atualizar RegisterDto no service se o campo precisar de validação
  4. Não é necessária migration — o campo signup_attribution é JSONB

Limitações conhecidas

LimitaçãoImpactoMitigação
Dois GA4 properties distintosDashboards GA4 separados website vs appComportamento intencional (Opção B); Postgres + visitor_id unificam vendas
Hotjar website vs appIDs separadosCriar 2 sites no Hotjar; website via GTM, app via VITE_HOTJAR_ID
First-touch fixa campanha por 30 diasCampanhas posteriores no mesmo browser não substituem a primeiraComportamento intencional; usar last-touch no CRM se necessário
Google Sign-In não usa o formtrackSignUp e attribution não são enviados no fluxo GoogleImplementar 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 eventosAttribution via DB é imune a isso — é server-side

Links relacionados