npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.posthog com o __SV, como o do snippet oficial) posto antes de tudo, e o array.js do CDN do PostHog no canal 1.x (/static/1/array.js, pelo host dos assets do apiHost, ou pelo /_t de um repo direct). 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 pelas posthog das 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, um token). 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 $pageview espera por ele até 1 s, contado com o carregamento dele, e cada evento leva o fingerprint e o fingerprint_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, o token e 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 checkout no 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 (o submit de 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 evento contact_captured (com contact_source: url ou form), 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 site direct, a versão da página e o teste (page_version, ab_test), o interruptor de consentimento do site.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 seu try. Um que lance deixa na consola the dom extra failed (com o nome dele) e, em cada evento, o tracking_degraded com a lista dos que falharam (['dom']), e o PostHog mede na mesma, com o tracking_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 do array.js, o before_send, a API) deixa a página como estava: o stub sai, o window.f10xTracking fica sem version, 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 _gl do 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 init da fila manda, e este não arranca; avisa na consola (another PostHog is already on this page) e o window.f10xTracking.mode fica conflict;
  • a oficial depois deste: o stub deste é o window.posthog, e o init dela entra na fila dele, sem efeito (o stub nunca insere um script, por isso não há outro array.js); avisa na consola, e cada evento leva tracking_conflict: true, que é o sinal nos dados. A supabase/verify/atr2b.sql do admin guarda o resto: num host com este snippet, nenhum $pageview de 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 Referer de 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): o strict-origin existia 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_KEY num static, NEXT_PUBLIC_POSTHOG_KEY num nextjs). É 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 o posthog.com): o array.js pede-se a /_t/static/1/array.js. O vercel.json do 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 levava Referrer-Policy: strict-origin, para o Referer de 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_VERSION num static, NEXT_PUBLIC_POSTHOG_SDK_VERSION num nextjs), que o _scripts/tracking.mjs e o Tracking.tsx da base lêem.

  • consent: o do site.json. Com true, 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 chama window.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 o array.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 (o init sai da fila, e o array.js não se pede). A escolha guarda-se em localStorage (f10x_consent), e a página seguinte arranca sozinha. Com false, mede logo. O GTM do site arranca depois do stub do snippet, para uma tag que use o window.posthog o encontrar.

  • linkedDomains: o do site.json, opcional, uma lista de hosts (O cross-domain, abaixo). Sem ele, ou vazio, o cross-domain está desligado.

  • pageVersion e test: o version da página principal no tests.json (ou o N de uma variante) e o nome do teste activo, que o build entrega.

  • pageRevision, abEligibility e abAssignedVersion (2.3.0, M79): o build Direct fornece o SHA-256 do conteúdo e dependências locais, a elegibilidade (none, randomized, direct ou cross_exposure) e a versão atribuída pelo cookie válido. Carimbam cada evento como page_revision, ab_eligibility e ab_assigned_version; nunca ficam em propriedades persistentes da pessoa. Só randomized pertence à amostra atribuída do teste. A versão servida pode diferir da atribuída numa exposição cruzada. A identidade vN conserva-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): o dist/direct-init.js (o init com o apiHost: '/_t') e o dist/direct-rewrites.json (os três rewrites de cima, para o vercel.json), que o build do pacote escreve e o tests/api/gtm-tags.test.mjs compara 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 no apiHost: sem ele, vai para o i.posthog.com, que a mesma lista bloqueia (o array.js e o /i/). A reserva só corre pelo GTM (o f10x_tracking_failed é um evento do dataLayer), 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.js nã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 (um purchase, um identify) 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 no init, de forma síncrona: o contentor arranca do mesmo componente, logo a seguir ao init, como na base direct desde a direct-v9, e não do layout (o GoogleTagManager do @next/third-parties, um next/script), cuja ordem para o init de um componente cliente não está garantida. No header, o stub só nasce quando o tag.js executa, e ele é assíncrono como o gtm.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 o posthog ao 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 o tag.js assí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 dos defaults; se só servia este site, sai.
  • A reserva, só no header: a tag empurra o f10x_tracking_failed para o window.dataLayer, e com o contentor na página a tag de reserva (dist/gtm-fallback.html), no evento personalizado f10x_tracking_failed, apanha a visita em que o snippet falha. No build não há reserva.
  • As tags que chamam o posthog continuam a funcionar: o window.posthog passa 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 site direct com consent: 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 o client_reference_id com 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. Os ph_distinct_id, ph_session_id, ph_anon_id e contact_id chegam ao admin pela A4, com forma de id. A sale_checkout_keys expõe-os, e a sale_posthog_links liga a venda pelo cs_… 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 localStorage nem no sessionStorage com o nome dele, ph_<chave>_posthog): o snippet passa ao posthog-js o bootstrap com o distinctID e o sessionID da origem, e isIdentifiedID quando o sinal é i. O primeiro $pageview sai com o distinct_id e o $session_id da origem; de uma origem identificada, com um $device_id novo (o ph_anon_id dos forms nunca é o id do contacto) e o estado identified. O bootstrap junta-se ao que o site passe em posthog.bootstrap, sem o perder;
  • com identidade (anónima ou identificada, guardada ou dada pelo site): o browser não a troca, porque o bootstrap sobrepunha-a sem evento nenhum, e cada evento desta página leva o linked_distinct_id (o id que veio no link). O admin guarda-o e liga os dois distinct_id como 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 _f10x fica no URL da página de destino, onde qualquer tag de terceiros o lê, e com o sinal i pode levar um email. O PostHog recebe o URL inteiro, e a ingestão do admin apara-o como aos ph_*;
  • 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 sessionID com 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 checkout vai já, e não na fila (o send_instantly do posthog-js), porque a página sai a seguir. Um site cujo código já mande o seu checkout tira-o quando passar ao snippet, senão conta dois.
  • O purchase liga a venda pelo cs_…, por isso tem de disparar em todas as compras: no Piko, a 2026-10-16, o purchase ligava 78% das vendas e o checkout_completed todas.
  • 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 lead e o checkout_completed deixaram 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: tag ou direct; frame num iframe, preview numa pré-visualização, e conflict quando a página já tinha outro PostHog antes deste (um que chegue depois não muda o mode: deixa tracking_conflict nos eventos);
  • fields(): os valores de cima, agora;
  • grant(), deny(), consent(): o interruptor de um site direct;
  • onReady(fn): quando o PostHog estiver a medir;
  • capture(event, properties): um evento pelo mesmo PostHog (o purchase da página de obrigado, o checkout de 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). O window.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:

  1. O snippet carrega pela versão: num repo direct, a versão exacta no package.json (a da base), a chave na variável de ambiente e os rewrites do /_t no vercel.json; num site de fora, a tag do dist/gtm.html da versão, com a chave da account e, se ela tiver o proxy gerido, o apiHost. O sdkVersion fica vazio.
  2. 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 o before_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. O apiHost da tag nova é o api_host que a antiga tinha (o proxy, se ela o usava): vazio, perdia quem hoje chega por ele. A data dos defaults é a mesma em todas as instalações do domínio registável (a deste pacote).
  3. O consentimento: o consent do site.json escrito, e o banner a chamar grant() e deny(); 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.
  4. Os forms: um envio de teste, escrito à mão numa página aberta com ?utm_source=teste, deixa no PostHog um contact_captured com o email, no momento do envio, e o contacto no GHL; um email escrito e não enviado não deixa nada. Num site direct com consentimento, a location tem os quatro campos de atribuição da tabela, com o query key igual ao nome.
  5. Os links de email e de SMS levam contact_id={{contact.id}}.
  6. O checkout: o host em checkoutHosts (o Stripe já lá está), e num checkout em código os fields() na metadata e o capture('checkout') no clique. Um clique de teste num link do checkout deixa um checkout no PostHog, e a página de obrigado manda o purchase com o cs_… em todas as compras (O contrato dos eventos, acima); nenhum outro evento custom.
  7. 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.
  8. A prova: na consola, com a reserva instalada, window.f10xTracking.mode não é conflict; um pageview aberto com [email protected]&utm_source=teste chega ao PostHog (Activity, no projeto da account) uma vez só, inteiro, com o tracking_version da versão, o $lib_version que o CDN serve e nenhum tracking_conflict nem tracking_fallback; os campos do form e o link do checkout levam a utm_source e não o email; e com o jsDelivr bloqueado no browser (nas DevTools, Network request blocking, cdn.jsdelivr.net) chega um só $pageview, com tracking_fallback: true e sem tracking_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 com linkedDomains, cada link para o outro domínio abre com o _f10x, e o destino, num browser limpo, com o distinct_id da origem.

Desenvolver e publicar

npm ci
npm run build

O 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-run
npm publish

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

O dist/THIRD_PARTY_LICENSES.txt leva as licenças do que vai embutido.