Código e Automação

11 de set. de 2026

Go back

Do GA4 Data API ao Looker Studio: como programamos um pipeline automatizado de Analytics para um curso Latam

Autor: Rafael Lins

Como programamos um pipeline de Analytics com GA4 Data API, Node.js, Apps Script, Sheets, testes, GitHub Actions e Looker Studio para um curso Latam.

World Latam Samsung Code

Fique por dentro do que há de mais relavante no Marketing Digital, assine a nossa newsletter:

Fique por dentro do que há de mais relavante no Marketing Digital, assine a nossa newsletter:

No artigo Como construímos uma arquitetura de dados para acompanhar um curso online latino-americano com GA4, Google Sheets, Apps Script, GitHub e Looker Studio, apresentei a arquitetura desenvolvida para acompanhar o Curso PBL 2026 do Solve for Tomorrow Latam um cliente do portal Porvir.org.

Naquele primeiro conteúdo, o foco foi explicar como organizamos uma operação de mensuração que combina inscrições, distribuição de conteúdos, comportamento no site, consumo dos módulos e conclusão.

Neste segundo artigo, o objetivo é descer um nível e mostrar como parte dessa arquitetura foi implementada em código.

O projeto acompanha uma operação latino-americana, em português e espanhol, organizada em cinco módulos, duas partes por módulo e diferentes tipos de conteúdo.

Isso significa que o pipeline precisa entender não apenas eventos e páginas, mas também calendário, idioma, contexto de distribuição, granularidade, deduplicação, conclusão, disponibilidade dos dados e atualização recorrente.

Não vou publicar credenciais, IDs privados, chaves ou informações sensíveis do projeto. Os trechos abaixo são exemplos simplificados dos padrões utilizados durante a implementação.

O problema que o código precisava resolver

A Fase 2 do projeto precisava responder perguntas em diferentes granularidades.

Quantos usuários acessaram um conteúdo?

Quantos usuários únicos consumiram a Parte 1?

Quantos usuários únicos consumiram um módulo?

Quantos usuários chegaram ao curso?

Quantas sessões foram geradas?

Quanto tempo de engajamento ocorreu?

Qual foi a profundidade de rolagem?

Quantas pessoas interagiram com vídeos?

Quantas responderam ao formulário final?

Qual percentual dos inscritos chegou ao módulo?

Qual percentual dos cursistas concluiu o módulo?

Tentar responder tudo isso diretamente dentro do Looker Studio seria uma decisão ruim de arquitetura.

O dashboard deveria receber dados preparados para cada pergunta.

Por isso, a implementação começou pela definição das entidades e das granularidades.

Configuração antes da consulta

Uma das primeiras decisões foi evitar espalhar URLs, UTMs e regras diretamente pelo código.

Os conteúdos são cadastrados em uma camada de configuração com campos semelhantes a estes:

conteudo_id
modulo_id
modulo_numero
idioma
parte
numero_mensagem
tipo_conteudo
formato
fonte_dados
utm_source
utm_medium
utm_campaign
utm_term
utm_content
utm_id
url_destino
bitlink
ativo
conteudo_id
modulo_id
modulo_numero
idioma
parte
numero_mensagem
tipo_conteudo
formato
fonte_dados
utm_source
utm_medium
utm_campaign
utm_term
utm_content
utm_id
url_destino
bitlink
ativo
conteudo_id
modulo_id
modulo_numero
idioma
parte
numero_mensagem
tipo_conteudo
formato
fonte_dados
utm_source
utm_medium
utm_campaign
utm_term
utm_content
utm_id
url_destino
bitlink
ativo

Um registro conceitual poderia ser:

conteudo_id: m01_pt_msg02
modulo_id: m01_pt
modulo_numero: 1
idioma: pt
parte: Parte 1
numero_mensagem: 2
formato: Texto site
utm_campaign: curso_pbl_3ed_2026
utm_content: m1_msg2
utm_id: leitura_pbl
ativo: true
conteudo_id: m01_pt_msg02
modulo_id: m01_pt
modulo_numero: 1
idioma: pt
parte: Parte 1
numero_mensagem: 2
formato: Texto site
utm_campaign: curso_pbl_3ed_2026
utm_content: m1_msg2
utm_id: leitura_pbl
ativo: true
conteudo_id: m01_pt_msg02
modulo_id: m01_pt
modulo_numero: 1
idioma: pt
parte: Parte 1
numero_mensagem: 2
formato: Texto site
utm_campaign: curso_pbl_3ed_2026
utm_content: m1_msg2
utm_id: leitura_pbl
ativo: true

Com isso, o código não precisa saber antecipadamente que leitura_pbl pertence ao Módulo 1 em português.

Ele lê essa informação da configuração.

Uma seleção simplificada poderia seguir esta lógica:

function selecionarConteudosGA4(registros) {
  return registros.filter((item) => {
    return (
      item.ativo === true &&
      item.fonte_dados === "GA4" &&
      item.url_destino &&
      item.modulo_id
    );
  });
}
function selecionarConteudosGA4(registros) {
  return registros.filter((item) => {
    return (
      item.ativo === true &&
      item.fonte_dados === "GA4" &&
      item.url_destino &&
      item.modulo_id
    );
  });
}
function selecionarConteudosGA4(registros) {
  return registros.filter((item) => {
    return (
      item.ativo === true &&
      item.fonte_dados === "GA4" &&
      item.url_destino &&
      item.modulo_id
    );
  });
}

Em produção existem validações adicionais, mas o princípio é esse.

A aplicação coleta aquilo que a configuração define como parte da operação.

A configuração funciona como metadata do pipeline

Na prática, essa tabela passou a funcionar como metadata.

Ela descreve o que deve ser monitorado.

O pipeline decide como consultar e consolidar os dados a partir dessa descrição.

Essa separação melhora manutenção, testes e auditabilidade.

Se um novo conteúdo entra no Módulo 3, por exemplo, a mudança pode acontecer na configuração sem exigir uma sequência de condições hardcoded espalhadas por várias funções.

O problema central: usuários não são aditivos

Este é um dos pontos mais importantes de toda a implementação.

Imagine três conteúdos:

Conteúdo A: 100 usuários
Conteúdo B: 80 usuários
Conteúdo C: 60 usuários
Conteúdo A: 100 usuários
Conteúdo B: 80 usuários
Conteúdo C: 60 usuários
Conteúdo A: 100 usuários
Conteúdo B: 80 usuários
Conteúdo C: 60 usuários

É tentador escrever:

const usuariosModulo =
  usuariosConteudoA +
  usuariosConteudoB +
  usuariosConteudoC;
const usuariosModulo =
  usuariosConteudoA +
  usuariosConteudoB +
  usuariosConteudoC;
const usuariosModulo =
  usuariosConteudoA +
  usuariosConteudoB +
  usuariosConteudoC;

Isso estaria errado.

A mesma pessoa pode ter acessado os três conteúdos.

A soma poderia resultar em 240, enquanto o módulo poderia ter apenas 150 usuários únicos.

Esse tipo de erro é especialmente perigoso porque o número final parece plausível.

O dashboard não quebra.

A consulta não retorna erro.

Ela apenas exibe um resultado metodologicamente incorreto.

Usuários únicos precisam ser calculados no nível correto

Para calcular usuários de um módulo, agrupamos as URLs oficiais daquele módulo e executamos uma consulta consolidada no GA4.

Conceitualmente:

const paginasModulo = conteudos
  .filter((item) => item.modulo_id === moduloId)
  .map((item) => item.url_destino);
const paginasModulo = conteudos
  .filter((item) => item.modulo_id === moduloId)
  .map((item) => item.url_destino);
const paginasModulo = conteudos
  .filter((item) => item.modulo_id === moduloId)
  .map((item) => item.url_destino);

Depois, construímos um filtro contendo o conjunto dessas páginas.

Um exemplo simplificado:

const filtroPaginas = {
  orGroup: {
    expressions: paginasModulo.map((pagina) => ({
      filter: {
        fieldName: "pagePath",
        stringFilter: {
          matchType: "EXACT",
          value: extrairPagePath(pagina)
        }
      }
    }))
  }
};
const filtroPaginas = {
  orGroup: {
    expressions: paginasModulo.map((pagina) => ({
      filter: {
        fieldName: "pagePath",
        stringFilter: {
          matchType: "EXACT",
          value: extrairPagePath(pagina)
        }
      }
    }))
  }
};
const filtroPaginas = {
  orGroup: {
    expressions: paginasModulo.map((pagina) => ({
      filter: {
        fieldName: "pagePath",
        stringFilter: {
          matchType: "EXACT",
          value: extrairPagePath(pagina)
        }
      }
    }))
  }
};

A consulta solicita totalUsers sobre o conjunto completo.

O princípio correto é:

GA4(
  página A
  OR página B
  OR página C
)
=> totalUsers
GA4(
  página A
  OR página B
  OR página C
)
=> totalUsers
GA4(
  página A
  OR página B
  OR página C
)
=> totalUsers

e não:

totalUsers(A)
+
totalUsers(B)
+
totalUsers(C)
totalUsers(A)
+
totalUsers(B)
+
totalUsers(C)
totalUsers(A)
+
totalUsers(B)
+
totalUsers(C)

Essa diferença é fundamental para a qualidade do relatório.

O mesmo princípio foi aplicado às partes

Cada módulo possui Parte 1 e Parte 2.

A chave lógica desse nível pode ser representada por:

data + modulo_id + parte
data + modulo_id + parte
data + modulo_id + parte

A seleção dos conteúdos pode seguir uma estrutura semelhante a:

const conteudosParte = conteudos.filter((item) => {
  return (
    item.modulo_id === moduloId &&
    item.parte === parte &&
    item.ativo === true
  );
});
const conteudosParte = conteudos.filter((item) => {
  return (
    item.modulo_id === moduloId &&
    item.parte === parte &&
    item.ativo === true
  );
});
const conteudosParte = conteudos.filter((item) => {
  return (
    item.modulo_id === moduloId &&
    item.parte === parte &&
    item.ativo === true
  );
});

Também aqui os usuários precisam vir de uma consulta consolidada.

Não fazemos:

const usuariosParte =
  conteudosParte.reduce(
    (total, conteudo) => total + conteudo.total_users,
    0
  );
const usuariosParte =
  conteudosParte.reduce(
    (total, conteudo) => total + conteudo.total_users,
    0
  );
const usuariosParte =
  conteudosParte.reduce(
    (total, conteudo) => total + conteudo.total_users,
    0
  );

Esse código seria matematicamente válido e analiticamente incorreto.

Granularidades diferentes, tabelas diferentes

Em vez de tentar manter uma tabela universal, o projeto separou as camadas analíticas.

A estrutura principal ficou próxima de:

ga4_conteudos
ga4_partes
ga4_modulos
ga4_curso
ga4_conteudos
ga4_partes
ga4_modulos
ga4_curso
ga4_conteudos
ga4_partes
ga4_modulos
ga4_curso

ga4_conteudos responde perguntas sobre peças individuais.

ga4_partes responde perguntas sobre Parte 1 e Parte 2.

ga4_modulos responde perguntas consolidadas sobre cada módulo.

ga4_curso responde perguntas sobre a experiência mais ampla.

Essa separação reduz a quantidade de engenharia que precisa acontecer dentro do Looker Studio.

Um schema de conteúdo pode ficar bastante detalhado

A camada de conteúdos possui campos para diferentes tipos de comportamento.

Uma versão simplificada:

const registro = {
  data,
  conteudo_id,
  modulo_id,
  modulo_numero,
  idioma,
  parte,
  numero_mensagem,
  formato,
  utm_content,
  utm_id,
  url_destino,

  total_users,
  sessions,
  screen_page_views,

  user_engagement_duration,
  tempo_medio_engajado_usuario,
  tempo_medio_engajado_sessao,

  scroll_25_users,
  scroll_50_users,
  scroll_75_users,
  scroll_90_users,

  scroll_25_rate,
  scroll_50_rate,
  scroll_75_rate,
  scroll_90_rate,

  video_interaction_users,

  downloads,
  video_detalhado_status,
  downloads_status,

  coletado_em
};
const registro = {
  data,
  conteudo_id,
  modulo_id,
  modulo_numero,
  idioma,
  parte,
  numero_mensagem,
  formato,
  utm_content,
  utm_id,
  url_destino,

  total_users,
  sessions,
  screen_page_views,

  user_engagement_duration,
  tempo_medio_engajado_usuario,
  tempo_medio_engajado_sessao,

  scroll_25_users,
  scroll_50_users,
  scroll_75_users,
  scroll_90_users,

  scroll_25_rate,
  scroll_50_rate,
  scroll_75_rate,
  scroll_90_rate,

  video_interaction_users,

  downloads,
  video_detalhado_status,
  downloads_status,

  coletado_em
};
const registro = {
  data,
  conteudo_id,
  modulo_id,
  modulo_numero,
  idioma,
  parte,
  numero_mensagem,
  formato,
  utm_content,
  utm_id,
  url_destino,

  total_users,
  sessions,
  screen_page_views,

  user_engagement_duration,
  tempo_medio_engajado_usuario,
  tempo_medio_engajado_sessao,

  scroll_25_users,
  scroll_50_users,
  scroll_75_users,
  scroll_90_users,

  scroll_25_rate,
  scroll_50_rate,
  scroll_75_rate,
  scroll_90_rate,

  video_interaction_users,

  downloads,
  video_detalhado_status,
  downloads_status,

  coletado_em
};

Nem todos os campos se aplicam a todos os conteúdos.

Um PDF não deve ser tratado como um artigo.

Um vídeo não deve ser tratado como um PDF.

E ausência de uma métrica não deve virar zero automaticamente.

Tempo médio exige semântica precisa

O GA4 disponibiliza diferentes métricas relacionadas a tempo.

Para algumas análises de consumo, utilizamos userEngagementDuration como base.

O cálculo pode ser representado assim:

function calcularTempoMedioEngajadoUsuario(
  userEngagementDuration,
  totalUsers
) {
  if (!totalUsers || totalUsers <= 0) {
    return null;
  }

  return userEngagementDuration / totalUsers;
}
function calcularTempoMedioEngajadoUsuario(
  userEngagementDuration,
  totalUsers
) {
  if (!totalUsers || totalUsers <= 0) {
    return null;
  }

  return userEngagementDuration / totalUsers;
}
function calcularTempoMedioEngajadoUsuario(
  userEngagementDuration,
  totalUsers
) {
  if (!totalUsers || totalUsers <= 0) {
    return null;
  }

  return userEngagementDuration / totalUsers;
}

O nome da métrica importa.

Não é simplesmente "tempo médio".

É:

tempo médio engajado por usuário
tempo médio engajado por usuário
tempo médio engajado por usuário

Esse tipo de precisão semântica reduz interpretações incorretas dentro do dashboard.

Zero e null representam coisas diferentes

Uma regra importante do pipeline é preservar a diferença entre ausência de evento e ausência de mensuração confiável.

Considere:

const downloads = null;
const downloads = null;
const downloads = null;

Isso pode significar:

Não existe atribuição confiável para determinar os downloads
Não existe atribuição confiável para determinar os downloads
Não existe atribuição confiável para determinar os downloads

Enquanto:

const downloads = 0;
const downloads = 0;
const downloads = 0;

significa:

A mensuração estava disponível e nenhum download foi registrado
A mensuração estava disponível e nenhum download foi registrado
A mensuração estava disponível e nenhum download foi registrado

São estados diferentes.

Por isso, também utilizamos campos de status.

Exemplo:

{
  downloads: null,
  downloads_status: "sem_atribuicao_confiavel"
}
{
  downloads: null,
  downloads_status: "sem_atribuicao_confiavel"
}
{
  downloads: null,
  downloads_status: "sem_atribuicao_confiavel"
}

Essa informação impede que o Looker Studio transforme ausência de mensuração em um falso desempenho zero.

Vídeo também exigiu tratamento prospectivo

A instrumentação de vídeo evoluiu para trabalhar com parâmetros como:

video_action
video_title
video_percent
video_current_time
video_duration
video_url
video_provider
video_action
video_title
video_percent
video_current_time
video_duration
video_url
video_provider
video_action
video_title
video_percent
video_current_time
video_duration
video_url
video_provider

Mas dados detalhados adicionados depois não existem retroativamente de maneira confiável.

A estrutura precisa preservar essa diferença.

Exemplo:

{
  video_interaction_users: 27,
  video_play_users: null,
  video_progress_25_users: null,
  video_progress_50_users: null,
  video_progress_75_users: null,
  video_complete_users: null,
  video_detalhado_status: "aguardando_validacao"
}
{
  video_interaction_users: 27,
  video_play_users: null,
  video_progress_25_users: null,
  video_progress_50_users: null,
  video_progress_75_users: null,
  video_complete_users: null,
  video_detalhado_status: "aguardando_validacao"
}
{
  video_interaction_users: 27,
  video_play_users: null,
  video_progress_25_users: null,
  video_progress_50_users: null,
  video_progress_75_users: null,
  video_complete_users: null,
  video_detalhado_status: "aguardando_validacao"
}

Não seria correto escrever:

video_complete_users = 0;
video_complete_users = 0;
video_complete_users = 0;

se a métrica ainda não estivesse disponível ou homologada.

A atribuição do GA4 exigiu filtros adicionais

Durante os testes apareceu outro comportamento importante.

A atribuição de sessão pode permanecer durante a navegação.

Imagine que uma pessoa entre por:

utm_campaign=curso_pbl_3ed_2026
utm_content=m1_msg2
utm_campaign=curso_pbl_3ed_2026
utm_content=m1_msg2
utm_campaign=curso_pbl_3ed_2026
utm_content=m1_msg2

Depois ela navega para outras páginas.

Dependendo da dimensão consultada, aquela atribuição pode continuar associada à sessão.

Filtrar somente pela UTM poderia incluir páginas que não pertencem realmente ao conteúdo monitorado.

Por isso, a consulta combina campanha, idioma e conjunto oficial de URLs.

Conceitualmente:

const filtro = AND(
  campanhaEsperada,
  idiomaEsperado,
  OR(...paginasOficiais)
);
const filtro = AND(
  campanhaEsperada,
  idiomaEsperado,
  OR(...paginasOficiais)
);
const filtro = AND(
  campanhaEsperada,
  idiomaEsperado,
  OR(...paginasOficiais)
);

Isso é muito mais seguro do que depender somente da campanha:

const filtro = campanhaEsperada;
const filtro = campanhaEsperada;
const filtro = campanhaEsperada;

O calendário também virou dado

Outro elemento que saiu do código e virou configuração foi o calendário operacional.

O sistema precisa saber quando cada mensagem, parte e módulo realmente entram em operação.

Uma estrutura simplificada:

modulo_id
idioma
parte
numero_mensagem
utm_content
data_disparo
tem_conteudo_monitorado
observacao
modulo_id
idioma
parte
numero_mensagem
utm_content
data_disparo
tem_conteudo_monitorado
observacao
modulo_id
idioma
parte
numero_mensagem
utm_content
data_disparo
tem_conteudo_monitorado
observacao

Isso permite registrar exceções.

Se uma mensagem em português precisa mudar de data por causa de um feriado enquanto a versão em espanhol permanece no calendário original, essa diferença pode ser registrada na configuração.

O pipeline não precisa receber uma condição improvisada apenas para aquela data.

Escopo operacional calculado automaticamente

A automação diária não foi construída para receber manualmente:

--module-number 1
--module-number 1
--module-number 1

e depois exigir alterações futuras para:

--module-number 2
--module-number 2
--module-number 2

O runner diário precisa descobrir sozinho o que está operacional.

Ele lê as configurações e compara com a data analítica de corte.

Conceitualmente:

function moduloEstaElegivel(modulo, endDate) {
  return (
    modulo.ativo &&
    modulo.data_liberacao <= endDate
  );
}
function moduloEstaElegivel(modulo, endDate) {
  return (
    modulo.ativo &&
    modulo.data_liberacao <= endDate
  );
}
function moduloEstaElegivel(modulo, endDate) {
  return (
    modulo.ativo &&
    modulo.data_liberacao <= endDate
  );
}

Depois:

const modulosElegiveis = configModulos.filter(
  (modulo) => moduloEstaElegivel(modulo, endDate)
);
const modulosElegiveis = configModulos.filter(
  (modulo) => moduloEstaElegivel(modulo, endDate)
);
const modulosElegiveis = configModulos.filter(
  (modulo) => moduloEstaElegivel(modulo, endDate)
);

A lógica real também considera calendário e conteúdos, mas o princípio é importante.

M1 entra quando sua janela começa.

M2 entra posteriormente.

Depois M3, M4 e M5.

O workflow não precisa ser editado para cada módulo.

Não criamos módulos futuros artificialmente

Suponha que o M2 ainda não tenha começado.

Poderíamos produzir:

{
  modulo_id: "m02_pt",
  usuarios_modulo: 0
}
{
  modulo_id: "m02_pt",
  usuarios_modulo: 0
}
{
  modulo_id: "m02_pt",
  usuarios_modulo: 0
}

Mas isso comunicaria algo incorreto.

Zero usuários significa que o módulo estava disponível, foi medido e ninguém o consumiu.

Antes da abertura do módulo, o estado correto é:

ainda não existe observação analítica válida
ainda não existe observação analítica válida
ainda não existe observação analítica válida

Por isso, módulos futuros são omitidos até sua janela operacional.

O Looker Studio recebe ausência de linha, não um zero fabricado.

O denominador também não pode ser hardcoded

A Fase 2 calcula taxas em relação aos inscritos da Fase 1.

A implementação mais rápida seria escrever:

const inscritosPT = 1299;
const inscritosES = 1663;
const inscritosPT = 1299;
const inscritosES = 1663;
const inscritosPT = 1299;
const inscritosES = 1663;

Isso seria um problema de governança.

Os números podem mudar.

A Fase 1 já possui a fonte oficial.

Por isso, o pipeline reutiliza essa base.

Conceitualmente:

const inscritos = await carregarInscritosFase1();
const inscritos = await carregarInscritosFase1();
const inscritos = await carregarInscritosFase1();

Depois:

function calcularTaxaConsumo(
  usuariosModulo,
  inscritosFase1
) {
  if (!inscritosFase1 || inscritosFase1 <= 0) {
    return null;
  }

  return usuariosModulo / inscritosFase1;
}
function calcularTaxaConsumo(
  usuariosModulo,
  inscritosFase1
) {
  if (!inscritosFase1 || inscritosFase1 <= 0) {
    return null;
  }

  return usuariosModulo / inscritosFase1;
}
function calcularTaxaConsumo(
  usuariosModulo,
  inscritosFase1
) {
  if (!inscritosFase1 || inscritosFase1 <= 0) {
    return null;
  }

  return usuariosModulo / inscritosFase1;
}

Nenhum denominador operacional precisa ser digitado novamente no dashboard.

O mesmo vale para conclusão

As respostas dos formulários são consolidadas por módulo e idioma.

A partir delas podemos calcular:

const taxaConclusaoInscritos =
  respostas / inscritosFase1;
const taxaConclusaoInscritos =
  respostas / inscritosFase1;
const taxaConclusaoInscritos =
  respostas / inscritosFase1;

e:

const taxaConclusaoCursistas =
  respostas / usuariosModulo;
const taxaConclusaoCursistas =
  respostas / usuariosModulo;
const taxaConclusaoCursistas =
  respostas / usuariosModulo;

Com proteção:

function dividirSeguro(numerador, denominador) {
  if (
    denominador === null ||
    denominador === undefined ||
    denominador <= 0
  ) {
    return null;
  }

  return numerador / denominador;
}
function dividirSeguro(numerador, denominador) {
  if (
    denominador === null ||
    denominador === undefined ||
    denominador <= 0
  ) {
    return null;
  }

  return numerador / denominador;
}
function dividirSeguro(numerador, denominador) {
  if (
    denominador === null ||
    denominador === undefined ||
    denominador <= 0
  ) {
    return null;
  }

  return numerador / denominador;
}

Isso evita Infinity, NaN e taxas sem significado metodológico.

Bitly também precisou de deduplicação

O Bitly foi utilizado para acompanhar links distribuídos durante a jornada.

Um mesmo Bitlink pode aparecer em mais de um registro de configuração.

Fazer uma chamada para cada linha poderia duplicar a coleta.

Por isso, os registros são agrupados por Bitlink antes da consulta.

Conceitualmente:

function agruparPorBitlink(conteudos) {
  const grupos = new Map();

  for (const conteudo of conteudos) {
    const chave = normalizarBitlink(conteudo.bitlink);

    if (!grupos.has(chave)) {
      grupos.set(chave, []);
    }

    grupos.get(chave).push(conteudo);
  }

  return grupos;
}
function agruparPorBitlink(conteudos) {
  const grupos = new Map();

  for (const conteudo of conteudos) {
    const chave = normalizarBitlink(conteudo.bitlink);

    if (!grupos.has(chave)) {
      grupos.set(chave, []);
    }

    grupos.get(chave).push(conteudo);
  }

  return grupos;
}
function agruparPorBitlink(conteudos) {
  const grupos = new Map();

  for (const conteudo of conteudos) {
    const chave = normalizarBitlink(conteudo.bitlink);

    if (!grupos.has(chave)) {
      grupos.set(chave, []);
    }

    grupos.get(chave).push(conteudo);
  }

  return grupos;
}

Isso permite consultar cada Bitlink uma única vez.

Links compartilhados entre idiomas

Em alguns casos o mesmo Bitlink atende conteúdos em português e espanhol.

Sem uma informação adicional no próprio link, não existe forma confiável de dividir os cliques por idioma.

A solução não foi estimar.

Foi representar explicitamente a limitação.

Um registro simplificado:

{
  idioma: "",
  escopo_idioma: "shared",
  bitlink: "bit.ly/exemplo"
}
{
  idioma: "",
  escopo_idioma: "shared",
  bitlink: "bit.ly/exemplo"
}
{
  idioma: "",
  escopo_idioma: "shared",
  bitlink: "bit.ly/exemplo"
}

Essa decisão evita atribuir artificialmente metade dos cliques a PT e metade a ES ou reproduzir o mesmo total duas vezes.

Idempotência no Google Sheets

Um pipeline diário não pode simplesmente executar:

sheet.appendRow(row);
sheet.appendRow(row);
sheet.appendRow(row);

em todas as execuções.

Se a mesma janela analítica for processada novamente, os registros seriam duplicados.

Por isso, cada tabela possui uma chave lógica.

Para conteúdos:

data + conteudo_id
data + conteudo_id
data + conteudo_id

Para módulos:

data + modulo_id
data + modulo_id
data + modulo_id

Para partes:

data + modulo_id + parte
data + modulo_id + parte
data + modulo_id + parte

Para o curso:

data + idioma
data + idioma
data + idioma

Uma implementação simplificada:

function gerarChave(row) {
  return [
    row.data,
    row.modulo_id,
    row.parte
  ].join("|");
}
function gerarChave(row) {
  return [
    row.data,
    row.modulo_id,
    row.parte
  ].join("|");
}
function gerarChave(row) {
  return [
    row.data,
    row.modulo_id,
    row.parte
  ].join("|");
}

Depois:

const existentes = new Map(
  linhasAtuais.map((row) => [
    gerarChave(row),
    row
  ])
);

for (const novaLinha of novasLinhas) {
  existentes.set(
    gerarChave(novaLinha),
    novaLinha
  );
}
const existentes = new Map(
  linhasAtuais.map((row) => [
    gerarChave(row),
    row
  ])
);

for (const novaLinha of novasLinhas) {
  existentes.set(
    gerarChave(novaLinha),
    novaLinha
  );
}
const existentes = new Map(
  linhasAtuais.map((row) => [
    gerarChave(row),
    row
  ])
);

for (const novaLinha of novasLinhas) {
  existentes.set(
    gerarChave(novaLinha),
    novaLinha
  );
}

A mesma janela pode ser processada novamente sem criar uma segunda cópia arbitrária.

Isso é especialmente importante por causa do processamento tardio do GA4

Dados recentes do Google Analytics 4 podem mudar após a primeira coleta.

Por isso, o sistema mantém dois conceitos diferentes:

data
data
data

e:

coletado_em
coletado_em
coletado_em

Um registro poderia ser:

{
  data: "2026-09-08",
  usuarios_modulo: 164,
  coletado_em: "2026-09-09T17:41:49.686Z"
}
{
  data: "2026-09-08",
  usuarios_modulo: 164,
  coletado_em: "2026-09-09T17:41:49.686Z"
}
{
  data: "2026-09-08",
  usuarios_modulo: 164,
  coletado_em: "2026-09-09T17:41:49.686Z"
}

data representa o período analítico.

coletado_em representa quando aquela fotografia foi produzida.

Se a mesma data for consultada novamente depois do processamento do GA4, os valores podem mudar.

Histórico e estado atual são coisas diferentes

Essa necessidade criou outro problema.

As tabelas históricas precisam preservar diferentes snapshots.

Mas o Looker Studio não pode somar esses snapshots como se fossem registros independentes.

Se ga4_modulos possuir:

03/09 | m01_pt | 112 usuários
08/09 | m01_pt | 164 usuários
03/09 | m01_pt | 112 usuários
08/09 | m01_pt | 164 usuários
03/09 | m01_pt | 112 usuários
08/09 | m01_pt | 164 usuários

o dashboard não pode apresentar:

276 usuários
276 usuários
276 usuários

Isso seria soma de snapshots históricos.

Por isso, criamos uma segunda camada específica para visualização corrente:

looker_ga4_conteudos_atual
looker_ga4_modulos_atual
looker_ga4_partes_atual
looker_ga4_curso_atual
looker_ga4_conteudos_atual
looker_ga4_modulos_atual
looker_ga4_partes_atual
looker_ga4_curso_atual
looker_ga4_conteudos_atual
looker_ga4_modulos_atual
looker_ga4_partes_atual
looker_ga4_curso_atual

Selecionando o último snapshot por chave

A regra não é simplesmente:

PEGAR A MAIOR DATA DA TABELA
PEGAR A MAIOR DATA DA TABELA
PEGAR A MAIOR DATA DA TABELA

Isso poderia remover entidades que não foram atualizadas na mesma data.

O correto é encontrar o último snapshot por chave lógica.

Uma versão conceitual:

function selecionarUltimoSnapshot(rows, gerarChave) {
  const latest = new Map();

  for (const row of rows) {
    const key = gerarChave(row);
    const atual = latest.get(key);

    if (!atual || row.data > atual.data) {
      latest.set(key, row);
    }
  }

  return Array.from(latest.values());
}
function selecionarUltimoSnapshot(rows, gerarChave) {
  const latest = new Map();

  for (const row of rows) {
    const key = gerarChave(row);
    const atual = latest.get(key);

    if (!atual || row.data > atual.data) {
      latest.set(key, row);
    }
  }

  return Array.from(latest.values());
}
function selecionarUltimoSnapshot(rows, gerarChave) {
  const latest = new Map();

  for (const row of rows) {
    const key = gerarChave(row);
    const atual = latest.get(key);

    if (!atual || row.data > atual.data) {
      latest.set(key, row);
    }
  }

  return Array.from(latest.values());
}

Para conteúdos, a chave pode ser:

(row) => row.conteudo_id
(row) => row.conteudo_id
(row) => row.conteudo_id

Para módulos:

(row) => row.modulo_id
(row) => row.modulo_id
(row) => row.modulo_id

Para partes:

(row) => `${row.modulo_id}|${row.parte}`
(row) => `${row.modulo_id}|${row.parte}`
(row) => `${row.modulo_id}|${row.parte}`

Para curso:

(row) => row.idioma
(row) => row.idioma
(row) => row.idioma

Assim, o histórico continua preservado e o Looker recebe somente o estado atual de cada entidade.

Um problema de serialização apareceu entre Sheets e Looker

Durante a construção dessa camada surgiu um bug interessante.

A leitura do Google Sheets estava utilizando valores formatados pela localidade da planilha.

Um número como:

0.1262509623
0.1262509623
0.1262509623

podia ser lido como:

"0,1262509623"
"0,1262509623"
"0,1262509623"

Depois, quando esse valor era gravado novamente usando RAW, o Sheets armazenava uma string.

O Looker reconhecia o campo no schema, mas não conseguia utilizá-lo corretamente como porcentagem ou duração.

A solução foi trabalhar com valores não formatados na leitura da API.

Conceitualmente:

const response = await sheets.spreadsheets.values.get({
  spreadsheetId,
  range,
  valueRenderOption: "UNFORMATTED_VALUE"
});
const response = await sheets.spreadsheets.values.get({
  spreadsheetId,
  range,
  valueRenderOption: "UNFORMATTED_VALUE"
});
const response = await sheets.spreadsheets.values.get({
  spreadsheetId,
  range,
  valueRenderOption: "UNFORMATTED_VALUE"
});

Assim:

0.1262509623
0.1262509623
0.1262509623

continua sendo número.

Não vira:

"0,1262509623"
"0,1262509623"
"0,1262509623"

Esse problema mostrou como localização e serialização podem afetar pipelines analíticos de maneira silenciosa.

Tipos também são parte do contrato de dados

Não basta preservar valores.

Precisamos preservar tipos.

Campos como:

usuarios_modulo
sessions
screen_page_views
taxa_consumo_inscritos
user_engagement_duration
usuarios_modulo
sessions
screen_page_views
taxa_consumo_inscritos
user_engagement_duration
usuarios_modulo
sessions
screen_page_views
taxa_consumo_inscritos
user_engagement_duration

devem chegar ao Looker como valores numéricos reais.

Campos como:

idioma
modulo_id
parte
formato
url_destino
idioma
modulo_id
parte
formato
url_destino
idioma
modulo_id
parte
formato
url_destino

devem continuar strings.

E valores nulos legítimos devem permanecer vazios.

O pipeline não deve converter indiscriminadamente qualquer string que "pareça número".

A conversão precisa conhecer o schema.

Até uma coluna AA virou caso de teste

Durante a implementação apareceu um bug simples e representativo.

A tabela técnica cresceu para além da coluna Z.

Uma função que calculava intervalos A1 precisava entender:

1 = A
26 = Z
27 = AA
28 = AB
38 = AL
1 = A
26 = Z
27 = AA
28 = AB
38 = AL
1 = A
26 = Z
27 = AA
28 = AB
38 = AL

Uma implementação possível:

function columnNumberToLetter(column) {
  let result = "";
  let value = column;

  while (value > 0) {
    const remainder = (value - 1) % 26;

    result =
      String.fromCharCode(65 + remainder) +
      result;

    value =
      Math.floor((value - 1) / 26);
  }

  return result;
}
function columnNumberToLetter(column) {
  let result = "";
  let value = column;

  while (value > 0) {
    const remainder = (value - 1) % 26;

    result =
      String.fromCharCode(65 + remainder) +
      result;

    value =
      Math.floor((value - 1) / 26);
  }

  return result;
}
function columnNumberToLetter(column) {
  let result = "";
  let value = column;

  while (value > 0) {
    const remainder = (value - 1) % 26;

    result =
      String.fromCharCode(65 + remainder) +
      result;

    value =
      Math.floor((value - 1) / 26);
  }

  return result;
}

E os testes:

assert.equal(columnNumberToLetter(1), "A");
assert.equal(columnNumberToLetter(26), "Z");
assert.equal(columnNumberToLetter(27), "AA");
assert.equal(columnNumberToLetter(38), "AL");
assert.equal(columnNumberToLetter(1), "A");
assert.equal(columnNumberToLetter(26), "Z");
assert.equal(columnNumberToLetter(27), "AA");
assert.equal(columnNumberToLetter(38), "AL");
assert.equal(columnNumberToLetter(1), "A");
assert.equal(columnNumberToLetter(26), "Z");
assert.equal(columnNumberToLetter(27), "AA");
assert.equal(columnNumberToLetter(38), "AL");

É um ótimo exemplo de por que testar integrações aparentemente simples.

TDD aplicado a um projeto de Analytics

O projeto passou a utilizar Test Driven Development em alterações críticas.

O fluxo é:

1. escrever um teste que reproduza o problema
2. executar e confirmar a falha
3. implementar a correção
4. executar novamente
5. rodar a suíte completa
6. verificar o diff
7. somente então publicar
1. escrever um teste que reproduza o problema
2. executar e confirmar a falha
3. implementar a correção
4. executar novamente
5. rodar a suíte completa
6. verificar o diff
7. somente então publicar
1. escrever um teste que reproduza o problema
2. executar e confirmar a falha
3. implementar a correção
4. executar novamente
5. rodar a suíte completa
6. verificar o diff
7. somente então publicar

Um teste simplificado poderia ser:

test("não soma usuários de conteúdos para obter usuários do módulo", () => {
  const resultado = calcularUsuariosModulo({
    usuariosConsultaConsolidada: 150,
    usuariosConteudos: [100, 80, 60]
  });

  assert.equal(resultado, 150);
});
test("não soma usuários de conteúdos para obter usuários do módulo", () => {
  const resultado = calcularUsuariosModulo({
    usuariosConsultaConsolidada: 150,
    usuariosConteudos: [100, 80, 60]
  });

  assert.equal(resultado, 150);
});
test("não soma usuários de conteúdos para obter usuários do módulo", () => {
  const resultado = calcularUsuariosModulo({
    usuariosConsultaConsolidada: 150,
    usuariosConteudos: [100, 80, 60]
  });

  assert.equal(resultado, 150);
});

A intenção é transformar uma regra metodológica em comportamento verificável.

Regras de Analytics podem virar testes

Esse é um ponto especialmente interessante.

Podemos testar não apenas código, mas decisões analíticas.

Exemplos:

Módulo futuro não deve gerar linha artificial
Módulo futuro não deve gerar linha artificial
Módulo futuro não deve gerar linha artificial
Usuários de módulo não podem ser derivados da soma dos conteúdos
Usuários de módulo não podem ser derivados da soma dos conteúdos
Usuários de módulo não podem ser derivados da soma dos conteúdos
Denominador ausente não deve gerar taxa
Denominador ausente não deve gerar taxa
Denominador ausente não deve gerar taxa
Métrica indisponível não deve virar zero
Métrica indisponível não deve virar zero
Métrica indisponível não deve virar zero
Bitlink compartilhado não deve receber idioma inventado
Bitlink compartilhado não deve receber idioma inventado
Bitlink compartilhado não deve receber idioma inventado
Reexecução não deve duplicar uma chave lógica
Reexecução não deve duplicar uma chave lógica
Reexecução não deve duplicar uma chave lógica

Quando essas regras estão apenas em uma documentação, alguém pode esquecê-las.

Quando também estão nos testes, quebrar uma regra pode quebrar o build.

GitHub como fonte de verdade técnica

A implementação passou a ser versionada em um repositório GitHub próprio.

Isso separa duas responsabilidades.

Google Sheets permanece como camada operacional de dados.

GitHub funciona como fonte de verdade para:

código
testes
schemas
documentação
arquitetura
decisões técnicas
código
testes
schemas
documentação
arquitetura
decisões técnicas
código
testes
schemas
documentação
arquitetura
decisões técnicas

Essa separação é importante porque um projeto de Analytics que depende de código também precisa preservar seu histórico de implementação.

Secrets não pertencem ao Git

Credenciais não são versionadas.

Arquivos locais sensíveis ficam fora do repositório.

Por exemplo:

.secrets/
.clasp.json
.clasprc.json
.env
.secrets/
.clasp.json
.clasprc.json
.env
.secrets/
.clasp.json
.clasprc.json
.env

No GitHub Actions, a service account é armazenada como Repository Secret.

O workflow pode materializar temporariamente essa credencial durante a execução:

- name: Write Google service account credentials
  env:
    GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}
  run: |
    printf '%s' "$GOOGLE_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/google-service-account.json"
- name: Write Google service account credentials
  env:
    GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}
  run: |
    printf '%s' "$GOOGLE_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/google-service-account.json"
- name: Write Google service account credentials
  env:
    GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}
  run: |
    printf '%s' "$GOOGLE_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/google-service-account.json"

O arquivo existe no ambiente temporário do runner e não precisa fazer parte do repositório.

Variables e Secrets possuem funções diferentes

Também separamos configuração operacional de credenciais.

Exemplos de Repository Variables:

GA4_PROPERTY_ID
GA4_START_DATE
FASE2_TECHNICAL_SPREADSHEET_ID
FASE1_SPREADSHEET_ID
FASE1_STATUS_SHEET_NAME
GA4_PROPERTY_ID
GA4_START_DATE
FASE2_TECHNICAL_SPREADSHEET_ID
FASE1_SPREADSHEET_ID
FASE1_STATUS_SHEET_NAME
GA4_PROPERTY_ID
GA4_START_DATE
FASE2_TECHNICAL_SPREADSHEET_ID
FASE1_SPREADSHEET_ID
FASE1_STATUS_SHEET_NAME

Exemplo de Repository Secret:

GOOGLE_SERVICE_ACCOUNT_JSON
GOOGLE_SERVICE_ACCOUNT_JSON
GOOGLE_SERVICE_ACCOUNT_JSON

O ID de uma propriedade GA4 é configuração.

Uma chave privada é credencial.

Separar os dois tipos reduz complexidade e risco.

GitHub Actions como executor diário

Depois da homologação local, criamos um workflow para executar a atualização técnica automaticamente.

Uma estrutura simplificada:

name: Fase 2 Daily Technical Update

on:
  workflow_dispatch:
  schedule:
    - cron: "0 15 * * *"

permissions:
  contents: read

jobs:
  update-fase2:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Run tests
        run: npm test

      - name: Write Google service account credentials
        env:
          GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}
        run: |
          printf '%s' "$GOOGLE_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/google-service-account.json"

      - name: Update Phase 2 technical sheets
        run

name: Fase 2 Daily Technical Update

on:
  workflow_dispatch:
  schedule:
    - cron: "0 15 * * *"

permissions:
  contents: read

jobs:
  update-fase2:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Run tests
        run: npm test

      - name: Write Google service account credentials
        env:
          GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}
        run: |
          printf '%s' "$GOOGLE_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/google-service-account.json"

      - name: Update Phase 2 technical sheets
        run

name: Fase 2 Daily Technical Update

on:
  workflow_dispatch:
  schedule:
    - cron: "0 15 * * *"

permissions:
  contents: read

jobs:
  update-fase2:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Run tests
        run: npm test

      - name: Write Google service account credentials
        env:
          GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}
        run: |
          printf '%s' "$GOOGLE_SERVICE_ACCOUNT_JSON" > "$RUNNER_TEMP/google-service-account.json"

      - name: Update Phase 2 technical sheets
        run

O workflow real injeta também as variáveis necessárias na etapa de produção.

O cron utilizado corresponde a uma execução diária às 15:00 UTC, aproximadamente 12:00 no horário de Brasília durante o período do projeto.

A rotina trabalha com o último dia fechado

O pipeline não tenta utilizar o dia corrente incompleto.

Se a execução acontece em 09/09, o endDate padrão é 08/09.

Conceitualmente:

function resolverEndDate(now) {
  return ontemEmSaoPaulo(now);
}
function resolverEndDate(now) {
  return ontemEmSaoPaulo(now);
}
function resolverEndDate(now) {
  return ontemEmSaoPaulo(now);
}

Isso evita misturar:

M1 completo até ontem
M1 completo até ontem
M1 completo até ontem

com:

dados parciais das primeiras horas de hoje
dados parciais das primeiras horas de hoje
dados parciais das primeiras horas de hoje

Também respeita o tempo de processamento do GA4.

Um erro no CI mostrou por que teste e produção precisam de isolamento

Durante a homologação do workflow, encontramos um problema interessante.

Variáveis de produção estavam disponíveis durante a execução dos testes.

Um teste que deveria validar a ausência de determinada configuração acabava encontrando a variável real.

Isso fazia a aplicação avançar para uma etapa seguinte e tentar carregar uma credencial local que, corretamente, não existia no GitHub Actions.

A solução foi separar os ambientes.

Primeiro:

- name: Run tests
  run

- name: Run tests
  run

- name: Run tests
  run

Sem variáveis de produção.

Depois:

- name: Update Phase 2 technical sheets
  env:
    GA4_PROPERTY_ID: ${{ vars.GA4_PROPERTY_ID }}
    GA4_START_DATE: ${{ vars.GA4_START_DATE }}
    FASE2_TECHNICAL_SPREADSHEET_ID: ${{ vars.FASE2_TECHNICAL_SPREADSHEET_ID }}
    FASE1_SPREADSHEET_ID: ${{ vars.FASE1_SPREADSHEET_ID }}
    FASE1_STATUS_SHEET_NAME: ${{ vars.FASE1_STATUS_SHEET_NAME }}
  run

- name: Update Phase 2 technical sheets
  env:
    GA4_PROPERTY_ID: ${{ vars.GA4_PROPERTY_ID }}
    GA4_START_DATE: ${{ vars.GA4_START_DATE }}
    FASE2_TECHNICAL_SPREADSHEET_ID: ${{ vars.FASE2_TECHNICAL_SPREADSHEET_ID }}
    FASE1_SPREADSHEET_ID: ${{ vars.FASE1_SPREADSHEET_ID }}
    FASE1_STATUS_SHEET_NAME: ${{ vars.FASE1_STATUS_SHEET_NAME }}
  run

- name: Update Phase 2 technical sheets
  env:
    GA4_PROPERTY_ID: ${{ vars.GA4_PROPERTY_ID }}
    GA4_START_DATE: ${{ vars.GA4_START_DATE }}
    FASE2_TECHNICAL_SPREADSHEET_ID: ${{ vars.FASE2_TECHNICAL_SPREADSHEET_ID }}
    FASE1_SPREADSHEET_ID: ${{ vars.FASE1_SPREADSHEET_ID }}
    FASE1_STATUS_SHEET_NAME: ${{ vars.FASE1_STATUS_SHEET_NAME }}
  run

Testes precisam rodar em ambiente controlado.

Produção recebe configuração de produção somente quando realmente precisa dela.

O pipeline para antes de atualizar produção se os testes falham

Essa regra ficou explícita no GitHub Actions.

A sequência é:

checkout

Node.js

testes

credenciais temporárias

coleta

transformação

Google Sheets
checkout

Node.js

testes

credenciais temporárias

coleta

transformação

Google Sheets
checkout

Node.js

testes

credenciais temporárias

coleta

transformação

Google Sheets

Se os testes falham:

STOP
STOP
STOP

A atualização das bases técnicas não acontece.

Isso parece um comportamento normal em desenvolvimento de software.

Mas é igualmente importante em Analytics.

Um pipeline que alimenta dashboards também é produção.

Apps Script continuou fazendo parte da solução

A evolução para Node.js e GitHub Actions não eliminou o Google Apps Script.

Ele continua adequado para determinadas operações próximas ao Google Workspace.

No projeto, Apps Script participa de integrações como:

Google Sheets
Google Forms
triggers operacionais
Bitly
Google Sheets
Google Forms
triggers operacionais
Bitly
Google Sheets
Google Forms
triggers operacionais
Bitly

Uma rotina pode seguir uma estrutura semelhante a:

function atualizarMetricas() {
  const spreadsheet = SpreadsheetApp.openById(
    "SPREADSHEET_ID"
  );

  const sheet = spreadsheet.getSheetByName(
    "metricas"
  );

  // leitura
  // transformação
  // escrita
}
function atualizarMetricas() {
  const spreadsheet = SpreadsheetApp.openById(
    "SPREADSHEET_ID"
  );

  const sheet = spreadsheet.getSheetByName(
    "metricas"
  );

  // leitura
  // transformação
  // escrita
}
function atualizarMetricas() {
  const spreadsheet = SpreadsheetApp.openById(
    "SPREADSHEET_ID"
  );

  const sheet = spreadsheet.getSheetByName(
    "metricas"
  );

  // leitura
  // transformação
  // escrita
}

O ponto não é escolher Apps Script ou Node.js.

É escolher a ferramenta de acordo com a responsabilidade.

Triggers do Apps Script também fazem parte da automação

Algumas rotinas continuam sendo executadas por acionadores do próprio Apps Script.

Uma função de instalação pode garantir que exista somente um trigger:

function instalarTriggerDiario() {
  const handler = "atualizarMetricasDiariamente";

  ScriptApp.getProjectTriggers()
    .filter((trigger) => {
      return trigger.getHandlerFunction() === handler;
    })
    .forEach((trigger) => {
      ScriptApp.deleteTrigger(trigger);
    });

  ScriptApp.newTrigger(handler)
    .timeBased()
    .everyDays(1)
    .atHour(10)
    .create();
}
function instalarTriggerDiario() {
  const handler = "atualizarMetricasDiariamente";

  ScriptApp.getProjectTriggers()
    .filter((trigger) => {
      return trigger.getHandlerFunction() === handler;
    })
    .forEach((trigger) => {
      ScriptApp.deleteTrigger(trigger);
    });

  ScriptApp.newTrigger(handler)
    .timeBased()
    .everyDays(1)
    .atHour(10)
    .create();
}
function instalarTriggerDiario() {
  const handler = "atualizarMetricasDiariamente";

  ScriptApp.getProjectTriggers()
    .filter((trigger) => {
      return trigger.getHandlerFunction() === handler;
    })
    .forEach((trigger) => {
      ScriptApp.deleteTrigger(trigger);
    });

  ScriptApp.newTrigger(handler)
    .timeBased()
    .everyDays(1)
    .atHour(10)
    .create();
}

A remoção de triggers anteriores evita duplicar execuções caso a instalação seja executada novamente.

Nem toda automação precisa estar no mesmo runtime

A arquitetura final é híbrida.

GitHub Actions executa a camada Node responsável por parte importante do processamento GA4 e das tabelas técnicas.

Apps Script executa rotinas que continuam mais próximas do ecossistema Google.

O requisito não é:

Tudo precisa rodar na mesma tecnologia
Tudo precisa rodar na mesma tecnologia
Tudo precisa rodar na mesma tecnologia

O requisito é:

Tudo precisa ter responsabilidade definida,
ser documentado,
ser testável
e produzir dados consistentes

Tudo precisa ter responsabilidade definida,
ser documentado,
ser testável
e produzir dados consistentes

Tudo precisa ter responsabilidade definida,
ser documentado,
ser testável
e produzir dados consistentes

Google Sheets como camada técnica

O Sheets recebe tanto históricos quanto estados atuais.

Exemplos de abas:

config_modulos
config_conteudos
config_calendario

ga4_conteudos
ga4_partes
ga4_modulos
ga4_curso

formularios_resumo
formularios_historico

bitly_metricas

execucao_log
config_modulos
config_conteudos
config_calendario

ga4_conteudos
ga4_partes
ga4_modulos
ga4_curso

formularios_resumo
formularios_historico

bitly_metricas

execucao_log
config_modulos
config_conteudos
config_calendario

ga4_conteudos
ga4_partes
ga4_modulos
ga4_curso

formularios_resumo
formularios_historico

bitly_metricas

execucao_log

Além das tabelas atuais destinadas ao Looker.

Essa organização transforma a planilha em uma camada operacional relativamente estruturada.

O Looker Studio ficou deliberadamente mais simples

Depois de todo esse processamento, o Looker não precisa reconstruir a engenharia.

Ele recebe fontes preparadas.

Exemplos:

looker_ga4_conteudos_atual
looker_ga4_modulos_atual
looker_ga4_partes_atual
looker_ga4_curso_atual
looker_ga4_conteudos_atual
looker_ga4_modulos_atual
looker_ga4_partes_atual
looker_ga4_curso_atual
looker_ga4_conteudos_atual
looker_ga4_modulos_atual
looker_ga4_partes_atual
looker_ga4_curso_atual

O dashboard então faz aquilo que deveria fazer:

filtrar
agrupar
comparar
visualizar
filtrar
agrupar
comparar
visualizar
filtrar
agrupar
comparar
visualizar

e não:

reconstruir toda a metodologia do pipeline
reconstruir toda a metodologia do pipeline
reconstruir toda a metodologia do pipeline

Ainda existem alguns cálculos no dashboard

Nem toda matemática precisa necessariamente sair do Looker.

Um cálculo simples e semanticamente seguro pode continuar na camada de apresentação.

Por exemplo:

SUM(usuarios_curso) / SUM(inscritos_fase1)
SUM(usuarios_curso) / SUM(inscritos_fase1)
SUM(usuarios_curso) / SUM(inscritos_fase1)

para uma taxa operacional consolidada.

Ou:

SUM(user_engagement_duration) / SUM(usuarios_curso)
SUM(user_engagement_duration) / SUM(usuarios_curso)
SUM(user_engagement_duration) / SUM(usuarios_curso)

para tempo médio engajado consolidado.

A regra é evitar que o dashboard precise reconstruir entidades complexas ou resolver deduplicação.

O dashboard também precisa saber quando não mostrar nada

Módulos futuros não são publicados como zero.

Isso gera um comportamento importante no Looker.

Antes do início do M2, determinadas páginas podem simplesmente não ter dados.

Isso é correto.

Depois que o calendário operacional tornar o módulo elegível e a automação processar seus conteúdos, as novas linhas começam a aparecer.

Essa decisão mantém a diferença entre:

0
0
0

e:

ainda não aplicável
ainda não aplicável
ainda não aplicável

A automação final

Uma visão simplificada do fluxo técnico fica assim:

MAILCHIMP
   |
   v
APPS SCRIPT
   |
   v
FASE 1 SHEETS
   |
   | inscritos
   v
NODE.JS <------ GA4 DATA API
   |                 ^
   |                 |
   |             GTM + GA4
   |
   +---------- BITLY
   |
   +---------- GOOGLE FORMS
   |
   v
FASE 2 TECHNICAL SHEETS
   |
   v
LOOKER SNAPSHOTS
   |
   v
LOOKER STUDIO
MAILCHIMP
   |
   v
APPS SCRIPT
   |
   v
FASE 1 SHEETS
   |
   | inscritos
   v
NODE.JS <------ GA4 DATA API
   |                 ^
   |                 |
   |             GTM + GA4
   |
   +---------- BITLY
   |
   +---------- GOOGLE FORMS
   |
   v
FASE 2 TECHNICAL SHEETS
   |
   v
LOOKER SNAPSHOTS
   |
   v
LOOKER STUDIO
MAILCHIMP
   |
   v
APPS SCRIPT
   |
   v
FASE 1 SHEETS
   |
   | inscritos
   v
NODE.JS <------ GA4 DATA API
   |                 ^
   |                 |
   |             GTM + GA4
   |
   +---------- BITLY
   |
   +---------- GOOGLE FORMS
   |
   v
FASE 2 TECHNICAL SHEETS
   |
   v
LOOKER SNAPSHOTS
   |
   v
LOOKER STUDIO

Por fora desse fluxo existe outra camada:

GITHUB
   |
   +-- código
   +-- testes
   +-- schemas
   +-- documentação
   +-- decisões
   |
   v
GITHUB ACTIONS
   |
   v
EXECUÇÃO AUTOMATIZADA
GITHUB
   |
   +-- código
   +-- testes
   +-- schemas
   +-- documentação
   +-- decisões
   |
   v
GITHUB ACTIONS
   |
   v
EXECUÇÃO AUTOMATIZADA
GITHUB
   |
   +-- código
   +-- testes
   +-- schemas
   +-- documentação
   +-- decisões
   |
   v
GITHUB ACTIONS
   |
   v
EXECUÇÃO AUTOMATIZADA

O que esse projeto mudou na minha visão de Web Analytics

Projetos de Analytics costumavam ser muito concentrados em:

tag
evento
dimensão
métrica
dashboard
tag
evento
dimensão
métrica
dashboard
tag
evento
dimensão
métrica
dashboard

Isso continua sendo importante.

Mas projetos mais complexos exigem uma stack diferente:

APIs
schemas
código
testes
versionamento
CI
credenciais
logs
idempotência
pipelines
governança
APIs
schemas
código
testes
versionamento
CI
credenciais
logs
idempotência
pipelines
governança
APIs
schemas
código
testes
versionamento
CI
credenciais
logs
idempotência
pipelines
governança

A fronteira entre Web Analytics, Analytics Engineering e desenvolvimento está cada vez menor.

O dashboard é apenas a interface final

Depois de desenvolver essa infraestrutura, fica mais evidente que o Looker Studio é apenas a interface final do sistema.

O valor não está apenas no gráfico.

Está no caminho que transforma uma interação bruta em uma métrica confiável.

Por exemplo:

Usuário recebe uma mensagem

clica em um link

entra em uma URL parametrizada

GTM registra comportamento

GA4 processa os eventos

Data API consulta o conjunto correto

Node.js transforma os resultados

testes validam as regras

Google Sheets recebe o snapshot

Looker Studio apresenta
Usuário recebe uma mensagem

clica em um link

entra em uma URL parametrizada

GTM registra comportamento

GA4 processa os eventos

Data API consulta o conjunto correto

Node.js transforma os resultados

testes validam as regras

Google Sheets recebe o snapshot

Looker Studio apresenta
Usuário recebe uma mensagem

clica em um link

entra em uma URL parametrizada

GTM registra comportamento

GA4 processa os eventos

Data API consulta o conjunto correto

Node.js transforma os resultados

testes validam as regras

Google Sheets recebe o snapshot

Looker Studio apresenta

Cada seta possui uma decisão técnica.

De Web Analytics para Analytics Engineering

Talvez essa seja a melhor maneira de definir a evolução deste projeto.

Começamos tentando medir uma jornada.

Terminamos construindo uma pequena plataforma de dados para acompanhá-la.

O GA4 continua sendo uma peça central, mas deixou de ser o lugar onde toda a inteligência precisa existir.

Google Sheets deixou de ser apenas uma planilha.

GitHub deixou de ser apenas um repositório.

GitHub Actions deixou de ser apenas uma ferramenta de CI.

Looker Studio deixou de ser o local onde todos os cálculos precisam nascer.

Cada ferramenta passou a ocupar uma camada específica.

É essa separação de responsabilidades que torna possível automatizar Analytics sem abrir mão da metodologia.

Este projeto ainda terá um terceiro capítulo

Este artigo e o anterior documentam a construção enquanto o Curso PBL 2026 ainda está acontecendo.

O primeiro apresentou a arquitetura geral:

Como construímos uma arquitetura de dados para acompanhar um curso online latino-americano com GA4, Google Sheets, Apps Script, GitHub e Looker Studio

Este segundo mostrou parte da implementação em código e das decisões de engenharia que sustentam o relatório.

Quando os cinco módulos terminarem, teremos uma base muito mais rica para analisar:

  • evolução entre módulos;

  • retenção;

  • diferenças entre português e espanhol;

  • consumo por parte;

  • conteúdos com maior adesão;

  • profundidade de consumo;

  • conclusão;

  • relação entre inscritos, cursistas e concluintes.

Esse será o terceiro estágio natural do projeto.

Não mais explicar como construímos o sistema.

Mas mostrar o que o sistema conseguiu revelar.

Leia também

Para entender a arquitetura completa antes de entrar na implementação, leia:

Como construímos uma arquitetura de dados para acompanhar um curso online latino-americano com GA4, Google Sheets, Apps Script, GitHub e Looker Studio

Conteúdo original pesquisado e redigido pelo autor. Ferramentas de IA podem ter sido utilizadas para auxiliar na edição e no aprimoramento.

Conteúdo original pesquisado e redigido pelo autor. Ferramentas de IA podem ter sido utilizadas para auxiliar na edição e no aprimoramento.

Posts relacionados:

Posts relacionados:

Compartilhe!

Go back

Deixe a IA fazer o trabalho para Você Crescer Mais Rápido

Agende uma conversa hoje e comece a automatizar.

Deixe a IA fazer o trabalho para Você Crescer Mais Rápido

Agende uma conversa hoje e comece a automatizar.

© 2010 - 2026 Copyright

All Rights Reserved - Develop by Ad Rock Digital Mkt

Tecnologias utilizadas

© 2010 - 2026 Copyright

All Rights Reserved - Develop by
Ad Rock Digital Mkt

Tecnologias utilizadas