@funnel10x/tracking 1.4.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,22 +5,29 @@ atribuição](https://github.com/funnel10x/f10x-admin/blob/main/docs/plano-atrib
5
5
  do admin. Vive em `packages/tracking/` do `funnel10x/f10x-admin`, e publica-se
6
6
  no npm como pacote público, pela versão.
7
7
 
8
- O que faz numa página:
9
-
10
- - **o PostHog** (o posthog-js, embutido: o snippet **é** o PostHog, e numa
11
- página que já tenha outro não arranca, ver o checklist), com os ids de
12
- anúncio como parâmetros de campanha, e **cada evento limpo antes de sair**: o URL fica com o host, o
13
- caminho e só os parâmetros de campanha da allow-list (`src/clean.js`), no
14
- evento, no perfil (`$set`, `$set_once`), no cookie do PostHog, nos heatmaps
15
- e na gravação de sessão (onde os pedidos de rede se gravam sem o corpo). O
16
- email, o nome, o telefone e as respostas de um quiz que um funil
17
- passe na query string ficam no browser;
18
- - **o FingerprintJS v5** (MIT), sem o pedido de monitorização que ele faz por
19
- omissão a um servidor deles; o `visitorId` vai em cada evento (`fingerprint`);
20
- - **os ids e os parâmetros do anúncio nos forms**: nos campos escondidos de
21
- cada form de lead da página (os que já existem com o nome, e os que faltam,
8
+ **Desde a 2.0.0 (Fase A2b, 2026-10-14) o snippet é o PostHog oficial com os
9
+ nossos extras por cima**: o mesmo `array.js` que a tag oficial carrega, do mesmo
10
+ CDN, no canal 1.x, e o SDK não vem neste ficheiro. O que faz numa página:
11
+
12
+ - **o PostHog oficial**, por um loader: o stub (o `window.posthog` com o
13
+ `__SV`, como o do snippet oficial) posto antes de tudo, e o `array.js` do CDN
14
+ do PostHog no canal 1.x (`/static/1/array.js`, pelo host dos assets do
15
+ `apiHost`, ou pelo `/_t` de um repo `direct`). As funcionalidades ficam como
16
+ na instalação oficial (feature flags, surveys, conversations, product tours;
17
+ os web experiments desligados, como lá), e um site desliga uma pelas
18
+ `posthog` das opções. **O PostHog recebe tudo**: o snippet não limpa nada, e o
19
+ URL chega inteiro, com o que a query string traga (o email, as respostas de
20
+ um quiz, um `token`). Quem apara é o admin, na ingestão (abaixo);
21
+ - **o FingerprintJS v5** (MIT, embutido), sem o pedido de monitorização que ele
22
+ faz por omissão a um servidor deles. O primeiro `$pageview` espera por ele
23
+ até 1 s, contado com o carregamento dele, e cada evento leva o `fingerprint`
24
+ e o `fingerprint_ms` (o tempo que levou, ou o tecto, 1000);
25
+ - **a lista de atribuição nos forms, no CRM e no checkout, e só ela**: os ids
26
+ e os 22 parâmetros do anúncio (`src/params.js`) nos campos escondidos de cada
27
+ form de lead da página (os que já existem com o nome, e os que faltam,
22
28
  acrescentados), no URL do iframe de um form, inquérito ou calendário do GHL,
23
- e nos links do checkout;
29
+ e nos links do checkout. O email, o `token` e o resto do URL não seguem: iam
30
+ para o URL do Stripe e para os campos do GHL, que é entregá-los a terceiros;
24
31
  - **o identify pelo id do contacto** num link de email ou de SMS
25
32
  (`?contact_id={{contact.id}}`);
26
33
  - **o email e o telefone que a pessoa envia** (desde a 1.1.0, como a HYROS;
@@ -28,7 +35,7 @@ O que faz numa página:
28
35
  (`email`, `phone`, `phoneNumber`, `phone_number`, `tel`, `telefone`), e dos
29
36
  campos de email e de telefone do form que ela envia (o `submit` de um
30
37
  `<form>`, ou o botão de enviar do form nativo do GHL). O de um form que ela
31
- não enviou não sai do browser. O telefone
38
+ não enviou não vai. O telefone
32
39
  ganha o indicativo do seletor de país (o intl-tel-input), que é o que um
33
40
  seletor costuma esconder. Vão num evento `contact_captured` (com
34
41
  `contact_source`: `url` ou `form`), cada valor uma vez por página, e
@@ -38,30 +45,114 @@ O que faz numa página:
38
45
  - **a versão do snippet em cada evento** (`tracking_version`), e, num site
39
46
  `direct`, **a versão da página e o teste** (`page_version`, `ab_test`), o
40
47
  **interruptor de consentimento** do `site.json`, e a guarda das
41
- pré-visualizações.
48
+ pré-visualizações;
49
+ - **os extras isolados do arranque do SDK** (desde a 2.1.0, Fase A2c): os
50
+ forms, o iframe e o checkout (`dom`), o email e o telefone (`contact`), o
51
+ FingerprintJS (`fingerprint`) e o cross-domain (`linker`) correm cada um no
52
+ seu `try`. Um que lance deixa na consola *the dom extra failed* (com o nome
53
+ dele) e, em cada evento, o `tracking_degraded` com a lista dos que falharam
54
+ (`['dom']`), e o PostHog mede na mesma, com o `tracking_version`. No 2.0.0 um
55
+ erro em qualquer deles impedia o SDK de carregar. Um erro no próprio arranque
56
+ (o núcleo: o stub, o consentimento, o carregamento do `array.js`, o
57
+ `before_send`, a API) deixa a página como estava: o stub sai, o
58
+ `window.f10xTracking` fica sem `version`, e numa tag do GTM arranca a tag
59
+ oficial de reserva (*A reserva*, abaixo);
60
+ - **o cross-domain entre domínios ligados, opcional** (`linkedDomains`, desde a
61
+ 2.1.0): os links e os forms para o outro domínio levam a pessoa e a sessão do
62
+ PostHog num parâmetro que expira em 2 minutos, como o `_gl` do GA (*O
63
+ cross-domain*, abaixo).
42
64
 
43
65
  **Quem lê isto do lado do admin** (Fase A3 do plano da atribuição): a fonte
44
- PostHog da ingestão guarda o `tracking_version`, a `page_version`, o `ab_test`
45
- e o `fingerprint` de cada evento, e o email e o telefone do `contact_captured`
46
- só em hash. O histórico de um funil, que chegou ao PostHog antes deste snippet
47
- com o URL inteiro, é aparado lá pela mesma allow-list do `src/clean.js`: um
48
- parâmetro que entre na `ALLOWED_PARAMS` entra também na entrada `posthog` da
49
- `ingest/pii.json` e na `supabase/verify/atr3.sql` do admin, no mesmo PR (um
50
- teste do admin prova que as três listas são a mesma).
51
-
52
- O posthog-js e o FingerprintJS vão dentro do ficheiro, pela versão que este
53
- pacote fixa, e não se resolvem no site. O que fica de fora são as extensões que
54
- o posthog-js carrega quando o projeto as liga (o gravador de sessão, os dead
55
- clicks, os web vitals), que vêm do PostHog pela mesma versão do posthog-js
56
- (`/static/<versão>/…`, pelo proxy num repo `direct`): esse código não passa
57
- pelo `integrity` da tag.
66
+ PostHog da ingestão guarda de cada toque o `tracking_version`, a
67
+ `page_version`, o `ab_test`, o `fingerprint`, e desde a A2b a versão do SDK e a
68
+ data dos `defaults` (`$lib_version` e `$config_defaults`), e o email e o
69
+ telefone do `contact_captured` só em hash; desde a A2c, o `tracking_fallback`
70
+ que a tag de reserva carimba e o `linked_distinct_id` do cross-domain (em hash
71
+ quando é um email, como o `distinct_id`). O `tracking_degraded` fica só no
72
+ PostHog, como o `fingerprint_ms`. **Apara cada URL pela mesma lista**
73
+ de `src/params.js`: um parâmetro que entre na `ALLOWED_PARAMS` entra também na
74
+ entrada `posthog` da `ingest/pii.json` e na `supabase/verify/atr3.sql` do
75
+ admin, no mesmo PR (um teste do admin prova que as três listas são a mesma).
76
+
77
+ ### Uma instalação activa por página: o snippet, e a oficial como reserva
78
+
79
+ Cada site, de qualquer account, escolhe **uma** de duas instalações, e nunca há
80
+ duas activas na mesma página (decisão 1 da A2b, revista pela decisão 1 da A2c):
81
+ a **oficial do PostHog** (a tag no GTM, o snippet oficial no código do site, ou
82
+ o posthog-js no código de uma app) ou **este snippet** (embutido num repo
83
+ `direct`, ou pela tag do `dist/gtm.html` em qualquer outro site). Num site com
84
+ a tag do GTM, a oficial pode lá estar **só como reserva**: outra tag, que
85
+ arranca quando o snippet não carregou, e nunca ao lado dele (*A reserva*,
86
+ abaixo). Quem escolhe este não perde nada da oficial: é o mesmo SDK, com os
87
+ extras. Com duas activas, a segunda ou toma o arranque da primeira ou duplica
88
+ cada evento:
89
+
90
+ - **a oficial antes deste**: o primeiro `init` da fila manda, e este não
91
+ arranca; avisa na consola (*another PostHog is already on this page*) e o
92
+ `window.f10xTracking.mode` fica `conflict`;
93
+ - **a oficial depois deste**: o stub deste é o `window.posthog`, e o `init`
94
+ dela entra na fila dele, sem efeito (o stub nunca insere um script, por isso
95
+ não há outro `array.js`); avisa na consola, e cada evento leva
96
+ `tracking_conflict: true`, que é o sinal nos dados. A
97
+ `supabase/verify/atr2b.sql` do admin guarda o resto: num host com este
98
+ snippet, nenhum `$pageview` de outra instalação no mesmo minuto.
99
+
100
+ Num contentor GTM partilhado por vários sites, a tag oficial de sempre dispara
101
+ só nos hosts que a escolheram, por uma **condição de inclusão** por hostname (a
102
+ lista desses hosts, e não uma excepção, para um host novo entrar sem disparar
103
+ nada), e a reserva é outra tag. **Dentro de um
104
+ domínio registável, todas as instalações levam a mesma data de `defaults`**: o
105
+ cookie do PostHog é partilhado pelos subdomínios, e a data muda a regra entre o
106
+ cookie e o `localStorage` (`cookieWinsOnConflict`). A deste pacote é
107
+ `2026-08-30`; uma tag oficial no mesmo domínio passa a ela.
108
+
109
+ ### O que se actualiza sozinho, e o que não
110
+
111
+ | camada | quem a dá | actualiza-se |
112
+ | --- | --- | --- |
113
+ | 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 |
114
+ | as extensões (o gravador, os surveys) | o mesmo CDN | seguem a versão do `array.js` (`strict_script_versioning: true`) |
115
+ | o comportamento por omissão do SDK | a data dos `defaults`, fixa neste pacote | só com uma versão nova do pacote |
116
+ | 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 |
117
+
118
+ Os testes A/B de um site `direct` correm sobre um SDK externo: uma versão nova
119
+ chega a todas as versões de um teste ao mesmo tempo, e a comparação entre elas
120
+ continua válida. O admin guarda a versão do SDK e a data dos `defaults` de cada
121
+ toque, para uma análise se poder partir por elas.
122
+
123
+ **O `sdkVersion`, numa emergência.** Uma versão exacta do posthog-js
124
+ (`'1.438.9'`) fixa o caminho exacto do `array.js`, que é imutável no CDN: numa
125
+ tag, na configuração dela; num repo `direct`, pela variável de ambiente da
126
+ Vercel (`VITE_POSTHOG_SDK_VERSION` num `static`, `NEXT_PUBLIC_POSTHOG_SDK_VERSION`
127
+ num `nextjs`), que muda com um redeploy, sem PR. É a resposta a uma versão do
128
+ SDK que parta um site, até uma versão nova do pacote; o workflow diário
129
+ `tracking-sdk.yml` do admin corre os testes contra o `array.js` que o CDN serve
130
+ nesse dia, e abre uma issue quando falham.
131
+
132
+ ### O que muda por o PostHog receber tudo
133
+
134
+ O que a 1.x limpava no browser vai inteiro ao PostHog (decisão 2 da A2b), e
135
+ isso tem três consequências que se sabem:
136
+
137
+ - **o cookie do PostHog leva o URL de entrada em bruto** (o
138
+ `$initial_person_info`), e o cookie é partilhado por todos os subdomínios do
139
+ domínio registável;
140
+ - **as gravações seguem as definições do projeto**, com os corpos dos pedidos
141
+ de rede se o projeto os ligar (um form de lead manda o email);
142
+ - **o `Referer` de um pedido ao `/_t`** (do mesmo domínio) leva o URL inteiro,
143
+ e o site volta à política do browser (`strict-origin-when-cross-origin`): o
144
+ `strict-origin` existia por isso, e a razão caiu.
145
+
146
+ **Um site com CSP** tem de deixar passar o host dos assets do PostHog no
147
+ `script-src` (o `us-assets.i.posthog.com`, ou o proxy da account): o SDK deixou
148
+ de vir no ficheiro. É uma das razões de a 2.0.0 ser major.
58
149
 
59
150
  ## Num repo `direct`: embutido no build
60
151
 
61
152
  O `package.json` do repo pina a versão exacta (sem `^`), e o build embute-o:
62
153
 
63
154
  ```json
64
- { "dependencies": { "@funnel10x/tracking": "1.0.0" } }
155
+ { "dependencies": { "@funnel10x/tracking": "2.1.0" } }
65
156
  ```
66
157
 
67
158
  ```js
@@ -70,7 +161,9 @@ import { init } from '@funnel10x/tracking'
70
161
  init({
71
162
  key: import.meta.env.VITE_POSTHOG_KEY,
72
163
  apiHost: '/_t',
164
+ sdkVersion: import.meta.env.VITE_POSTHOG_SDK_VERSION || undefined,
73
165
  consent: site.consent,
166
+ linkedDomains: site.linkedDomains,
74
167
  pageVersion: 3,
75
168
  test: '/pt/acq_f1/webinar/lp#2',
76
169
  })
@@ -79,9 +172,10 @@ init({
79
172
  - `key`: a chave do projeto PostHog da account (`phc_…`), pela variável de
80
173
  ambiente da Vercel (`VITE_POSTHOG_KEY` num `static`, `NEXT_PUBLIC_POSTHOG_KEY`
81
174
  num `nextjs`). É pública: vai no HTML de qualquer maneira.
82
- - `apiHost: '/_t'`: os eventos vão pelo **proxy no domínio do site**, que livra
83
- os envios das listas de bloqueio (a EasyPrivacy bloqueia o `posthog.com`). O
84
- `vercel.json` do repo leva os três rewrites, por esta ordem:
175
+ - `apiHost: '/_t'`: o SDK e os eventos vão pelo **proxy no domínio do site**,
176
+ que os livra das listas de bloqueio (a EasyPrivacy bloqueia o `posthog.com`):
177
+ o `array.js` pede-se a `/_t/static/1/array.js`. O `vercel.json` do repo leva
178
+ os três rewrites, por esta ordem:
85
179
 
86
180
  ```json
87
181
  {
@@ -96,16 +190,27 @@ init({
96
190
  O padrão é `:path(.*)` e não `:path*`, que não apanha a barra final dos
97
191
  envios (`/e/`) e lhes responde 404 (medido em produção). O `_` à frente
98
192
  garante que não colide com um funil (a base não deixa um nome
99
- de funil começar por `_`). **E o site leva `Referrer-Policy: strict-origin`**
100
- (nos `headers` do `vercel.json`): o proxy é do mesmo domínio, e com a política
101
- por omissão o browser manda-lhe o URL inteiro da página no `Referer`, com o
102
- que a query string traga, e o rewrite reencaminha-o para o PostHog.
193
+ de funil começar por `_`). Até à 1.x o site levava `Referrer-Policy:
194
+ strict-origin`, para o `Referer` de um pedido ao proxy não levar o URL
195
+ inteiro; desde a 2.0.0 o PostHog recebe-o de qualquer maneira, e a base volta
196
+ à política do browser.
197
+ - `sdkVersion`: vazio, o canal 1.x do CDN; uma versão exacta só numa
198
+ emergência (acima), pela variável de ambiente da Vercel
199
+ (`VITE_POSTHOG_SDK_VERSION` num `static`, `NEXT_PUBLIC_POSTHOG_SDK_VERSION` num
200
+ `nextjs`), que o `_scripts/tracking.mjs` e o `Tracking.tsx` da base lêem.
103
201
  - `consent`: o do `site.json`. Com `true`, o snippet não mede nem guarda nada
104
202
  até à escolha: os forms levam só os parâmetros do anúncio tirados do URL, e o
105
203
  banner do site chama `window.f10xTracking.grant()` no sim e `.deny()` no não
106
- (ou para retirar o sim, que pára o PostHog e apaga o que ele guardou). A
107
- escolha guarda-se em `localStorage` (`f10x_consent`), e a página seguinte
108
- arranca sozinha. Com `false`, mede logo.
204
+ (ou para retirar o sim, que pára o PostHog e apaga o que ele guardou). **Antes
205
+ do sim nada se pede, nem o `array.js`**: o padrão que o PostHog recomenda,
206
+ carregar com a recolha desligada, pede a configuração remota antes da
207
+ escolha. Um não antes de o SDK chegar cancela o arranque (o `init` sai da
208
+ fila, e o `array.js` não se pede). A escolha guarda-se em `localStorage`
209
+ (`f10x_consent`), e a página seguinte arranca sozinha. Com `false`, mede
210
+ logo. **O GTM do site arranca depois do stub do snippet**, para uma tag que
211
+ use o `window.posthog` o encontrar.
212
+ - `linkedDomains`: o do `site.json`, opcional, uma lista de hosts (*O
213
+ cross-domain*, abaixo). Sem ele, ou vazio, o cross-domain está desligado.
109
214
  - `pageVersion` e `test`: o `version` da página principal no `tests.json` (ou o
110
215
  `N` de uma variante) e o nome do teste activo, que o build entrega.
111
216
 
@@ -114,26 +219,72 @@ de uma versão) não mede nada nem mexe nos forms.
114
219
 
115
220
  ## Num site que não é repo nosso: a tag do GTM
116
221
 
117
- Num funil do GHL ou num site em Webflow, onde não há build, o mesmo pacote
118
- carrega-se por uma tag Custom HTML do GTM (em All Pages), **pela versão fixa**
119
- no URL do jsDelivr e com o hash do ficheiro no `integrity`. A tag de cada versão
222
+ Num funil do GHL, num site institucional ou num site em Webflow, onde não há
223
+ build, o mesmo pacote carrega-se por uma tag Custom HTML do GTM (em All Pages),
224
+ **em vez da tag oficial do PostHog, que fica só como reserva** (*Uma instalação
225
+ activa por página*, acima, e *A reserva*, abaixo), **pela versão fixa** no URL
226
+ do jsDelivr e com o hash do ficheiro no `integrity`. A tag de cada versão
120
227
  publicada está no próprio pacote, em `dist/gtm.html`, e cola-se tal como está:
121
- só se trocam a chave, o `apiHost` (se a account tiver o proxy gerido, abaixo) e
122
- os hosts do checkout.
228
+ só se trocam a chave, o `apiHost` (se a account tiver o proxy gerido, abaixo),
229
+ os hosts do checkout, os domínios ligados (*O cross-domain*, abaixo; vazio,
230
+ desligado) e, numa emergência, o `sdkVersion`.
123
231
 
124
232
  ```html
125
233
  <script>
126
234
  window.f10xTracking = {
127
235
  key: '{{PostHog project key}}',
128
236
  apiHost: '',
129
- checkoutHosts: []
237
+ sdkVersion: '',
238
+ checkoutHosts: [],
239
+ linkedDomains: []
130
240
  }
241
+ ;(function () {
242
+ var timer
243
+ function failed() {
244
+ clearTimeout(timer)
245
+ if (window.__f10xTracking && window.__f10xTracking.version) {
246
+ window.f10xTracking = window.__f10xTracking
247
+ return
248
+ }
249
+ if (window.__f10xTrackingFailed) return
250
+ window.__f10xTrackingFailed = true
251
+ ;(window.dataLayer = window.dataLayer || []).push({ event: 'f10x_tracking_failed' })
252
+ }
253
+ var script = document.createElement('script')
254
+ script.src = 'https://cdn.jsdelivr.net/npm/@funnel10x/tracking@2.1.0/dist/tag.js'
255
+ script.integrity = 'sha384-…'
256
+ script.crossOrigin = 'anonymous'
257
+ script.onload = function () {
258
+ clearTimeout(timer)
259
+ if (!(window.f10xTracking && window.f10xTracking.version)) failed()
260
+ }
261
+ script.onerror = failed
262
+ timer = setTimeout(failed, 10000)
263
+ document.head.appendChild(script)
264
+ })()
131
265
  </script>
132
- <script src="https://cdn.jsdelivr.net/npm/@funnel10x/tracking@1.4.0/dist/tag.js" integrity="sha384-…" crossorigin="anonymous"></script>
133
266
  ```
134
267
 
135
- Uma versão nova é uma tag nova, nunca `@1` nem `@latest`: o GTM de cada site
136
- diz que versão corre, e o `tracking_version` de cada evento confirma-o.
268
+ **Desde a 2.1.0 a tag diz quando o snippet não arrancou**: empurra para o
269
+ `dataLayer` o evento `f10x_tracking_failed`, uma vez por página, quando o
270
+ `tag.js` não chega (o `onerror`: o jsDelivr em baixo ou bloqueado, um
271
+ `integrity` que não bate, uma CSP que o recusa; ou nenhum dos dois ao fim de
272
+ 10 s, um pedido pendurado) ou chega e não deixa a `version` no
273
+ `window.f10xTracking` (o `onload`: um erro nosso no arranque). É o evento em
274
+ que a reserva dispara. Um `tag.js` que chegue depois dos 10 s encontra a
275
+ oficial e fica inerte (o `conflict`), e continua a haver uma só instalação
276
+ activa; e uma tag que dispare outra vez com o snippet já a correr não empurra
277
+ nada, mesmo que a rede lhe falhe. **O evento vai para o `window.dataLayer`**:
278
+ num GTM com a data layer renomeada (o `l=` do snippet do GTM) a reserva não
279
+ dispara, e a prova do ponto 8 do checklist apanha-o. O `<script>` do jsDelivr cria-se por código, e não como
280
+ um `<script src>` no HTML: o GTM recria cada `<script>` de um Custom HTML
281
+ copiando-lhe os atributos, e um `onload` escrito como atributo é um handler
282
+ inline, que uma CSP com nonce não deixa correr; posto como propriedade, de
283
+ dentro do script da tag, corre sempre.
284
+
285
+ Uma versão nova é uma tag nova, nunca `@2` nem `@latest`: o GTM de cada site
286
+ diz que versão dos extras corre, e o `tracking_version` de cada evento
287
+ confirma-o. O SDK, esse, actualiza-se sozinho, pelo CDN do PostHog.
137
288
 
138
289
  **O proxy gerido do PostHog, opcional.** Sem build não há o `/_t`, e com o
139
290
  `apiHost` vazio os envios vão para o `us.i.posthog.com`: funciona, mas a
@@ -141,7 +292,8 @@ EasyPrivacy bloqueia-o, e a visita de quem tem um bloqueador não chega ao
141
292
  PostHog. O PostHog tem um proxy
142
293
  gerido, um subdomínio da account (um CNAME para o PostHog, nas definições da
143
294
  organização, *Managed reverse proxy*) que recebe os envios e os passa ao
144
- PostHog. Com ele, o `apiHost` da tag é esse subdomínio: na IR,
295
+ PostHog, e serve também o `array.js`. Com ele, o `apiHost` da tag é esse
296
+ subdomínio: na IR,
145
297
  `https://p.infinityresponse.com`, e na IMO Circle, `https://p.imocircle.com`
146
298
  (os dois válidos, lidos no PostHog a 2026-10-08; o Piko ainda não tem). As listas que bloqueiam pelo nome do domínio deixam de o apanhar;
147
299
  o uBlock Origin no Firefox, que lê o CNAME, apanha na mesma, e quanto se
@@ -174,6 +326,77 @@ anúncio, e os campos escondidos do form lêem-nos de lá (cada campo com o
174
326
  código do form: dentro de um iframe o snippet não arranca o PostHog, só passa
175
327
  ao form o que veio no URL dele.
176
328
 
329
+ ### A reserva: a tag oficial quando o snippet não carrega
330
+
331
+ Num site com a tag do snippet, a tag oficial do PostHog passa a reserva
332
+ (decisão 1 da A2c): uma tag Custom HTML (o template da galeria não tem onde pôr
333
+ uma função) com o snippet oficial do PostHog, que dispara **só no evento
334
+ personalizado `f10x_tracking_failed`**, e por isso só quando o snippet tentou e
335
+ não conseguiu, nunca antes dele. O que leva:
336
+
337
+ - **o snippet oficial do PostHog**, copiado das definições do projeto (o stub
338
+ com o `__SV`), com o `posthog.init` de baixo no lugar do dele;
339
+ - **a mesma chave e o mesmo `api_host`** da tag do snippet: o proxy gerido da
340
+ account se ela o tiver (o `apiHost` da tag), senão `https://us.i.posthog.com`;
341
+ - **a data de `defaults` do pacote** (`2026-08-30`, a mesma em todas as
342
+ instalações do domínio registável) e `person_profiles: 'identified_only'`;
343
+ - **um `before_send` que carimba `tracking_fallback: true` em cada evento**, que
344
+ é o que distingue uma visita pela reserva nos dados (o admin guarda-o). Pelo
345
+ `before_send` e não pelo `register`, que persistia e marcava os `$pageview`
346
+ do snippet nas páginas seguintes.
347
+
348
+ ```html
349
+ <script>
350
+ /* aqui o stub do snippet oficial do PostHog, copiado das definições do projeto, sem o posthog.init dele */
351
+ posthog.init('{{PostHog project key}}', {
352
+ api_host: 'https://us.i.posthog.com',
353
+ ui_host: 'https://us.posthog.com',
354
+ defaults: '2026-08-30',
355
+ person_profiles: 'identified_only',
356
+ custom_campaign_params: ['a_campaign_id', 'a_adset_id', 'a_ad_id', 'h_ad_id', 'fbc_id', 'utm_id', 'utm_placement', 'lp', 'narrative'],
357
+ before_send: function (event) {
358
+ if (event && event.event !== '$snapshot') {
359
+ event.properties = event.properties || {}
360
+ event.properties.tracking_fallback = true
361
+ }
362
+ return event
363
+ }
364
+ })
365
+ </script>
366
+ ```
367
+
368
+ O `custom_campaign_params` é o do pacote, para os ids de anúncio irem como
369
+ propriedades nas duas instalações. **O trigger é um evento personalizado**, com
370
+ o nome `f10x_tracking_failed`, e **a mesma condição de consentimento da tag do
371
+ snippet**. Nunca All Pages nem *Window Loaded*: em All Pages a oficial dispara
372
+ antes de o `tag.js` chegar e toma o arranque (o `conflict`), e num site com
373
+ banner o `load` chega antes do sim, a reserva media sem ele, e quando o sim
374
+ fizesse correr a tag do snippet ela dava `conflict`. Pelo evento, a reserva já
375
+ é segura contra duplicados: com o snippet carregado o evento não sai, e se
376
+ saísse o `init` dela era um no-op no stub do snippet.
377
+
378
+ **Num contentor partilhado** (o `GTM-PM4T6FGX` do Piko) são duas tags oficiais:
379
+ a de sempre, em All Pages, sem `before_send`, com a condição de inclusão por
380
+ hostname só nos hosts que escolhem a oficial (o `app.piko.com`); e a reserva,
381
+ outra tag, só no `f10x_tracking_failed`, com o `before_send`. Uma tag só a fazer
382
+ as duas coisas disparava em All Pages antes do `tag.js` nos hosts do snippet (o
383
+ `conflict`) e carimbava `tracking_fallback` no `app.piko.com`, que não é
384
+ reserva nenhuma. A tag do snippet leva a excepção para os hosts `direct` (onde
385
+ o snippet vai no build) e para os que escolhem a oficial.
386
+
387
+ **O que a reserva cobre**: o jsDelivr em baixo ou bloqueado, um `integrity` que
388
+ não bata, e um erro nosso no arranque. **O que não cobre**: uma versão do SDK
389
+ que parta (a oficial carrega o mesmo `array.js`, e a resposta é o
390
+ `sdkVersion`), e os bloqueadores de anúncios (bloqueiam as duas). Num site
391
+ `direct` não há reserva: o snippet vai no build, a tag do snippet não dispara
392
+ nesses hosts, e o que o protege são os extras isolados.
393
+
394
+ **O que uma visita pela reserva perde**: a visita conta, com o URL inteiro e os
395
+ parâmetros dele no PostHog, e perde-se o que é dos extras: os ids nos campos
396
+ dos forms, no URL do iframe do GHL e nos links do checkout (os `ph_*` e o
397
+ `client_reference_id`), o `contact_captured` (o email e o telefone), o
398
+ fingerprint, o `_f10x` do cross-domain, e o `tracking_version`.
399
+
177
400
  ## Os campos
178
401
 
179
402
  O que os forms, o iframe e os links do checkout levam:
@@ -208,19 +431,97 @@ O que os forms, o iframe e os links do checkout levam:
208
431
  checkout.
209
432
  - **Num checkout criado no servidor** (uma sessão do Stripe em código),
210
433
  `window.f10xTracking.fields()` dá os mesmos valores, para a metadata.
434
+ Os `ph_distinct_id`, `ph_session_id`, `ph_anon_id` e `contact_id` chegam ao
435
+ admin pela A4, com forma de id. A `sale_checkout_keys` expõe-os, e a
436
+ `sale_posthog_links` liga a venda pelo `cs_…` observado no PostHog primeiro,
437
+ senão pelo primeiro id da metadata que o projeto da account tenha visto.
438
+
439
+ ## O cross-domain: `linkedDomains`
440
+
441
+ **Desligado por omissão**, e só para um funil que atravesse dois domínios
442
+ registáveis nossos (decisão 3 da A2c). Entre subdomínios do mesmo domínio o
443
+ cookie do PostHog já segue a pessoa, também em sufixos de duas partes
444
+ (`.com.br`, `.co.uk`), e o que hoje atravessa domínios são terceiros (o Stripe,
445
+ o redirect dos links do GHL), que têm os seus caminhos. O posthog-js não tem
446
+ linker; este desenha-se como o `_gl` do GA.
447
+
448
+ **A opção** é uma lista de hosts (`go.marca.pt`) ou de domínios registáveis
449
+ (`marca.pt`, que apanha os subdomínios), sem esquema nem caminho, ou uma string
450
+ com eles separados por vírgulas ou espaços; uma entrada sem forma de hostname
451
+ (um `*.marca.pt`) sai, com um aviso na consola. Vai no
452
+ `window.f10xTracking` da tag, ou no `init` de um repo `direct` (o `linkedDomains`
453
+ do `site.json`, que a base passa). Os dois sites levam o outro na lista: um
454
+ `_f10x` que chegue só se lê numa página com a lista preenchida.
455
+
456
+ **À saída**, os links (`a[href]`) e os forms (`form[action]`) para um host da
457
+ lista ganham, no clique (o esquerdo, e o do meio, que abre noutro separador;
458
+ o direito não, porque o menu dele tem *Copiar endereço do link*, e uma marca
459
+ fresca no instante da cópia é o instante da partilha; e o direito tira o
460
+ `_f10x` que um clique anterior tenha deixado no link) ou no envio, o parâmetro
461
+ `_f10x=<marca de tempo, em base 36>.<i ou a>.<distinct id>.<session id>`,
462
+ codificado para o URL. Só com o PostHog a medir (com `consent: true`, depois do
463
+ sim). Ficam de fora os hosts do checkout, que têm a decoração deles, e um
464
+ destino que partilha com a página um domínio da lista (o cookie já o segue). O
465
+ `i` diz que a origem está identificada (o `distinct_id` já não é o
466
+ `$device_id`: um `contact_id` de um link de email, um identify), e o `a` que é
467
+ anónima. **O `distinct_id` vai sempre, mesmo quando é um email** (o dono,
468
+ 2026-10-14): com o sinal `i` o destino arranca identificado no PostHog, e os
469
+ eventos caem logo na pessoa do contacto. Num form GET o browser troca a query
470
+ string do `action` pelos campos, e o `_f10x` vai num campo escondido; num POST
471
+ vai no `action`.
472
+
473
+ **À chegada**, um `_f10x` cuja marca está a **menos de 2 minutos** do
474
+ carregamento da página (contados até ao carregamento, e não até ao sim do
475
+ banner; nos dois sentidos, para um relógio adiantado na origem não deixar um
476
+ link válido mais do que isso):
477
+
478
+ - **sem identidade do PostHog na página** (nada no cookie, no `localStorage` nem
479
+ no `sessionStorage` com o nome dele, `ph_<chave>_posthog`): o snippet passa
480
+ ao posthog-js o `bootstrap` com o `distinctID` e o `sessionID` da origem, e
481
+ `isIdentifiedID` quando o sinal é `i`. O primeiro `$pageview` sai com o
482
+ `distinct_id` e o `$session_id` da origem; de uma origem identificada, com um
483
+ `$device_id` novo (o `ph_anon_id` dos forms nunca é o id do contacto) e o
484
+ estado `identified`. O `bootstrap` junta-se ao que o site passe em
485
+ `posthog.bootstrap`, sem o perder;
486
+ - **com identidade** (anónima ou identificada, guardada ou dada pelo site): o
487
+ browser não a troca, porque o `bootstrap` sobrepunha-a sem evento nenhum, e
488
+ cada evento desta página leva o `linked_distinct_id` (o id que veio no link).
489
+ O admin guarda-o e liga os dois `distinct_id` como factos de ligação;
490
+ - **fora da janela**: nada. A pessoa fica com um id novo, e o admin liga-a por
491
+ outro caminho.
492
+
493
+ **Com `consent: true`** nada acontece antes do sim: o `_f10x` fica em memória,
494
+ o sim aplica-o (a janela conta desde o carregamento), e um não deita-o fora.
495
+
496
+ O que se sabe e se aceitou:
497
+
498
+ - **o `_f10x` fica no URL da página de destino**, onde qualquer tag de
499
+ terceiros o lê, e com o sinal `i` pode levar um email. O PostHog recebe o URL
500
+ inteiro, e a ingestão do admin apara-o como aos `ph_*`;
501
+ - **um link reencaminhado dentro dos 2 minutos junta duas pessoas**: é o preço
502
+ da janela, e 2 minutos é a do `_gl` (apanham 94% dos saltos medidos entre
503
+ hosts do Piko);
504
+ - **um redirect no destino que apague a query string parte o salto**, como no
505
+ GA: a aceitação de um site com a opção prova cada link;
506
+ - **um relógio adiantado no browser de origem perde a sessão, e não a
507
+ pessoa**: o posthog-js ignora um `sessionID` com mais de 1 minuto no futuro.
211
508
 
212
509
  ## A API
213
510
 
214
511
  `init(options)` (ou a tag) devolve, e põe em `window.f10xTracking`:
215
512
 
216
513
  - `mode`: `tag` ou `direct`; `frame` num iframe, `preview` numa
217
- pré-visualização, e `conflict` quando a página já tem outro PostHog;
514
+ pré-visualização, e `conflict` quando a página já tinha outro PostHog antes
515
+ deste (um que chegue depois não muda o `mode`: deixa `tracking_conflict` nos
516
+ eventos);
218
517
  - `fields()`: os valores de cima, agora;
219
518
  - `grant()`, `deny()`, `consent()`: o interruptor de um site `direct`;
220
519
  - `onReady(fn)`: quando o PostHog estiver a medir;
221
520
  - `capture(event, properties)`: um evento pelo mesmo PostHog (o `lead` de um
222
521
  form em código), que sem consentimento não faz nada;
223
- - `posthog`: a instância, para o que não está aqui.
522
+ - `posthog`: a instância do PostHog depois de o SDK arrancar, e o stub até lá
523
+ (o que se lhe chamar fica na fila, como no stub oficial). O
524
+ `window.posthog` é o mesmo, e é por ele que as tags do GTM o usam.
224
525
 
225
526
  ## Os modelos de parâmetros de URL
226
527
 
@@ -256,24 +557,34 @@ os ids `a_` já os dizem.
256
557
  **Meta**, nos *URL parameters* de cada anúncio:
257
558
 
258
559
  ```
259
- utm_source=meta&utm_medium=paid_social&utm_campaign=acq_f1_webinar_cold&utm_content={{ad.name}}&utm_placement={{placement}}&a_campaign_id={{campaign.id}}&a_adset_id={{adset.id}}&a_ad_id={{ad.id}}
560
+ 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}}
260
561
  ```
261
562
 
262
563
  O nome do anúncio acaba no código do asset (decisão 10 do plano), e é por isso
263
- que vai no `utm_content`. O `utm_source` é `meta`, como na Traffic Sources e nos
564
+ que vai no `utm_content`; o `utm_term` leva a campanha e o ad set, separados por
565
+ ` >> ` (Fase M60 do plano da Marketing), só para se ler no PostHog: nenhum
566
+ 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
264
567
  anúncios das três accounts (45.819 pageviews com `fbclid` em 90 dias, medidos a
265
568
  2026-10-08); até à 1.2.0 este modelo dizia `facebook`, que nenhum usava.
266
569
 
267
570
  **Google**, no *Final URL suffix* da campanha (ou do ad group, ou do anúncio:
268
571
  o `utm_campaign` é o do funil e do stage, e por isso não vai ao nível da conta),
269
- com o auto-tagging ligado (o `gclid`) e um parâmetro personalizado, `asset`, em
270
- cada anúncio (o código do asset, que num anúncio de pesquisa, num responsive
271
- display ou na Performance Max não chega pelo nome):
572
+ com o auto-tagging ligado (o `gclid`). A Google não tem macro para nomes, só
573
+ para ids, e por isso os nomes vão por três parâmetros personalizados, escritos à
574
+ mão, com o nome só de letras e dígitos e a referência com o `_` à frente:
575
+ `campaign` (`{_campaign}`) na campanha, `adgroup` (`{_adgroup}`) em cada ad
576
+ group e `ad` (`{_ad}`) em cada anúncio (o nome dele, que acaba no código do
577
+ asset; num anúncio de pesquisa, que não tem campo de nome, é o único nome que
578
+ leva). Sem eles chegam os ids `a_`, que
579
+ são a garantia. Numa campanha de pesquisa o `utm_term` leva a keyword no fim, e
580
+ o ` >> ` vai codificado:
272
581
 
273
582
  ```
274
- utm_source=google&utm_medium=cpc&utm_campaign=acq_f1_webinar&utm_term={keyword}&utm_content={_asset}&a_campaign_id={campaignid}&a_adset_id={adgroupid}&a_ad_id={creative}
583
+ 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}
275
584
  ```
276
585
 
586
+ Fora da pesquisa, o mesmo sem o `%20%3E%3E%20{keyword}`.
587
+
277
588
  ## Os links orgânicos
278
589
 
279
590
  Um link que não é anúncio (a bio do Instagram, a descrição de um vídeo, um
@@ -291,17 +602,20 @@ cada link com o sítio onde foi posto.
291
602
  Antes de ligar o tráfego:
292
603
 
293
604
  1. **O snippet carrega pela versão**: num repo `direct`, a versão exacta no
294
- `package.json`, a chave na variável de ambiente e os rewrites do `/_t` no
295
- `vercel.json`; num site de fora, a tag do `dist/gtm.html` da versão, com a
296
- chave da account e, se ela tiver o proxy gerido, o `apiHost`.
297
- 2. **Nenhum outro PostHog na página**: a tag ou o snippet do PostHog que o site
298
- já tenha (no GTM, no head, no código do site) sai quando este entra. Este
299
- snippet já é o PostHog, e com outro na página não arranca (avisa na
300
- consola, e o `window.f10xTracking.mode` fica `conflict`). Com a mesma chave
301
- não se perde nada: quem volta mantém o id anónimo, e o URL inicial que o
302
- outro deixou no cookie reescreve-se limpo. **O `apiHost` da tag nova é o
303
- `api_host` que a antiga tinha** (o proxy, se ela o usava): vazio, perdia
304
- quem hoje chega por ele.
605
+ `package.json` (a da base), a chave na variável de ambiente e os rewrites
606
+ do `/_t` no `vercel.json`; num site de fora, a tag do `dist/gtm.html` da
607
+ versão, com a chave da account e, se ela tiver o proxy gerido, o `apiHost`.
608
+ O `sdkVersion` fica vazio.
609
+ 2. **O snippet, e a oficial como reserva**: este snippet é a única instalação
610
+ activa na página. Ao escolher este, o snippet do PostHog que o site já tenha
611
+ no head ou no código sai quando este entra, e no GTM a tag oficial passa a
612
+ reserva (*A reserva*, acima: só no `f10x_tracking_failed`, com o
613
+ `before_send`); num contentor partilhado, a oficial de sempre fica com a
614
+ condição de inclusão por hostname, e a reserva é outra tag. Com a mesma
615
+ chave não se perde nada: quem volta mantém o id anónimo. **O `apiHost` da
616
+ tag nova é o `api_host` que a antiga tinha** (o proxy, se ela o usava):
617
+ vazio, perdia quem hoje chega por ele. **A data dos `defaults` é a mesma em
618
+ todas as instalações do domínio registável** (a deste pacote).
305
619
  3. **O consentimento**: o `consent` do `site.json` escrito, e o banner a chamar
306
620
  `grant()` e `deny()`; num site de fora, a tag no GTM com a condição do
307
621
  consentimento se o site tiver banner, e no head só se não tiver.
@@ -315,9 +629,19 @@ Antes de ligar o tráfego:
315
629
  checkout em código os `fields()` na metadata.
316
630
  7. **Os anúncios**: o texto do gerador de links nos anúncios da Meta e da
317
631
  Google, e os links orgânicos gerados por ele, com as UTMs da Traffic Sources.
318
- 8. **A prova**: um pageview aberto com `?email=teste@example.com&utm_source=teste`
319
- chega ao PostHog (Activity, no projeto da account) com a `utm_source` e sem
320
- o `email`, no evento e no perfil, e com o `tracking_version` da versão.
632
+ 8. **A prova**: na consola, com a reserva instalada,
633
+ `window.f10xTracking.mode` não é `conflict`; um pageview aberto com
634
+ `?email=teste@example.com&utm_source=teste` chega ao PostHog (Activity, no
635
+ projeto da account) uma vez só, inteiro, com o `tracking_version` da versão,
636
+ o `$lib_version` que o CDN serve e nenhum `tracking_conflict` nem
637
+ `tracking_fallback`; os campos do form e o link do checkout levam a
638
+ `utm_source` e não o `email`; e com o jsDelivr bloqueado no browser (nas
639
+ DevTools, *Network request blocking*, `cdn.jsdelivr.net`) chega um só
640
+ `$pageview`, com `tracking_fallback: true` e sem `tracking_version` (sem ele,
641
+ a reserva não disparou: o trigger, a condição de consentimento, ou um GTM com
642
+ a data layer renomeada, onde o evento não chega). Num site
643
+ com `linkedDomains`, cada link para o outro domínio abre com o `_f10x`, e o
644
+ destino, num browser limpo, com o `distinct_id` da origem.
321
645
 
322
646
  ## Desenvolver e publicar
323
647
 
@@ -328,8 +652,11 @@ npm run build
328
652
 
329
653
  O `dist/` commita-se, como o build das apps do admin, e a CI refaz-o e falha se
330
654
  não der os mesmos bytes: o que se publica é o que se testou. Os testes estão no
331
- admin, em `tests/api/tracking.test.mjs` (a limpeza, sem browser) e
332
- `tests/tracking.spec.mjs` (o snippet numa página, contra o `dist/`).
655
+ admin, em `tests/api/tracking.test.mjs` (as listas e o pacote, sem browser) e
656
+ `tests/tracking.spec.mjs` (o snippet numa página, contra o `dist/`, com o
657
+ `array.js` da devDependency servido sem rede). O workflow `tracking-sdk.yml`
658
+ corre o mesmo spec uma vez por dia contra o `array.js` que o CDN do PostHog
659
+ serve nesse dia, e abre uma issue quando falha.
333
660
 
334
661
  Uma versão nova: subir a `version` no `package.json` (semver: uma mudança no
335
662
  que os forms ou os eventos levam é minor, uma que parte uma integração é
@@ -348,7 +675,9 @@ dono**: a conta do npm (`funnel10x-org`) tem 2FA, e o `npm publish` pede uma
348
675
  autenticação no browser (*EOTP: This operation requires a one-time
349
676
  password*) mesmo com a sessão iniciada (medido a 2026-10-08, na 1.3.0). Um
350
677
  agente prepara a pasta na `main` e corre o primeiro, e o dono corre o segundo
351
- no terminal dele.
678
+ no terminal dele. A alternativa sem OTP é o *trusted publishing* do npm (a
679
+ publicação pela CI do GitHub, sem token), que é configuração do dono no
680
+ npmjs.com.
352
681
 
353
682
  Depois de publicar, o npm avisa que a versão *may take a few minutes to become
354
683
  available*: espera-se, e só então se confirma. O `npm view` lê de uma cache e