Testando com tráfego real: Como usar Edge Functions para garantir que seu novo software esteja pronto para produção

Pablo Diehl
aziontechbr
Published in
9 min readMar 8, 2024

--

Há um provérbio famoso que diz “Se não está quebrado, não conserte”, no entanto, isso não é exatamente verdade quando estamos falando de desenvolvimento de software. Com novos frameworks, ferramentas e processos surgindo todos os dias, a vontade de mudar é bem alta.

Talvez você busque mudanças porque sua base de código é tão complexa que cada implementação é um pesadelo. Ou talvez você queira construir uma nova solução do zero porque o desempenho de sua aplicação não é o mesmo de antes.

Por qualquer motivo que a mudança precise ser feita, sempre surge uma pergunta: O novo software vai suportar aquantidade de dados transportados por nossa aplicação no “mundo real”?

E é por isso que os testes são realmente importantes no desenvolvimento de software.

Embora existam algumas ferramentas muito boas para testes de carga e coisas do tipo, e quando falamos desses padrões de uso muito específicos que apenas os usuários reais podem pensar? Seria realmente bom se pudéssemos usar dados reais para testar nosso novo software, não é mesmo?

Bem, usando Azion Cells, na verdade é bem possível!

Espelhando o tráfego real para testar uma nova origem com Edge Functions

Uma das funcionalidades do listener “Firewall” disponível no Azion Cells é que ele pode executar tarefas sem causar qualquer impacto na solicitação do usuário e sem afetar quaisquer características da nossa Edge Application/Firewall (bem, é claro que também precisamos criar uma regra para acionar uma Edge Application).

Assim, podemos criar uma função que, para cada solicitação recebida pelo nosso Firewall, uma “sub-solicitação” será enviada para a nova origem que desejamos testar com os dados de produção.

O exemplo mais simples possível seria algo como:

const TEST_URL = "https://www.my-new-domain.com";

async function firewallHandler(event) {
fetch(TEST_URL);
event.continue();
}

addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

E uma regra de Rules Engine que ficará da seguinte forma:

Com essa simples Edge Function, agora somos capazes de transmitir o mesmo volume de solicitações do ambiente de produção para nossa origem de teste.

No entanto, não estamos exatamente simulando as solicitações reais: e quanto ao método da solicitação? A URI da solicitação? O método da solicitação?

Portanto, teremos que criar um código um pouco mais complexo:

const TEST_DOMAIN = "www.my-new-domain.com";

async function firewallHandler(event) {
const originalUrl = new URL(event.request.url);
const testUrl = `${originalUrl.protocol}//${TEST_DOMAIN}${originalUrl.pathname}${originalUrl.search}`;

let fetchOptions = {
method: event.request.method,
headers: Object.fromEntries(event.request.headers)
}

if (event.request.body) {
fetchOptions["body"] = await event.request.text();
}

fetch(testUrl, fetchOptions);
event.continue();
}


addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

Com esse novo código, somos capazes de enviar uma verdadeira duplicata da solicitação original para nossa origem de teste.

Observe que, como não utilizamos “await”, o tempo de latência adicionado à busca da solicitação é mínimo: para 100.000 (cem mil) solicitações, a latência média para a execução da função foi inferior a 0,001 segundos.

O problema com essa abordagem é que, quando não usamos “await” para uma resposta de busca, as Cells podem encerrar a solicitação antes que a origem responda.

Por isso, para garantir que a solicitação para nossa nova origem seja sempre concluída, devemos modificar um pouco nosso código:

const TEST_DOMAIN = "www.my-new-domain.com";

async function firewallHandler(event) {
const originalUrl = new URL(event.request.url);
const testUrl = `${originalUrl.protocol}//${TEST_DOMAIN}${originalUrl.pathname}${originalUrl.search}`;

let fetchOptions = {
method: event.request.method,
headers: Object.fromEntries(event.request.headers)
}

if (event.request.body) {
fetchOptions["body"] = await event.request.text();
}

event.waitUntil(fetch(testUrl, fetchOptions));
event.continue();
}

addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

Ao chamar o fetch dentro do método “event.waitUntil, isso fará com que as Azion Cells aguardem a conclusão da solicitação. Enquanto isso, nossa edge function não aguardará, pois chamamos imediatamente o método “event.continue”. Dessa forma, estamos garantindo a execução do fetch para nossa nova origem sem adicionar latência aos nossos usuários finais.

Eu já espelhei o tráfego, o que faço agora?

Assim que tudo estiver configurado, tudo o que precisamos fazer é aguardar as novas solicitações duplicadas chegarem à nossa nova origem e monitorar o status delas. Podemos verificar se há alguma degradação na nova origem, verificar se houve um aumento na latência em comparação com nossa origem “original”, verificar se houve um aumento no número de erros de servidor em comparação com a origem “original”, entre outros fatores que podem nos ajudar a entender se a nova origem está pronta para ser usada como a origem principal em um futuro próximo.

Há algo mais a ser feito na edge function?

Se você não puder monitorar ativamente o que está acontecendo em sua nova origem, podemos adicionar algumas ações na edge function. Com o custo de maior latência para os usuários, então a função não seria mais “transparente” para o usuário final, mas ainda pode ser útil, pois podemos usar os Azion Real-time Events para fornecer status sobre como a nova origem está funcionando.

Ao adicionar um “await” real no fetch para a nova origem, podemos, por exemplo, adicionar um registro sempre que a nova origem retornar um código de status 5xx.

const TEST_DOMAIN = "www.my-new-domain.com";

async function firewallHandler(event) {
const originalUrl = new URL(event.request.url);
const testUrl = `${originalUrl.protocol}//${TEST_DOMAIN}${originalUrl.pathname}${originalUrl.search}`;

let fetchOptions = {
method: event.request.method,
headers: Object.fromEntries(event.request.he aders)
}

if (event.request.body) {
fetchOptions["body"] = await event.request.text();
}

const testOriginResponse = await fetch(testUrl, fetchOptions);
if (testOriginResponse.status > 499) {
event.console.log(`New origin status code: ${testOriginResponse.status}`)
}

event.continue();
}


addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

Com esse novo código, sempre que a nova origem receber uma resposta 5xx, um registro como o seguinte aparecerá no Real-Time Events:

Observe que, com o “await”, a latência média para a execução da função aumentou para 0,014 segundos. Dito isso, como no teste anterior a latência média da função era apenas de 0,001 segundos, podemos deduzir que a latência média da nossa nova origem é de aproximadamente 0,0139 segundos.

No entanto, é importante perceber que, para o usuário final, essa nova abordagem tornou a função 14 vezes mais lenta do que a ideal original (sem o “await”). E que, para uma origem realmente rápida, quanto mais lenta a resposta da nova origem, mais o usuário final será afetado por esse “await”.

Outra situação: A latência não é tão crítica, quero que a função extraia mais dados sobre a sub-solicitação para a nova origem.

Se, para os seus testes, aumentar o tempo de resposta para o usuário final não for um problema, podemos adicionar mais detalhes nos logs escritos pela edge function. Por exemplo, para cada solicitação, mesmo aquelas que não resultam em um código de status 5xx, podemos registrar a latência da sub-solicitação.

const TEST_DOMAIN = "www.my-new-domain.com";

async function firewallHandler(event) {
const originalUrl = new URL(event.request.url);
const testUrl = `${originalUrl.protocol}//${TEST_DOMAIN}${originalUrl.pathname}${originalUrl.search}`;

let fetchOptions = {
method: event.request.method,
headers: Object.fromEntries(event.request.headers)
}

if (event.request.body) {
fetchOptions["body"] = await event.request.text();
}

const now = Date.now();
const testOriginResponse = await fetch(testUrl, fetchOptions);
const timeSpent = (Date.now() - now)/1000; // Get the time spent in seconds
event.console.log(`[${testOriginResponse.status}, ${timeSpent}]`);

if (testOriginResponse.status > 499) {
event.console.warn(`New origin status code: ${testOriginResponse.status}`)
}

event.continue();
}


addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

O log resultante ficaria assim:

Mas as possibilidades não terminam por aí. Podemos, por exemplo, querer saber mais sobre as solicitações para a nova origem que não foram bem-sucedidas. Assim, podemos adicionar um log mais detalhado de “solicitação x resposta” para essas situações:

const TEST_DOMAIN = "www.my-new-domain.com";

async function firewallHandler(event) {
const originalUrl = new URL(event.request.url);
const testUrl = `${originalUrl.protocol}//${TEST_DOMAIN}${originalUrl.pathname}${originalUrl.search}`;

let fetchOptions = {
method: event.request.method,
headers: Object.fromEntries(event.request.headers)
}

if (event.request.body) {
fetchOptions["body"] = await event.request.text();
}

const now = Date.now();
const testOriginResponse = await fetch(testUrl, fetchOptions);
const timeSpent = (Date.now() - now)/1000; // Get the time spent in seconds

event.console.log(`[${testOriginResponse.status}, ${timeSpent}]`);

if (testOriginResponse.status > 399) {
event.console.warn(JSON.stringify({
request_method: event.request.method,
request_body: fetchOptions["body"],
request_headers: fetchOptions["headers"],
response_body: await testOriginResponse.text(),
response_status: testOriginResponse.status
}));
}

event.continue();
}


addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

Dessa forma, podemos analisar os logs para entender se o usuário foi bloqueado permanentemente ou se é um problema na nossa nova origem, por exemplo.

Algo que você pode estar pensando agora é: “E se a nova origem estiver inativa ou demorar muito para responder?” Certamente, isso é um problema ao usar essa abordagem de aguardar a resposta.

Porém, podemos prevenir isso adicionando um “AbortSignal” na operação de fetch.

const TEST_DOMAIN = "www.my-new-domain.com";

async function firewallHandler(event) {
try {
const originalUrl = new URL(event.request.url);
const testUrl = `${originalUrl.protocol}//${TEST_DOMAIN}${originalUrl.pathname}${originalUrl.search}`;

let fetchOptions = {
method: event.request.method,
headers: Object.fromEntries(event.request.headers),
signal: AbortSignal.timeout(1) // The user should not wait for more than 1.5 seconds
}

if (event.request.body) {
fetchOptions["body"] = await event.request.text();
}

const now = Date.now();
const testOriginResponse = await fetch(testUrl, fetchOptions);
const timeSpent = (Date.now() - now) / 1000; // Get the time spent in seconds

event.console.log(`[${testOriginResponse.status}, ${timeSpent}]`);

if (testOriginResponse.status > 399) {
event.console.warn(JSON.stringify({
request_method: event.request.method,
request_body: fetchOptions["body"],
request_headers: fetchOptions["headers"],
response_body: await testOriginResponse.text(),
response_status: testOriginResponse.status
}));
}
} catch(err) {
if (err.name === "TimeoutError") {
event.console.warn("The request to the new origin took too long!");
} else {
// In case of any other sort of error
event.console.warn(`Generic error handler. Error: ${err.message}`);
}
}

event.continue();
}


addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

Agora, sempre que a nova origem demorar muito para responder, a solicitação será abortada e o seguinte log será registrado:

Construindo uma function reutilizável

Até agora, construímos uma função que resolve nosso caso de uso, no entanto, ela não tem a melhor “reutilização”, por assim dizer. Isso ocorre porque ela possui argumentos codificados, como a URL do novo domínio ou o tempo limite usado no sinal de abort.

Podemos melhorá-la adicionando argumentos via variáveis de ambiente e/ou Argumentos JSON. Para fazer isso, precisamos modificar nosso código apenas um pouco:

async function firewallHandler(event) {
try {
const testDomain = event.args.url || Azion.env.get("TEST_URL") || "www.my-new-domain.com";
const testTimeout = event.args.timeout || Azion.env.get("TEST_TIMEOUT") || 10000;

const originalUrl = new URL(event.request.url);
const testUrl = `${originalUrl.protocol}//${testDomain}${originalUrl.pathname}${originalUrl.search}`;

let fetchOptions = {
method: event.request.method,
headers: Object.fromEntries(event.request.headers),
signal: AbortSignal.timeout(testTimeout) // The user should not wait for more than 1.5 seconds
}

if (event.request.body) {
fetchOptions["body"] = await event.request.text();
}

const now = Date.now();
const testOriginResponse = await fetch(testUrl, fetchOptions);
const timeSpent = (Date.now() - now) / 1000; // Get the time spent in seconds

event.console.log(`[${testOriginResponse.status}, ${timeSpent}]`);

if (testOriginResponse.status > 399) {
event.console.warn(JSON.stringify({
request_method: event.request.method,
request_body: fetchOptions["body"],
request_headers: fetchOptions["headers"],
response_body: await testOriginResponse.text(),
response_status: testOriginResponse.status
}));
}
} catch(err) {
if (err.name === "TimeoutError") {
event.console.warn("The request to the new origin took too long!");
} else {
// In case of any other sort of error
event.console.warn(`Generic error handler. Error: ${err.message}`);
}
}

event.continue();
}


addEventListener("firewall", (event) => event.waitUntil(firewallHandler(event)));

Agora, nossa função primeiro tenta carregar a URL e o tempo limite dos Argumentos JSON. Se não houver nenhum, então ela tenta carregar os dados das variáveis de ambiente ou, finalmente, usa os valores padrão codificados. Com essa alteração, se torna bastante simples criar várias instâncias da função, executando com argumentos diferentes

Considerações Finais

Espelhar o tráfego do ambiente de produção fornece uma maneira simples de testar um novo software e garantir que ele esteja pronto para produção. Não apenas ajuda a verificar se o novo software pode lidar com a quantidade de solicitações que nossa aplicação normalmente recebe, mas também pode ser usado para verificar como o novo software se comporta em solicitações do mundo real, com dados de usuários reais.

Claro, isso exige um pouco de registro/análise de dados para entender as saídas e as possibilidades, mas pode ser uma ferramenta importante para descobrir se há algum problema crítico com o software que estamos prestes a implantar.

Quer saber mais sobre isso?
Confira nossa documentação e fale conosco na nossa comunidade no Discord.

--

--