Executando verificação de segurança...
20

Economizando com "analytics"

Encontrei um artigo chamado How to save $13.27 on your sAAs bill. O tom da escrita é muito bom, e a explicação técnica também é boa, apesar de eu ter sentido algumas lacunas nela. Alguns termos que o autor mencionou e que eu não entendi de primeira são "Gypity", para o ChatGPT, e "RSC", para React Server Components.

O artigo usa um tom cômico para criticar complicações sobre coisas simples, ao referenciar "SaaS" como "sAAs", por exemplo, ou ao dar um nome para a stack de tecnologias que escolheu (squeeh), ou mesmo o motivo de ter escolhido algo ("Bun porque tem um mascote delicioso e Hono porque vi um vídeo em que um cara disse Hono e isso me fez rir").

Então, para quem sabe inglês e gosta de ler, recomendo ir direto ao artigo fonte porque pode te uma surpresa agradável com o estilo de escrita do autor, David Gerrells.

Como economizar US$ 13,27 na sua conta sAAs

US$ 13,27 não é muito, e foi por isso que o título do artigo me chamou a atenção. Na verdade, depende da escala e do trabalho envolvido para ter essa economia, mas eu decidi continuar lendo porque o artigo fala sobre analytics. E mais: analytics da Vercel. O TabNews também usa isso, e o custo apenas nesse ponto está na casa dos US$ 50 mensais (veja aqui).

Bem, o David Guerrels decidiu experimentar o analytics da Vercel porque o plano pro inclui 25 mil eventos, cobrando US$ 14 por cada 100 mil eventos seguintes, e ele poderia cancelar isso se usasse a cota disponível, então era um custo controlado.

No fim do mês, ele recebeu uma conta de US$ 28, e decidiu experimentar algo diferente.

Meu jogo de infra é fraco. Pensei que seria um desafio divertido e uma boa prática tentar economizar alguns dólares na minha conta da Vercel construindo uma API de analytics do zero usando uma nova stack. Uma stack tão avançada que os edge lords só agora ouviram falar dela.

The Squeeh stack: o autor queria usar SQLite porque ouvia com frequência que ele é rápido, mas nunca havia usado antes. Então, a Squeeh stack é qualquer stack que usa SQLite para os dados.

Também escolheu usar Bun e Hono, mas sem dar os motivos direito (como já citei no início do texto, "Bun porque tem um mascote delicioso e Hono porque vi um vídeo em que um cara disse Hono e isso me fez rir"), então vou fazer uma inserção educacional aqui:

  • Em resumo, Bun é uma alternativa ao Node.js. Você pode usá-lo para executar seu código JavaScript.
  • Hono eu não conhecia. Então fiz uma pesquisa rápida, e o site deles motra que é um concorrente do Express. É um framework para você criar sua API.

Voltando. O David criou um schema em um script db.ts responsável por criar a tabela de analytics, e o código do endpoint era esse:

app.post("/analytics", async (c) => {
  try {
    const data = await c.req.json();
    insertLog(data);
    return c.json({ message: "Event logged" }, 201);
  } catch (error) {
    console.error("Error logging analytics:", error);
    return c.json({ error: "Internal Server Error" }, 500);
  }
});

O objetivo era verificar o quanto o SQLite aguenta antes de continuar o desenvolvimento. Pediu um script de teste de carga para o Gypity (ChatGPT), que forneceu um código usando o hey.

URL="http://localhost:3000/analytics"
DURATION="30s"
CONCURRENT_REQUESTS=10
TOTAL_REQUESTS=10000

DATA='{
  "time": "2024-07-23T15:12:20.53Z",
  "status_code": 200,
  "status_text": "OK",
  "host": "example.com",
  "request_path": "/some/path",
  "request_id": "abc123",
  "request_user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
  "level": "Info",
  "environment": "production",
  "location": "New York, USA",
  "ip_address": "203.0.113.1"
}'

hey -m POST -d "$DATA" -H "Content-Type: application/json" -c $CONCURRENT_REQUESTS -n $TOTAL_REQUESTS $URL

O teste com 10.000 requisições foi tranquilo, então ele aumentou para 1 milhão. Esse deu alguns problemas de travas (locks) e algumas requisições falharam. Ao adicionar o PRAGMA WAL, resolveu: db.exec("PRAGMA journal_mode = WAL;");. Na documentação tem um bom resumo sobre o WAL logo no início.

O objetivo momentâneo dele foi conseguir aumentar o número de requisições, então ele decidiu realizar o INSERT em lotes:

const insertAnalytics = db.prepare(`
  INSERT INTO analytics (
    data
  ) VALUES (many question marks)
`);

const transact = db.transaction((logs) => {
  for (const log of logs) {
    insertAnalytics.run(...orderSpecificLogFields);
  }
  return logs.length;
});

let activeLogBuffer: any[] = [];
let isActiveWrite = false;

function backgroundPersist() {
  if (activeLogBuffer.length === 0 || isActiveWrite) return;
  try {
    const tempLogs = activeLogBuffer;
    activeLogBuffer = [];
    isActiveWrite = true;
    const count = transact(tempLogs);
    console.log(`inserted ${count} events`);
  } catch (e) {
    console.error("batch insert error events dropped", e);
  }
  isActiveWrite = false;
}

setInterval(backgroundPersist, 20);

app.post("/analytics", async (c) => {
  try {
    const data = await c.req.json();
    activeLogBuffer.push(data);
    return c.json({ message: "Event logged" }, 201);
  } catch (error) {
    console.error("Error logging analytics:", error);
    return c.json({ error: "Internal Server Error" }, 500);
  }
});

Assim também é possível retornar a resposta antes do log do evento. Um teste de carga de 100 mil requisições ficou assim:

Summary:
  Total:        2.0621 secs
  Slowest:      0.0093 secs
  Fastest:      0.0000 secs
  Average:      0.0002 secs
  Requests/sec: 48495.3401

  Total data:   2600000 bytes
  Size/request: 26 bytes

E 1 milhão com 20 requisições concorrentes:

Summary:
  Total:        19.8167 secs
  Slowest:      0.0111 secs
  Fastest:      0.0000 secs
  Average:      0.0004 secs
  Requests/sec: 50462.3789

  Total data:   26000000 bytes
  Size/request: 26 bytes

Então, estava tudo pronto para o deploy.

Deploy na DigitalOcean

O David sofreu um pouco para conseguir fazer o serviço rodar no Docker. Pegou uma VPS na DigitalOcean, e criou um script bash que fazia tudo o que fosse necessário para rodar o serviço em uma nova máquina: instala todas as dependência, configura o nginx com lets encrypt etc.

Depois de conseguir colocar a API no ar, teve um problema de só conseguir alcançar 250 requisições por segundo. A CPU e memória estava ok, mas ele decidiu aumentar e tentar de novo. Chegou a 2 mil requisições por segundo antes de parar novamente. Ele descobriu que teve o IP bloqueado (não conseguia nem mesmo acessar via SSH). O jeito de testar seria na prática, com tráfego real.

De volta à Vercel

A API ficaria em uma função da Vercel. Ele queria incluir um pouco mais de informações e implementar algum rastreamento de sessão simples para poder ter uma ideia melhor dos usuários únicos. O schema ficou assim:

db.exec(`
  CREATE TABLE IF NOT EXISTS analytics (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    type TEXT,
    time TEXT,
    status_code INTEGER,
    status_text TEXT,
    host TEXT,
    request_path TEXT,
    request_id TEXT,
    request_user_agent TEXT,
    session_id TEXT,
    os TEXT,
    browser TEXT,
    country TEXT,
    level TEXT,
    environment TEXT,
    location TEXT,
    ip_address TEXT,
    content TEXT,
    referrer TEXT
  )
`);

A função da Vercel:

import { UAParser } from "ua-parser-js";

const url = process.env.ANALYTICS_API_URL || "fallback";

export async function POST(req) {
  const data = {
    ...requestData,
    //set other data from headers etc
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(data),
    });

    const result = await response.json();
    return new Response(JSON.stringify(result), {
      status: 201,
      headers: {
        "Content-Type": "application/json",
      },
    });
  } catch (error) {
    // handle errors
  }
}

E o hook no React:

import { usePathname } from "next/navigation";
import { useEffect } from "react";

function getSessionId() {
  let sessionId = localStorage.getItem("sessionId");
  if (!sessionId) {
    sessionId = `session-${crypto.randomUUID()}`;
    localStorage.setItem("sessionId", sessionId);
  }

  return sessionId;
}

export const useAnalytics = () => {
	const pathname = usePathname();
	const hasFiredExitEventRef = useRef<boolean>(false);

	useEffect(() => {
	  logAnalytics("page-view");

	  const handleVisibilityChange = (e: any) => {
	    if (document.visibilityState === "visible") {
	      logAnalytics("page-return");
	      hasFiredExitEventRef.current = false;
	      return;
	    }

	    if (hasFiredExitEventRef.current) return;

	    if (document.visibilityState === "hidden") {
	      logAnalytics("page-leave");
	      hasFiredExitEventRef.current = true;
	      return;
	    }

	    if (e.type === "pagehide") {
	      logAnalytics("page-leave");
	      hasFiredExitEventRef.current = true;
	    }
	  };

	  document.addEventListener("visibilitychange", handleVisibilityChange);
	  window.addEventListener("pagehide", handleVisibilityChange);

	  return () => {
	    document.removeEventListener("visibilitychange", handleVisibilityChange);
	    window.removeEventListener("pagehide", handleVisibilityChange);
	  };
	}, [pathname]);

  return null;
};

E ele precisou encapsular o hook em um componente:

"use client";
import { useAnalytics } from "./useAnalytics";

export function Analytics() {
  useAnalytics();
  return null;
}

Sem esse componente, ele não conseguiri usar o hook na raiz da árvore de componentes (por causa do "use client"), então ao encapsular o hook num componente, fica fácil de usar isso sem impactar um "componente do lado do servidor" (RSC).

E assim ele consegue executar o próprio analytics em conjunto do analytics da Vercel para ter uma base comparativa:

import { Analytics } from "./components/Analytics";
import { Analytics as VercelStyle } from "@vercel/analytics/react";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
        <VercelStyle />
      </body>
    </html>
  );
}

Resolvendo erros

No dia seguinte, ele notou que várias requisições falharam. Quando acessou a VPS, não tinha alto consumo de nada, mas o serviço havia parado. Parece que era algum problema no systemd.

Depois, passou a ter alguns problemas com o bun encerrando de vez em quando, veja os vales no consumo de CPU no gráfico abaixo.

Uso de CPU com alguns vales

O problema era o static router do Hono, e ele conseguiu ver isso pelos logs do systemd. Depois de removê-lo, a situação melhorou bastante:

O uso de CPU que estava sempre em 6~7% passou para algo próximo de 0, provavelmente 1%.

Dashboard

Ele criou o analytics com base no da Vercel, para não sair perdendo algo ao trocar o serviço, então tinha todas as informações que o analytics da Vercel disponibiliza, e um pouco mais. Montou um dashboard simples:

Gráfico com vistantes por hora, somatório de visitantes únicos e bounce rate.

Tabelas com referrer, visitantes por página, país etc.

Squeeh stack vs Vercel

O analytics dele pareceu contar um pouco mais de visitas do que o da Vercel, o que pode ser por causa de como é determinado se um visitante é único ou não. Ele também não filtra dados de teste nem de bot, e o shield do Brave não bloqueava esse rastreador dele.

Com o tráfego atual, isso funcionaria bem em um VPS de US$ 6,00 por mês. Os dados são suficientes para cobrir bem mais de 100 milhões de eventos, talvez até um bilhão, dependendo do SQLite. Posso adicionar volumes para backup de dados por alguns dólares a mais, dependendo do tamanho. Deixei o VPS superprovisionado em um nível mais alto e obtive uma economia de US$ 13,27 em comparação com meus gastos atuais com o Vercel Analytics. Demorou cerca de 2 dias para construir isso e colher essa economia. CPU/memória/etc. é baixo. Com o teste de carga, o bun atingiu o pico em torno de 50 MB.

Eu fui dar uma olhada no site da DigitalOcean e não encontrei um VPS de US$ 6,00 por mês. Lá tem um droplet de US$ 7,00 por mês, não sei se é o que ele usou, e a minha impressão é que a quantidade de memória disponível (SSD) é pequena para armazenar dados por um longo período, mas ele poderia extrair dados antigos e armazenar no S3, por exemplo, caso deseje manter o histórico, e/ou criar uma outra tabela com os dados agregados.

Voltando à comparação com o analytics da Vercel, o David mencionou que pode adicionar outros dados que desejar rastrear, usar queries diferentes, tem acesso direto ao serviço e aos dados e consegue calcular um bounce rate aproximado. Ele comentou que continuará rodando esse serviço lado a lado do Vercel Analytics e irá iterar com o passar do tempo (então na prática não foi uma economia hehe).

No fim ele disse que a Vercel usa o Tinybird por baixo dos panos, então pode ser interessante trocar a Vercel pelo Tinybird. Eu não conhecia, mas vi um comentário falando que usou o Tinybird em conjunto de um analytics próprio e ficou mais barato do que um VPS.

Se você tem experiência com analytics, se já fez uma economia nesse aspecto, se já usou o Tinybird ou outro produto, compartilhe!

Carregando publicação patrocinada...
2
2

Belo resumo do artigo! Muito massa a versão de Analytics do Daniel, dei uma olhada no blog dele, bastante coisa legal.

Achei bacana esse sobre Box Shadows, seria interessante essa interatividade em um post do TabNews 🤔

2

Aí sim hein Rafael, muito boa sua análise e forma de compartilhar o post original!

Estou usando o plausible como analytics a alguns meses, mas também quero trabalhar em uma poc de analytics nas próximas semanas, estou considerando usar o tinybird como db, gostei da sua sugestão.

Continue postando, abraço!

5

Eu estou testando o Tinybird no momento e muito provavelmente vou criar uma publicação ainda este mês no TabNews sobre isso. Caso queira testar antes de eu publicar sobre, estou fazendo com base no artigo mencionado acima e nesse aqui, além da documentação do Tinybird.

Depois de eu terminar os testes e a publicação, vou deixar o repositório público também 👍

1
2
1
1