@funnel10x/tracking
v2.3.0
Published
Funnel 10X tracking snippet: the official PostHog SDK from its CDN, with FingerprintJS, the attribution parameters in forms and checkout links, and the consent switch of a direct site.
Readme
@funnel10x/tracking
O snippet de tracking dos funis da Funnel 10X: a Fase A2 do plano da
atribuição
do admin. Vive em packages/tracking/ do funnel10x/f10x-admin, e publica-se
no npm como pacote público, pela versão.
Desde a 2.0.0 (Fase A2b, 2026-10-14) o snippet é o PostHog oficial com os
nossos extras por cima: o mesmo array.js que a tag oficial carrega, do mesmo
CDN, no canal 1.x, e o SDK não vem neste ficheiro. O que faz numa página:
- o PostHog oficial, por um loader: o stub (o
window.posthogcom o__SV, como o do snippet oficial) posto antes de tudo, e oarray.jsdo CDN do PostHog no canal 1.x (/static/1/array.js, pelo host dos assets doapiHost, ou pelo/_tde um repodirect). As funcionalidades ficam como na instalação oficial (feature flags, surveys, conversations, product tours; os web experiments desligados, como lá), e um site desliga uma pelasposthogdas opções. O PostHog recebe tudo: o snippet não limpa nada, e o URL chega inteiro, com o que a query string traga (o email, as respostas de um quiz, umtoken). Quem apara é o admin, na ingestão (abaixo); - o FingerprintJS v5 (MIT, embutido), sem o pedido de monitorização que ele
faz por omissão a um servidor deles. O primeiro
$pageviewespera por ele até 1 s, contado com o carregamento dele, e cada evento leva ofingerprinte ofingerprint_ms(o tempo que levou, ou o tecto, 1000); - a lista de atribuição nos forms, no CRM e no checkout, e só ela: os ids
e os 22 parâmetros do anúncio (
src/params.js) nos campos escondidos de cada form de lead da página (os que já existem com o nome, e os que faltam, acrescentados), no URL do iframe de um form, inquérito ou calendário do GHL, e nos links do checkout. O email, otokene o resto do URL não seguem: iam para o URL do Stripe e para os campos do GHL, que é entregá-los a terceiros; - o
checkoutno clique num link do checkout (desde a 2.2.0, Fase A41), uma vez por link e por página: o início do checkout, do contrato dos eventos (abaixo); - o identify pelo id do contacto num link de email ou de SMS
(
?contact_id={{contact.id}}); - o email e o telefone que a pessoa envia (desde a 1.1.0, como a HYROS;
só no envio desde a 1.2.0): do URL, quando o funil os passa à página seguinte
(
email,phone,phoneNumber,phone_number,tel,telefone), e dos campos de email e de telefone do form que ela envia (osubmitde um<form>, ou o botão de enviar do form nativo do GHL). O de um form que ela não enviou não vai. O telefone ganha o indicativo do seletor de país (o intl-tel-input), que é o que um seletor costuma esconder. Vão num eventocontact_captured(comcontact_source:urlouform), cada valor uma vez por página, e só com o PostHog a medir. Não vão ao perfil ($set), que passaria o browser a pessoa identificada, o escalão mais caro do PostHog: o perfil recebe-os do identify do servidor, uma vez por contacto; - a versão do snippet em cada evento (
tracking_version), e, num sitedirect, a versão da página e o teste (page_version,ab_test), o interruptor de consentimento dosite.json, e a guarda das pré-visualizações; - os extras isolados do arranque do SDK (desde a 2.1.0, Fase A2c): os
forms, o iframe e o checkout (
dom), o email e o telefone (contact), o FingerprintJS (fingerprint) e o cross-domain (linker) correm cada um no seutry. Um que lance deixa na consola the dom extra failed (com o nome dele) e, em cada evento, otracking_degradedcom a lista dos que falharam (['dom']), e o PostHog mede na mesma, com otracking_version. No 2.0.0 um erro em qualquer deles impedia o SDK de carregar. Um erro no próprio arranque (o núcleo: o stub, o consentimento, o carregamento doarray.js, obefore_send, a API) deixa a página como estava: o stub sai, owindow.f10xTrackingfica semversion, e numa tag do GTM arranca a tag oficial de reserva (A reserva, abaixo); - o cross-domain entre domínios ligados, opcional (
linkedDomains, desde a 2.1.0): os links e os forms para o outro domínio levam a pessoa e a sessão do PostHog num parâmetro que expira em 2 minutos, como o_gldo GA (O cross-domain, abaixo).
Quem lê isto do lado do admin (Fase A3 do plano da atribuição): a fonte
PostHog da ingestão guarda de cada toque o tracking_version, a
page_version, o ab_test, o fingerprint, e desde a A2b a versão do SDK e a
data dos defaults ($lib_version e $config_defaults), e o email e o
telefone do contact_captured só em hash; desde a A2c, o tracking_fallback
que a tag de reserva carimba e o linked_distinct_id do cross-domain (em hash
quando é um email, como o distinct_id). O tracking_degraded fica só no
PostHog, como o fingerprint_ms. Apara cada URL pela mesma lista
de src/params.js: um parâmetro que entre na ALLOWED_PARAMS entra também na
entrada posthog da ingest/pii.json e na supabase/verify/atr3.sql do
admin, no mesmo PR (um teste do admin prova que as três listas são a mesma).
Uma instalação activa por página: o snippet, e a oficial como reserva
Cada site, de qualquer account, escolhe uma de duas instalações, e nunca há
duas activas na mesma página (decisão 1 da A2b, revista pela decisão 1 da A2c):
a oficial do PostHog (a tag no GTM, o snippet oficial no código do site, ou
o posthog-js no código de uma app) ou este snippet (embutido num repo
direct, ou pela tag do dist/gtm.html em qualquer outro site). Num site com
a tag do GTM, a oficial pode lá estar só como reserva: outra tag, que
arranca quando o snippet não carregou, e nunca ao lado dele (A reserva,
abaixo). Quem escolhe este não perde nada da oficial: é o mesmo SDK, com os
extras. Com duas activas, a segunda ou toma o arranque da primeira ou duplica
cada evento:
- a oficial antes deste: o primeiro
initda fila manda, e este não arranca; avisa na consola (another PostHog is already on this page) e owindow.f10xTracking.modeficaconflict; - a oficial depois deste: o stub deste é o
window.posthog, e oinitdela entra na fila dele, sem efeito (o stub nunca insere um script, por isso não há outroarray.js); avisa na consola, e cada evento levatracking_conflict: true, que é o sinal nos dados. Asupabase/verify/atr2b.sqldo admin guarda o resto: num host com este snippet, nenhum$pageviewde outra instalação no mesmo minuto.
Num contentor GTM partilhado por vários sites, a tag oficial dispara nos hosts
que ainda não passaram ao snippet: em All Pages, com o acionador Snippet hosts
(o da tag do snippet) como excepção, e o Direct hosts (os hosts das
maneiras 1 e 2, com o snippet no build ou no header) como outra (Fase A2d do admin, que revê a lista de
inclusão da A2c: passar um host é uma edição da RegEx, e um host que ninguém
listou fica medido pela oficial, em vez de calado). A reserva é outra tag.
Dentro de um
domínio registável, todas as instalações levam a mesma data de defaults: o
cookie do PostHog é partilhado pelos subdomínios, e a data muda a regra entre o
cookie e o localStorage (cookieWinsOnConflict). A deste pacote é
2026-08-30; uma tag oficial no mesmo domínio passa a ela.
O que se actualiza sozinho, e o que não
| camada | quem a dá | actualiza-se |
| --- | --- | --- |
| o SDK (array.js) | o CDN do PostHog, no canal 1.x | sozinho, como na tag oficial: o canal tem 4 horas de cache, e uma versão nova chega a todos os sites nesse prazo |
| as extensões (o gravador, os surveys) | o mesmo CDN | seguem a versão do array.js (strict_script_versioning: true) |
| o comportamento por omissão do SDK | a data dos defaults, fixa neste pacote | só com uma versão nova do pacote |
| os extras (este ficheiro) | o jsDelivr pela versão, com o integrity, numa tag; o package.json, num direct | nunca sozinhos: decidem o que vai para os forms, o CRM e o checkout, e uma mudança não anunciada chegaria a todos os sites ao mesmo tempo. Num direct, uma versão nova do pacote é uma release da base |
Os testes A/B de um site direct correm sobre um SDK externo: uma versão nova
chega a todas as versões de um teste ao mesmo tempo, e a comparação entre elas
continua válida. O admin guarda a versão do SDK e a data dos defaults de cada
toque, para uma análise se poder partir por elas.
O sdkVersion, numa emergência. Uma versão exacta do posthog-js
('1.438.9') fixa o caminho exacto do array.js, que é imutável no CDN: numa
tag, na configuração dela; num repo direct, pela variável de ambiente da
Vercel (VITE_POSTHOG_SDK_VERSION num static, NEXT_PUBLIC_POSTHOG_SDK_VERSION
num nextjs), que muda com um redeploy, sem PR. É a resposta a uma versão do
SDK que parta um site, até uma versão nova do pacote; o workflow diário
tracking-sdk.yml do admin corre os testes contra o array.js que o CDN serve
nesse dia, e abre uma issue quando falham.
O que muda por o PostHog receber tudo
O que a 1.x limpava no browser vai inteiro ao PostHog (decisão 2 da A2b), e isso tem três consequências que se sabem:
- o cookie do PostHog leva o URL de entrada em bruto (o
$initial_person_info), e o cookie é partilhado por todos os subdomínios do domínio registável; - as gravações seguem as definições do projeto, com os corpos dos pedidos de rede se o projeto os ligar (um form de lead manda o email);
- o
Refererde um pedido ao/_t(do mesmo domínio) leva o URL inteiro, e o site volta à política do browser (strict-origin-when-cross-origin): ostrict-originexistia por isso, e a razão caiu.
Um site com CSP tem de deixar passar o host dos assets do PostHog no
script-src (o us-assets.i.posthog.com, ou o proxy da account): o SDK deixou
de vir no ficheiro. É uma das razões de a 2.0.0 ser major.
Num repo direct: embutido no build
O package.json do repo pina a versão exacta (sem ^), e o build embute-o:
{ "dependencies": { "@funnel10x/tracking": "2.2.0" } }import { init } from '@funnel10x/tracking'
init({
key: import.meta.env.VITE_POSTHOG_KEY,
apiHost: '/_t',
sdkVersion: import.meta.env.VITE_POSTHOG_SDK_VERSION || undefined,
consent: site.consent,
linkedDomains: site.linkedDomains,
pageVersion: 3,
test: '/pt/acq_f1/webinar/lp#2',
})key: a chave do projeto PostHog da account (phc_…), pela variável de ambiente da Vercel (VITE_POSTHOG_KEYnumstatic,NEXT_PUBLIC_POSTHOG_KEYnumnextjs). É pública: vai no HTML de qualquer maneira.apiHost: '/_t': o SDK e os eventos vão pelo proxy no domínio do site, que os livra das listas de bloqueio (a EasyPrivacy bloqueia oposthog.com): oarray.jspede-se a/_t/static/1/array.js. Overcel.jsondo repo leva os três rewrites, por esta ordem:{ "rewrites": [ { "source": "/_t/static/:path(.*)", "destination": "https://us-assets.i.posthog.com/static/:path" }, { "source": "/_t/array/:path(.*)", "destination": "https://us-assets.i.posthog.com/array/:path" }, { "source": "/_t/:path(.*)", "destination": "https://us.i.posthog.com/:path" } ] }O padrão é
:path(.*)e não:path*, que não apanha a barra final dos envios (/e/) e lhes responde 404 (medido em produção). O_à frente garante que não colide com um funil (a base não deixa um nome de funil começar por_). Até à 1.x o site levavaReferrer-Policy: strict-origin, para oRefererde um pedido ao proxy não levar o URL inteiro; desde a 2.0.0 o PostHog recebe-o de qualquer maneira, e a base volta à política do browser.sdkVersion: vazio, o canal 1.x do CDN; uma versão exacta só numa emergência (acima), pela variável de ambiente da Vercel (VITE_POSTHOG_SDK_VERSIONnumstatic,NEXT_PUBLIC_POSTHOG_SDK_VERSIONnumnextjs), que o_scripts/tracking.mjse oTracking.tsxda base lêem.consent: o dosite.json. Comtrue, o snippet não mede nem guarda nada até à escolha: os forms levam só os parâmetros do anúncio tirados do URL, e o banner do site chamawindow.f10xTracking.grant()no sim e.deny()no não (ou para retirar o sim, que pára o PostHog e apaga o que ele guardou). Antes do sim nada se pede, nem oarray.js: o padrão que o PostHog recomenda, carregar com a recolha desligada, pede a configuração remota antes da escolha. Um não antes de o SDK chegar cancela o arranque (oinitsai da fila, e oarray.jsnão se pede). A escolha guarda-se emlocalStorage(f10x_consent), e a página seguinte arranca sozinha. Comfalse, mede logo. O GTM do site arranca depois do stub do snippet, para uma tag que use owindow.posthogo encontrar.linkedDomains: o dosite.json, opcional, uma lista de hosts (O cross-domain, abaixo). Sem ele, ou vazio, o cross-domain está desligado.pageVersionetest: oversionda página principal notests.json(ou oNde uma variante) e o nome do teste activo, que o build entrega.pageRevision,abEligibilityeabAssignedVersion(2.3.0, M79): o build Direct fornece o SHA-256 do conteúdo e dependências locais, a elegibilidade (none,randomized,directoucross_exposure) e a versão atribuída pelo cookie válido. Carimbam cada evento comopage_revision,ab_eligibilityeab_assigned_version; nunca ficam em propriedades persistentes da pessoa. Sórandomizedpertence à amostra atribuída do teste. A versão servida pode diferir da atribuída numa exposição cruzada. A identidadevNconserva-se ao mudar a principal; uma correcção técnica do conteúdo conserva N e muda a revisão. Sites sem estes parâmetros continuam compatíveis e não recebem uma atribuição inferida do URL.
Uma página cujo caminho acaba em _ seguido de algarismos (a pré-visualização
de uma versão) não mede nada nem mexe nos forms. A base também conserva os
previews --vN sem medição. Desde direct-v16, a entrada /lp redirecciona
para /lp-vN, que é um URL de produção medido; abrir esse URL directamente
nunca sorteia nem substitui o cookie A/B.
Num site com build próprio que não é da base, ou só com o header
O cartão PostHog do Account Settings dá as três maneiras, pela ordem do que é melhor, já com a chave do projeto:
- No build do site (um Next.js ou um Vite na Vercel que não nasceu da
base
direct): odist/direct-init.js(oinitcom oapiHost: '/_t') e odist/direct-rewrites.json(os três rewrites de cima, para overcel.json), que o build do pacote escreve e otests/api/gtm-tags.test.mjscompara com o bloco da secção de cima. O/_té o proxy no domínio do site, e por isso não leva o proxy gerido. - Colado no header (um funil do GHL, um Webflow): a tag do snippet, o
dist/gtm.html, tal como está. Não depende do GTM, que a EasyPrivacy bloqueia inteiro (||googletagmanager.com^), mas só passa pelas listas com o proxy gerido noapiHost: sem ele, vai para oi.posthog.com, que a mesma lista bloqueia (oarray.jse o/i/). A reserva só corre pelo GTM (of10x_tracking_failedé um evento dodataLayer), e por isso um site sem contentor não mede a visita em que o snippet falha. Corre sempre, também para quem recusou (O consentimento, abaixo): um site com banner usa a tag no GTM, com a condição de consentimento. - Pelo GTM: só a tag do snippet em All Pages, que é a mais simples (sem
a reserva, a visita em que o
tag.jsnão carrega não se mede), ou as três tags, abaixo.
O caso misto: o snippet no build ou no header, e um contentor GTM na página
Um site da primeira ou da segunda maneira que carrega também um contentor do
GTM (o Piko, com o GTM-PM4T6FGX; a IMO Circle, com o GTM-54G2Q8GD) tem
quatro coisas a acertar no contentor (Fase A42 do plano da atribuição). O
cartão di-las nos diálogos das duas maneiras, com o campo Direct hosts lá
dentro:
- A ordem. Uma tag do contentor que chame o
window.posthog(umpurchase, umidentify) só o encontra depois do stub do snippet: antes do stub a chamada lança, e o evento perde-se; depois dele, mesmo com o SDK por carregar, fica na fila. No build, o stub nasce noinit, de forma síncrona: o contentor arranca do mesmo componente, logo a seguir aoinit, como na basedirectdesde adirect-v9, e não do layout (oGoogleTagManagerdo@next/third-parties, umnext/script), cuja ordem para oinitde um componente cliente não está garantida. No header, o stub só nasce quando otag.jsexecuta, e ele é assíncrono como ogtm.js: o snippet vai acima do código do contentor, o que o faz começar primeiro e não o garante (num funil do GHL o código do header nem vem no HTML: a app insere-o depois de arrancar, medido a 2026-10-19). Por isso, uma tag que chame oposthogao carregar a página leva o invólucro abaixo, que espera por ele até 15 s, para lá dos 10 s em que o snippet desiste e a reserva arranca, e desiste em silêncio. O mesmo vale numa tag do snippet pelo GTM, que também carrega otag.jsassíncrono. - A tag oficial do PostHog, se o contentor a tiver, ganha o acionador
Direct hosts (Page View, Page Hostname corresponde à RegEx) como
excepção, publicada no dia em que o host passa: duas instalações na mesma
página tomam o arranque uma da outra (o
conflict) ou contam cada evento duas vezes. Se ela fica para outros hosts do mesmo domínio registável, troca-se pela oficial gerada (dist/gtm-official.html), que traz a data dosdefaults; se só servia este site, sai. - A reserva, só no header: a tag empurra o
f10x_tracking_failedpara owindow.dataLayer, e com o contentor na página a tag de reserva (dist/gtm-fallback.html), no evento personalizadof10x_tracking_failed, apanha a visita em que o snippet falha. No build não há reserva. - As tags que chamam o
posthogcontinuam a funcionar: owindow.posthogpassa a ser o do snippet, o mesmo SDK, e o que se lhe chama depois de o stub existir e antes de o SDK arrancar fica na fila.
A prova, nas duas maneiras: na consola, window.f10xTracking.mode não é
conflict, e no PostHog (Activity) cada navegação deixa um $pageview só,
sem tracking_conflict. No header, também a da reserva (o ponto 8 do
checklist): com o cdn.jsdelivr.net bloqueado nas DevTools, chega um só
$pageview, com tracking_fallback: true. No build não há reserva, e o SDK
vem pelo /_t.
O invólucro, com a chamada da tag no lugar da do exemplo (o Account Settings dá o mesmo, para copiar):
<script>
;(function send(tries) {
var posthog = window.posthog
if (posthog && typeof posthog.capture === 'function') {
posthog.capture('purchase', { session_id: '{{Checkout session ID}}' })
return
}
if (tries < 75) setTimeout(function () { send(tries + 1) }, 200)
})(0)
</script>Num site que não é repo nosso: a tag do GTM
Num funil do GHL, num site institucional ou num site em Webflow, onde não há
build, o mesmo pacote carrega-se por uma tag Custom HTML do GTM, em vez da tag
oficial do PostHog, que fica só como reserva (Uma instalação activa por
página, acima, e A reserva, abaixo), pela versão fixa no URL do jsDelivr
e com o hash do ficheiro no integrity. As três tags de um contentor geram-se
no admin, já com a chave do projeto e o proxy gerido da account: em Account
Settings › Integrations, no cartão PostHog, Install the tracking on a site
(Fase A2d do plano da atribuição), com os acionadores e os passos de
instalar, de passar um host e de ligar o proxy. O que o cartão gera são os
ficheiros do dist/ sem o comentário de cabeça, com os marcadores trocados, e
mais nada:
| ficheiro | a tag | dispara |
| --- | --- | --- |
| dist/gtm.html | o snippet, desta versão | no acionador Snippet hosts (Page View, Page Hostname corresponde à RegEx) |
| dist/gtm-fallback.html | a oficial do PostHog como reserva, com o tracking_fallback | só no evento personalizado f10x_tracking_failed |
| dist/gtm-official.html | a oficial do PostHog, para os hosts que ainda não passaram | em All Pages, com o Snippet hosts e o Direct hosts como excepção |
As três levam a condição de consentimento de analytics do site, quando ele tem
banner. Só se trocam a chave, o host (o apiHost no snippet, o api_host nas
oficiais: o proxy gerido da account, abaixo, ou o PostHog directo), os hosts do
checkout, os domínios ligados (O cross-domain, abaixo; vazio, desligado) e,
numa emergência, o sdkVersion. Uma oficial que já exista no contentor
substitui-se pela gerada, que traz a data dos defaults do pacote.
Desde a 2.1.0 a tag diz quando o snippet não arrancou: empurra para o
dataLayer o evento f10x_tracking_failed, uma vez por página, quando o
tag.js não chega (o onerror: o jsDelivr em baixo ou bloqueado, um
integrity que não bate, uma CSP que o recusa; ou nenhum dos dois ao fim de
10 s, um pedido pendurado) ou chega e não deixa a version no
window.f10xTracking (o onload: um erro nosso no arranque). É o evento em
que a reserva dispara. Um tag.js que chegue depois dos 10 s encontra a
oficial e fica inerte (o conflict), e continua a haver uma só instalação
activa; e uma tag que dispare outra vez com o snippet já a correr não empurra
nada, mesmo que a rede lhe falhe. O evento vai para o window.dataLayer:
num GTM com a data layer renomeada (o l= do snippet do GTM) a reserva não
dispara, e a prova do ponto 8 do checklist apanha-o. O <script> do jsDelivr cria-se por código, e não como
um <script src> no HTML: o GTM recria cada <script> de um Custom HTML
copiando-lhe os atributos, e um onload escrito como atributo é um handler
inline, que uma CSP com nonce não deixa correr; posto como propriedade, de
dentro do script da tag, corre sempre.
Uma versão nova é uma tag nova, nunca @2 nem @latest: o GTM de cada site
diz que versão dos extras corre, e o tracking_version de cada evento
confirma-o. O SDK, esse, actualiza-se sozinho, pelo CDN do PostHog.
O proxy gerido do PostHog, opcional. Sem build não há o /_t, e com o
apiHost vazio os envios vão para o us.i.posthog.com: funciona, mas a
EasyPrivacy bloqueia-o, e a visita de quem tem um bloqueador não chega ao
PostHog. O PostHog tem um proxy
gerido, um subdomínio da account (um CNAME para o PostHog, nas definições da
organização, Managed reverse proxy) que recebe os envios e os passa ao
PostHog, e serve também o array.js. Com ele, o apiHost da tag é esse
subdomínio: na IR,
https://p.infinityresponse.com, e na IMO Circle, https://p.imocircle.com
(os dois válidos, lidos no PostHog a 2026-10-08; o Piko ainda não tem). O
admin guarda o de cada projeto (o Managed proxy do mesmo cartão, que só grava
um host que sirva o SDK do PostHog), e gera as três tags com ele. As listas que bloqueiam pelo nome do domínio deixam de o apanhar;
o uBlock Origin no Firefox, que lê o CNAME, apanha na mesma, e quanto se
recupera não se mede, porque o que é bloqueado não chega para se contar. O
subdomínio é de outra origem que a página, e por isso o browser só lhe manda a
origem no Referer, e não o URL com a query string.
O consentimento. Sem site.json o snippet não tem interruptor, e quem
decide se ele corre é o sítio onde a tag está. No GTM, corre como as condições
de consentimento da tag deixarem (o banner ligado ao GTM); no Head tracking
code do GHL, ou no campo de código de outra plataforma, corre sempre, também
para quem recusou. Por isso, num site com banner, a tag fica no GTM, com a
condição do consentimento de analytics; num site sem banner, pode ir para o
head, o que a livra de um GTM bloqueado (a EasyPrivacy bloqueia o
googletagmanager.com). É uma regra de onde se põe a tag, e não código.
Numa página de um funil do GHL, o form é nativo (sem <form>, cada campo
com o query key em data-q), e um campo escondido só se enche pelo URL com
que a página carrega: o GHL lê o query key quando a app arranca, e envia o
estado dele, a que o campo não está ligado (medido a 2026-10-07: um valor
escrito no campo depois, mesmo com o evento input, não chega ao envio, e a tag
do Head tracking code corre já depois do arranque). Por isso, num funil do
GHL, a ponte entre o dispositivo e o contacto é o email que a pessoa envia (o
contact_captured), que o admin liga ao contacto criado nesse momento; o campo
ph_anon_id só se enche quando o id já vem no URL. Um form do GHL embebido noutro site corre num
iframe de outro domínio, onde o script da página
não chega. O snippet passa ao iframe, pelo URL dele, os ids e os parâmetros do
anúncio, e os campos escondidos do form lêem-nos de lá (cada campo com o
query key igual ao nome). Se o form não os ler do URL, a mesma tag entra no
código do form: dentro de um iframe o snippet não arranca o PostHog, só passa
ao form o que veio no URL dele.
A reserva: a tag oficial quando o snippet não carrega
Num site com a tag do snippet, a tag oficial do PostHog passa a reserva
(decisão 1 da A2c): uma tag Custom HTML (o template da galeria não tem onde pôr
uma função) com o snippet oficial do PostHog, que dispara só no evento
personalizado f10x_tracking_failed, e por isso só quando o snippet tentou e
não conseguiu, nunca antes dele. É o dist/gtm-fallback.html, que o build
escreve e o Account Settings gera já preenchido: o stub do snippet oficial (a
forma que as definições do projeto dão hoje, a do snippetFunctions do
PostHog, que insere o array.js uma vez só e deixa repetir depois de uma
falha, com a lista de métodos do posthog-js da devDependency), a mesma chave e o mesmo api_host da tag do snippet, a data de
defaults e o custom_campaign_params do pacote (para os ids de anúncio irem
como propriedades nas duas instalações), person_profiles: 'identified_only',
e um before_send que carimba tracking_fallback: true em cada evento, que
é o que distingue uma visita pela reserva nos dados (o admin guarda-o). Pelo
before_send e não pelo register, que persistia e marcava os $pageview do
snippet nas páginas seguintes.
O trigger é um evento personalizado, com a mesma condição de consentimento
da tag do snippet. Nunca All Pages nem Window Loaded: em All Pages a oficial
dispara antes de o tag.js chegar e toma o arranque (o conflict), e num site
com banner o load chega antes do sim, a reserva media sem ele, e quando o sim
fizesse correr a tag do snippet ela dava conflict. Pelo evento, a reserva já
é segura contra duplicados: com o snippet carregado o evento não sai, e se
saísse o init dela era um no-op no stub do snippet.
Num contentor partilhado (o GTM-PM4T6FGX do Piko) são duas tags oficiais:
a dos hosts que ainda não passaram (o dist/gtm-official.html), em All Pages,
sem before_send, com o Snippet hosts e o Direct hosts como excepção; e a
reserva, outra tag, só no f10x_tracking_failed, com o before_send. Uma tag
só a fazer as duas coisas disparava em All Pages antes do tag.js nos hosts do
snippet (o conflict) e carimbava tracking_fallback nos hosts da oficial, que
não é reserva nenhuma. A tag do snippet dispara só no Snippet hosts, e por
isso nunca nos hosts direct (onde o snippet vai no build) nem nos que ainda
estão na oficial.
O que a reserva cobre: o jsDelivr em baixo ou bloqueado, um integrity que
não bata, e um erro nosso no arranque. O que não cobre: uma versão do SDK
que parta (a oficial carrega o mesmo array.js, e a resposta é o
sdkVersion), e os bloqueadores de anúncios (bloqueiam as duas). Num site
direct não há reserva: o snippet vai no build, a tag do snippet não dispara
nesses hosts, e o que o protege são os extras isolados.
O que uma visita pela reserva perde: a visita conta, com o URL inteiro e os
parâmetros dele no PostHog, e perde-se o que é dos extras: os ids nos campos
dos forms, no URL do iframe do GHL e nos links do checkout (os ph_* e o
client_reference_id), o contact_captured (o email e o telefone), o
fingerprint, o _f10x do cross-domain, e o tracking_version.
Os campos
O que os forms, o iframe e os links do checkout levam:
| campo | o que é | sem consentimento | campo custom no GHL |
| --- | --- | --- | --- |
| ph_anon_id | o id do dispositivo no PostHog (o anónimo de origem) | não | opcional: só se enche quando o id chega no URL |
| ph_distinct_id | o distinct id agora (o id do contacto, depois do identify) | não | não |
| ph_session_id | a sessão do PostHog | não | não |
| contact_id | o id do contacto que veio num link desta sessão | não | não |
| as UTMs | utm_source, utm_medium, utm_campaign, utm_content, utm_term, utm_id, utm_placement | sim, do URL | só utm_source, utm_medium e utm_campaign, nas locations de um site direct com consentimento |
| os click ids | fbclid, gclid, gbraid, wbraid, gad_source, gad_campaignid, msclkid, ctwa_clid | sim, do URL | não |
| os ids de anúncio | a_campaign_id, a_adset_id, a_ad_id (a convenção, abaixo), e os da HYROS h_ad_id, fbc_id | sim, do URL | só a_ad_id, como as UTMs |
| lp | a página de entrada que o URL declara | sim, do URL | não |
| narrative | o slug da narrativa declarado pela página (desde a 1.4.0) | sim, do URL | narrative, com o query key igual ao nome, se a location o tiver |
- Num form da página, um campo que já existe com o nome recebe o valor se
for escondido ou estiver vazio; os que faltam acrescentam-se escondidos, só
num form de lead (com um campo de email ou de telefone) e nunca num com
data-f10x-skip. Voltam a preencher-se no envio, com os valores desse momento. - Num form do GHL não há campos a pôr: a ponte para o contacto é o email
que a pessoa envia (o
contact_captured), e o click id do contacto é a atribuição nativa do GHL, que a A5 lê. Os quatro campos de atribuição da tabela só existem nas locations que recebam leads de um sitedirectcomconsent: true: quem recusa não tem PostHog, e um contacto criado pelo webhook não tem atribuição nativa (plano, A2 revista a 2026-10-07). - Nos links do checkout (os do Stripe e os hosts em
checkoutHosts), os ids sobrepõem-se ao que o link traga, e os parâmetros do anúncio só entram se o link não tiver os seus. Num Payment Link do Stripe vai também oclient_reference_idcom o distinct id, que é o que chega à sessão de checkout. - Num checkout criado no servidor (uma sessão do Stripe em código),
window.f10xTracking.fields()dá os mesmos valores, para a metadata. Osph_distinct_id,ph_session_id,ph_anon_idecontact_idchegam ao admin pela A4, com forma de id. Asale_checkout_keysexpõe-os, e asale_posthog_linksliga a venda pelocs_…observado no PostHog primeiro, senão pelo primeiro id da metadata que o projeto da account tenha visto.
O cross-domain: linkedDomains
Desligado por omissão, e só para um funil que atravesse dois domínios
registáveis nossos (decisão 3 da A2c). Entre subdomínios do mesmo domínio o
cookie do PostHog já segue a pessoa, também em sufixos de duas partes
(.com.br, .co.uk), e o que hoje atravessa domínios são terceiros (o Stripe,
o redirect dos links do GHL), que têm os seus caminhos. O posthog-js não tem
linker; este desenha-se como o _gl do GA.
A opção é uma lista de hosts (go.marca.pt) ou de domínios registáveis
(marca.pt, que apanha os subdomínios), sem esquema nem caminho, ou uma string
com eles separados por vírgulas ou espaços; uma entrada sem forma de hostname
(um *.marca.pt) sai, com um aviso na consola. Vai no
window.f10xTracking da tag, ou no init de um repo direct (o linkedDomains
do site.json, que a base passa). Os dois sites levam o outro na lista: um
_f10x que chegue só se lê numa página com a lista preenchida.
À saída, os links (a[href]) e os forms (form[action]) para um host da
lista ganham, no clique (o esquerdo, e o do meio, que abre noutro separador;
o direito não, porque o menu dele tem Copiar endereço do link, e uma marca
fresca no instante da cópia é o instante da partilha; e o direito tira o
_f10x que um clique anterior tenha deixado no link) ou no envio, o parâmetro
_f10x=<marca de tempo, em base 36>.<i ou a>.<distinct id>.<session id>,
codificado para o URL. Só com o PostHog a medir (com consent: true, depois do
sim). Ficam de fora os hosts do checkout, que têm a decoração deles, e um
destino que partilha com a página um domínio da lista (o cookie já o segue). O
i diz que a origem está identificada (o distinct_id já não é o
$device_id: um contact_id de um link de email, um identify), e o a que é
anónima. O distinct_id vai sempre, mesmo quando é um email (o dono,
2026-10-14): com o sinal i o destino arranca identificado no PostHog, e os
eventos caem logo na pessoa do contacto. Num form GET o browser troca a query
string do action pelos campos, e o _f10x vai num campo escondido; num POST
vai no action.
À chegada, um _f10x cuja marca está a menos de 2 minutos do
carregamento da página (contados até ao carregamento, e não até ao sim do
banner; nos dois sentidos, para um relógio adiantado na origem não deixar um
link válido mais do que isso):
- sem identidade do PostHog na página (nada no cookie, no
localStoragenem nosessionStoragecom o nome dele,ph_<chave>_posthog): o snippet passa ao posthog-js obootstrapcom odistinctIDe osessionIDda origem, eisIdentifiedIDquando o sinal éi. O primeiro$pageviewsai com odistinct_ide o$session_idda origem; de uma origem identificada, com um$device_idnovo (oph_anon_iddos forms nunca é o id do contacto) e o estadoidentified. Obootstrapjunta-se ao que o site passe emposthog.bootstrap, sem o perder; - com identidade (anónima ou identificada, guardada ou dada pelo site): o
browser não a troca, porque o
bootstrapsobrepunha-a sem evento nenhum, e cada evento desta página leva olinked_distinct_id(o id que veio no link). O admin guarda-o e liga os doisdistinct_idcomo factos de ligação; - fora da janela: nada. A pessoa fica com um id novo, e o admin liga-a por outro caminho.
Com consent: true nada acontece antes do sim: o _f10x fica em memória,
o sim aplica-o (a janela conta desde o carregamento), e um não deita-o fora.
O que se sabe e se aceitou:
- o
_f10xfica no URL da página de destino, onde qualquer tag de terceiros o lê, e com o sinalipode levar um email. O PostHog recebe o URL inteiro, e a ingestão do admin apara-o como aosph_*; - um link reencaminhado dentro dos 2 minutos junta duas pessoas: é o preço
da janela, e 2 minutos é a do
_gl(apanham 94% dos saltos medidos entre hosts do Piko); - um redirect no destino que apague a query string parte o salto, como no GA: a aceitação de um site com a opção prova cada link;
- um relógio adiantado no browser de origem perde a sessão, e não a
pessoa: o posthog-js ignora um
sessionIDcom mais de 1 minuto no futuro.
O contrato dos eventos
Um funil manda três eventos custom, e só estes (Fase A41 do plano da atribuição, 2026-10-16):
| evento | quando | quem o manda |
| --- | --- | --- |
| contact_captured | o email ou o telefone sai num form, ou chega no URL (contact_source: form ou url), cada valor uma vez por página | o snippet |
| checkout | o início do checkout: o clique num link para um host do Stripe ou do checkoutHosts, uma vez por link e por página, com o checkout_host | o snippet, desde a 2.2.0; um checkout aberto por código (um botão com redirect, um checkout embebido) chama f10xTracking.capture('checkout') |
| purchase | o pagamento confirmado, com o session_id (o cs_… da sessão de checkout do Stripe), o currency, o amount_total e o product | o código do funil, na página de obrigado |
- O
contact_capturedé a ponte para o contacto, e não uma etapa do funil: dispara também num form de checkout, e um opt-in, uma marcação e uma compra dão o mesmo. O lead é o contacto no CRM. - O
checkoutvai já, e não na fila (osend_instantlydo posthog-js), porque a página sai a seguir. Um site cujo código já mande o seucheckouttira-o quando passar ao snippet, senão conta dois. - O
purchaseliga a venda pelocs_…, por isso tem de disparar em todas as compras: no Piko, a 2026-10-16, opurchaseligava 78% das vendas e ocheckout_completedtodas. - Não há evento de marcação: o calendário do GHL vem num iframe, onde o PostHog não arranca, e a marcação, a presença e o no-show vêm do GHL.
- O
leade ocheckout_completeddeixaram de ser contrato; o admin continua a aceitá-los, pelo histórico e pelos funis que ainda os mandam.
A API
init(options) (ou a tag) devolve, e põe em window.f10xTracking:
mode:tagoudirect;framenum iframe,previewnuma pré-visualização, econflictquando a página já tinha outro PostHog antes deste (um que chegue depois não muda omode: deixatracking_conflictnos eventos);fields(): os valores de cima, agora;grant(),deny(),consent(): o interruptor de um sitedirect;onReady(fn): quando o PostHog estiver a medir;capture(event, properties): um evento pelo mesmo PostHog (opurchaseda página de obrigado, ocheckoutde um checkout aberto por código), que sem consentimento não faz nada;posthog: a instância do PostHog depois de o SDK arrancar, e o stub até lá (o que se lhe chamar fica na fila, como no stub oficial). Owindow.posthogé o mesmo, e é por ele que as tags do GTM o usam.
Os modelos de parâmetros de URL
Os nomes vão nas UTMs, os ids em três parâmetros nossos, com o prefixo
a_ (de attribution), como a HYROS tem o h_, e desde a 1.3.0. Um nome é
editável no gestor de anúncios e um rename não dá erro nenhum; o id nasce com
o anúncio. Os três querem dizer o mesmo em todas as plataformas, e a plataforma
diz-se no utm_source:
| nível | parâmetro | Meta | Google |
| --- | --- | --- | --- |
| a campanha | a_campaign_id | {{campaign.id}} | {campaignid} |
| o conjunto (o ad set da Meta, o ad group da Google) | a_adset_id | {{adset.id}} | {adgroupid} |
| o anúncio | a_ad_id | {{ad.id}} | {creative} |
Na HYROS, o h_ad_id é o anúncio em todas, mas o nível de cima muda de nome e
de nível com a plataforma: o fbc_id é o ad set na Meta, o gc_id é a
campanha na Google; na Meta a campanha não vai, e na Google o ad group também
não. O h_ad_id e o fbc_id continuam aceites, pelos anúncios da IMO Circle
que ainda os trazem (4.324 pageviews desde abril, medidos a 2026-10-08). Os
nomes sem prefixo da 1.0.0 à 1.2.0 (ad_id, adset_id, campaign_id,
adgroup_id) saíram: nenhum anúncio os usou. Nenhum URL das três accounts
trazia já um parâmetro a_ (medido no mesmo dia).
O texto de cada anúncio sai do gerador de links da Marketing Ops (o módulo
URL Parameters, Fase M48 do plano da Marketing), que o compõe a partir da regra
da fonte na Traffic Sources, do funil e do stage: é aí que se gera, e os dois
modelos de baixo são a forma do que ele dá. O utm_campaign deixou de ser o nome
da campanha (que chega pelo a_campaign_id) e é o funil e o stage,
<funil>_<stage>[_<temperatura>]; o nome do ad set e o utm_id saíram, porque
os ids a_ já os dizem.
Meta, nos URL parameters de cada anúncio:
utm_source=meta&utm_medium=paid_social&utm_campaign=acq_f1_webinar_cold&utm_term={{campaign.name}} >> {{adset.name}}&utm_content={{ad.name}}&utm_placement={{placement}}&a_campaign_id={{campaign.id}}&a_adset_id={{adset.id}}&a_ad_id={{ad.id}}O nome do anúncio acaba no código do asset (decisão 10 do plano), e é por isso
que vai no utm_content; o utm_term leva a campanha e o ad set, separados por
>> (Fase M60 do plano da Marketing), só para se ler no PostHog: nenhum
código o lê, e quem liga o clique ao anúncio são os ids a_. O utm_source é meta, como na Traffic Sources e nos
anúncios das três accounts (45.819 pageviews com fbclid em 90 dias, medidos a
2026-10-08); até à 1.2.0 este modelo dizia facebook, que nenhum usava.
Google, no Final URL suffix da campanha (ou do ad group, ou do anúncio:
o utm_campaign é o do funil e do stage, e por isso não vai ao nível da conta),
com o auto-tagging ligado (o gclid). A Google não tem macro para nomes, só
para ids, e por isso os nomes vão por três parâmetros personalizados, escritos à
mão, com o nome só de letras e dígitos e a referência com o _ à frente:
campaign ({_campaign}) na campanha, adgroup ({_adgroup}) em cada ad
group e ad ({_ad}) em cada anúncio (o nome dele, que acaba no código do
asset; num anúncio de pesquisa, que não tem campo de nome, é o único nome que
leva). Sem eles chegam os ids a_, que
são a garantia. Numa campanha de pesquisa o utm_term leva a keyword no fim, e
o >> vai codificado:
utm_source=google&utm_medium=cpc&utm_campaign=acq_f1_webinar&utm_term={_campaign}%20%3E%3E%20{_adgroup}%20%3E%3E%20{keyword}&utm_content={_ad}&a_campaign_id={campaignid}&a_adset_id={adgroupid}&a_ad_id={creative}Fora da pesquisa, o mesmo sem o %20%3E%3E%20{keyword}.
Os links orgânicos
Um link que não é anúncio (a bio do Instagram, a descrição de um vídeo, um email, o WhatsApp, um parceiro) leva as UTMs da fonte dele, pelas regras da tabela Traffic Sources do admin, que é nomenclatura partilhada por todas as accounts. Sem elas a visita chega sem origem, e o referrer de um browser in-app ou de um cliente de email vem quase sempre vazio, o que a conta como direct. Este README não copia a tabela, que é a fonte; o gerador de links da Marketing Ops (o módulo URL Parameters, Fase M48 do plano da Marketing) monta-os a partir dela, junta-os a um URL que já tenha parâmetros sem duplicar nenhum, e guarda cada link com o sítio onde foi posto.
O checklist de um funil
Antes de ligar o tráfego:
- O snippet carrega pela versão: num repo
direct, a versão exacta nopackage.json(a da base), a chave na variável de ambiente e os rewrites do/_tnovercel.json; num site de fora, a tag dodist/gtm.htmlda versão, com a chave da account e, se ela tiver o proxy gerido, oapiHost. OsdkVersionfica vazio. - O snippet, e a oficial como reserva: este snippet é a única instalação
activa na página. Ao escolher este, o snippet do PostHog que o site já tenha
no head ou no código sai quando este entra, e no GTM a tag oficial passa a
reserva (A reserva, acima: só no
f10x_tracking_failed, com obefore_send); num contentor partilhado, a oficial de sempre fica com a condição de inclusão por hostname, e a reserva é outra tag. Com a mesma chave não se perde nada: quem volta mantém o id anónimo. OapiHostda tag nova é oapi_hostque a antiga tinha (o proxy, se ela o usava): vazio, perdia quem hoje chega por ele. A data dosdefaultsé a mesma em todas as instalações do domínio registável (a deste pacote). - O consentimento: o
consentdosite.jsonescrito, e o banner a chamargrant()edeny(); num site de fora, a tag no GTM com a condição do consentimento se o site tiver banner, e no head só se não tiver. - Os forms: um envio de teste, escrito à mão numa página aberta com
?utm_source=teste, deixa no PostHog umcontact_capturedcom o email, no momento do envio, e o contacto no GHL; um email escrito e não enviado não deixa nada. Num sitedirectcom consentimento, a location tem os quatro campos de atribuição da tabela, com o query key igual ao nome. - Os links de email e de SMS levam
contact_id={{contact.id}}. - O checkout: o host em
checkoutHosts(o Stripe já lá está), e num checkout em código osfields()na metadata e ocapture('checkout')no clique. Um clique de teste num link do checkout deixa umcheckoutno PostHog, e a página de obrigado manda opurchasecom ocs_…em todas as compras (O contrato dos eventos, acima); nenhum outro evento custom. - Os anúncios: o texto do gerador de links nos anúncios da Meta e da Google, e os links orgânicos gerados por ele, com as UTMs da Traffic Sources.
- A prova: na consola, com a reserva instalada,
window.f10xTracking.modenão éconflict; um pageview aberto com[email protected]&utm_source=testechega ao PostHog (Activity, no projeto da account) uma vez só, inteiro, com otracking_versionda versão, o$lib_versionque o CDN serve e nenhumtracking_conflictnemtracking_fallback; os campos do form e o link do checkout levam autm_sourcee não oemail; e com o jsDelivr bloqueado no browser (nas DevTools, Network request blocking,cdn.jsdelivr.net) chega um só$pageview, comtracking_fallback: truee semtracking_version(sem ele, a reserva não disparou: o trigger, a condição de consentimento, ou um GTM com a data layer renomeada, onde o evento não chega). Num site comlinkedDomains, cada link para o outro domínio abre com o_f10x, e o destino, num browser limpo, com odistinct_idda origem.
Desenvolver e publicar
npm ci
npm run buildO dist/ commita-se, como o build das apps do admin, e a CI refaz-o e falha se
não der os mesmos bytes: o que se publica é o que se testou. Os testes estão no
admin, em tests/api/tracking.test.mjs (as listas e o pacote, sem browser) e
tests/tracking.spec.mjs (o snippet numa página, contra o dist/, com o
array.js da devDependency servido sem rede). O workflow tracking-sdk.yml
corre o mesmo spec uma vez por dia contra o array.js que o CDN do PostHog
serve nesse dia, e abre uma issue quando falha.
Uma versão nova: subir a version no package.json (semver: uma mudança no
que os forms ou os eventos levam é minor, uma que parte uma integração é
major), npm run build, e no mesmo PR, em apps-src/account-settings, o
npm install e o npm run install-build (o Account Settings importa as três
tags do dist/ pelo nome do pacote, e a CI falha se o bundle não for o que
elas dão); depois do merge, da main, nesta pasta:
npm publish --dry-runnpm publishO primeiro confere a versão e os ficheiros sem publicar nada. O segundo é do
dono: a conta do npm (funnel10x-org) tem 2FA, e o npm publish pede uma
autenticação no browser (EOTP: This operation requires a one-time
password) mesmo com a sessão iniciada (medido a 2026-10-08, na 1.3.0). Um
agente prepara a pasta na main e corre o primeiro, e o dono corre o segundo
no terminal dele. Sem sessão do npm na máquina, o npm publish não diz 401:
responde 404 Not Found no PUT do pacote (medido a 2026-10-16, na 2.2.0), e
o npm whoami dá o 401 que mostra o que falta, o npm login. A alternativa
sem OTP é o trusted publishing do npm (a
publicação pela CI do GitHub, sem token), que é configuração do dono no
npmjs.com.
Depois de publicar, o npm avisa que a versão may take a few minutes to become
available: espera-se, e só então se confirma. O npm view lê de uma cache e
mostrou a versão anterior durante esses minutos; o registo, lido directamente,
diz a verdade:
curl -s https://registry.npmjs.org/@funnel10x%2ftracking | node -e "process.stdin.on('data',d=>b+=d);let b='';process.stdin.on('end',()=>console.log(JSON.parse(b)['dist-tags']))"E o tag.js que o jsDelivr serve tem de ter o hash do integrity da tag do
GTM, senão a tag não carrega em site nenhum. As duas linhas têm de dar o mesmo
sha384-…:
echo "sha384-$(curl -sL "https://cdn.jsdelivr.net/npm/@funnel10x/tracking@$(node -p "require('./package.json').version")/dist/tag.js" | openssl dgst -sha384 -binary | openssl base64 -A)"grep -o 'sha384-[^"]*' dist/gtm.htmlO dist/THIRD_PARTY_LICENSES.txt leva as licenças do que vai embutido.
