Três armadilhas de encoding em query string que corrompem relatório de campanha
Ano passado gastei quase um dia inteiro atrás de um bug que não era bug.
Era um encarte digital de uma rede de varejo, 200 ofertas, cada uma com o próprio link rastreado por UTM. A plataforma de mídia acusava alguns milhares de cliques. O GA4 mostrava tráfego direto no mesmo volume e nos mesmos horários. Nada de erro no console nem exceção em log.
O problema estava em três lugares diferentes, todos ligados a como query string é codificada e decodificada. Nenhum é exótico. Passam despercebidos porque nenhum deles gera erro.
1. O + literal vira espaço
Em query string, o padrão application/x-www-form-urlencoded define que + representa um espaço. Não é convenção de um servidor específico: é como praticamente todo parser de query string se comporta.
new URLSearchParams('nome=frete+gratis').get('nome')
// → 'frete gratis'
Agora imagine um nome de campanha como Frete Grátis + Cashback. Se ele for concatenado direto na URL, sem passar por encoding:
const campanha = 'Frete Grátis + Cashback';
const url = `https://exemplo.com/?utm_campaign=${campanha}`;
new URL(url).searchParams.get('utm_campaign');
// → 'Frete Grátis Cashback' ← o + sumiu e virou espaço
O + precisa ser codificado como %2B para sobreviver:
const url = `https://exemplo.com/?utm_campaign=${encodeURIComponent(campanha)}`;
// utm_campaign=Frete%20Gr%C3%A1tis%20%2B%20Cashback
new URL(url).searchParams.get('utm_campaign');
// → 'Frete Grátis + Cashback' ✓
Em PHP, o equivalente é rawurlencode() para valor de parâmetro (segue RFC 3986, espaço vira %20) ou urlencode() (espaço vira +, estilo formulário). Os dois funcionam para espaço. O que importa é que ambos escapam o + literal para %2B.
2. encodeURI não serve para valor de parâmetro
Confusão clássica, e a diferença entre as duas funções é pequena o suficiente para passar batido.
encodeURI foi feita para codificar uma URL inteira, então ela preserva os caracteres reservados que dão estrutura à URL: ; / ? : @ & = + $ , #.
encodeURIComponent foi feita para codificar um pedaço que vai dentro da URL, então ela escapa esses caracteres também.
encodeURI('Frete + Cashback & Brinde')
// → 'Frete%20+%20Cashback%20&%20Brinde' + e & intactos
encodeURIComponent('Frete + Cashback & Brinde')
// → 'Frete%20%2B%20Cashback%20%26%20Brinde' + e & escapados
Use encodeURI no valor de um parâmetro e o & do nome da sua campanha vira um separador de parâmetros. A partir dali, o parser lê Brinde como se fosse um novo campo, e o valor real é truncado.
O jeito mais seguro é não montar a string na mão:
const u = new URL('https://exemplo.com/oferta');
u.searchParams.set('utm_source', 'instagram');
u.searchParams.set('utm_campaign', 'Frete + Cashback & Brinde');
u.toString();
// https://exemplo.com/oferta?utm_source=instagram&utm_campaign=Frete+%2B+Cashback+%26+Brinde
Repare que URLSearchParams usa + para espaço e %2B para o + literal, no estilo form-urlencoded, diferente do %20 que o encodeURIComponent produz. As duas formas decodificam para a mesma coisa. O que importa é que o + literal foi escapado.
Quase todo caso desses que eu vi começou com alguém concatenando query string na mão.
3. Encoding duplo
Esse é o mais chato de diagnosticar, porque a URL parece correta à primeira vista.
Acontece quando o valor passa por duas camadas que codificam, tipicamente porque o time monta o link já codificado e depois uma ferramenta de disparo ou um encurtador codifica de novo:
const uma = encodeURIComponent('Verão 2026'); // 'Ver%C3%A3o%202026'
const duas = encodeURIComponent(uma); // 'Ver%25C3%25A3o%25202026'
decodeURIComponent(duas);
// → 'Ver%C3%A3o%202026' ← decodifica uma vez e para
O sinal característico é %25 na URL. %25 é o próprio % codificado, então sempre que você vir isso numa query string, é forte indício de que algo codificou duas vezes. O relatório passa a mostrar Ver%C3%A3o%202026 como se fosse o nome literal da campanha.
Dá para detectar com um regex:
const suspeito = (v) => /%25[0-9A-Fa-f]{2}/.test(v);
O que realmente derrubou o rastreamento
Nenhuma das três, no fim das contas. Elas existiam e sujavam parte dos dados, mas a perda em massa vinha de um redirecionamento.
O link do encarte passava por um encurtador mal configurado, que entregava o usuário no destino sem repassar a query string. Sem parâmetro na URL de chegada, o GA4 não tem como atribuir a origem e classifica a sessão como direta. Daí a assinatura do sintoma: cliques na plataforma de mídia e sessões diretas no mesmo volume, no mesmo horário.
Isso não aparece testando o link original. Só aparece testando o link final, já encurtado, e olhando a URL que efetivamente carrega no navegador.
// no destino, depois do redirect
console.log([...new URL(location.href).searchParams]);
// array vazio = a query string se perdeu no caminho
Um detalhe que não é encoding, mas custa caro
Valor de UTM diferencia maiúscula de minúscula. Instagram e instagram são duas fontes distintas no relatório, e a comparação histórica quebra em silêncio quando duas pessoas escrevem o mesmo canal de jeitos diferentes.
Não existe normalização automática. A saída é disciplina de nomenclatura: dicionário fechado de valores permitidos para utm_source e utm_medium, tudo minúsculo e sem acento, com o valor gerado por ferramenta em vez de digitado.
Checklist de verificação
O que eu passei a fazer antes de qualquer disparo em volume:
- Sortear uns 10 links da lista e abrir cada um em aba anônima, conferindo no relatório em tempo real do GA4 se origem, mídia e campanha chegam como planejado.
- Testar sempre o link final, depois do encurtador, nunca o original.
- Procurar
%25em toda a lista, que é indício de encoding duplo. - Conferir que todos os valores estão em minúsculo e sem acento.
- Garantir que nenhum
utm_contentse repita apontando para destinos diferentes, senão dois produtos colapsam na mesma linha do relatório.
Leva uns dez minutos e evita descobrir o problema quando o relatório do trimestre já está fechado.
Escrevi uma versão mais longa sobre governança de nomenclatura, que é como manter padrão quando várias pessoas geram link, aqui. E o encoder/decoder de URL que uso para inspecionar esses casos roda inteiro no navegador, sem enviar nada para servidor.
Se você já perdeu tempo com alguma variação disso, comenta aí. Tenho curiosidade de saber se o caso do redirect que come a query string é tão comum quanto imagino.