Como a DevStride está mudando de Datadog para AWS X-Ray e Cloudwatch para observabilidade
O DevStride tem mais de 200 funções lambda que precisamos para garantir que estejam funcionando conforme o esperado e funcionando corretamente. Precisamos de uma ferramenta para nos dar uma visão geral de como está nossa infraestrutura e avaliar a integridade geral do sistema.
O que nós precisamos?
Nosso caso de uso é muito simples e precisamos responder principalmente a algumas perguntas:
- Qual é a nossa latência geral?
- Qual é o nosso uso?
- Temos algum erro na produção?
- Receba alertas no Slack para qualquer novo problema que aparecer.
- Facilite o rastreamento de erros e encontre a causa raiz.
- Correlacione todos os logs do início ao fim da execução (em todas as funções).
O Datadog é um ótimo serviço com muitos recursos - significativamente mais do que precisamos atualmente. Usamos principalmente APM, Logs e RUM.
Embora o Datadog forneça uma ótima maneira de realizar o que precisamos, o custo é o principal motivo de nossa mudança. Em particular, o custo do APM para sem servidor que, a partir de agora, é de US$ 6 /mês por função + custos extras com base no uso.
Como estamos substituindo o Datadog?
Como mencionado anteriormente, o DevStride está usando infraestrutura sem servidor e temos mais de 200 funções lambda que precisamos implantar e acompanhar.
Na DevStride, usamos SST , que é uma abstração do AWS CDK . Para nossas definições de infraestrutura e código de função, usamos TypeScript.
Queremos substituir nosso agente Datadog atual por AWS X-Ray e CloudWatch e isso requer algumas alterações em nosso código de função e infraestrutura.
Funções de configuração
Para tornar nossa transição o mais simples possível, decidimos usar o novo AWS Lambda PowerTools . Este pacote simplifica algumas das configurações e é super útil.
Das ferramentas elétricas, obtemos:
- O rastreador que podemos usar para anotar traços
- O registrador para anotar logs com nossos rastreamentos e parâmetros úteis
- O middleware para anotar traces/logs automaticamente
- Auxiliares para adicionar métricas personalizadas
Criamos o rastreador e o registrador e expomos a funcionalidade que podemos usar em nosso aplicativo.
import { Tracer } from '@aws-lambda-powertools/tracer';
import { Logger } from '@aws-lambda-powertools/logger';
import { Config } from '@serverless-stack/node/config';
let tracer: Tracer;
let logger: Logger;
// Only initiate the tracer in production
// We call Config.APP to make sure that the tracer is initiated after
// all the secrets are loaded from SST
if (process.env.NODE_ENV === 'production' && Config.APP) {
tracer = new Tracer();
logger = new Logger();
tracer.provider.setLogger(logger);
}
// Annotate the trace with metadata
export function addTags(tags: { [key: string]: any }) {
if (!tracer) {
return;
}
for (const key of Object.keys(tags)) {
tracer.putMetadata(key, tags[key]);
}
}
// Annotate the trace with user metadata
export function setUser(authUser: AuthUser) {
if (!tracer) {
return;
}
tracer.putAnnotation('user_email', authUser.email);
tracer.putAnnotation('user_username', authUser.username);
tracer.putAnnotation('user_name', authUser.name);
tracer.putAnnotation('user_id', authUser.userId);
tracer.putAnnotation('user_status', authUser.status);
}
export function getTracer() {
return tracer;
}
export function getLogger() {
return logger;
}
No DevStride, usamos middy para nosso middleware e abstraímos com classes. Este é um exemplo do manipulador lambda base que todos os outros manipuladores estendem.
export abstract class LambdaBaseHandler<Event, Response> {
constructor(
protected logger: Logger,
protected middlewares?: Array<MiddlewareObj>
) {}
make(): MiddyfiedHandler<Event, Response> {
const mdw = [];
const tracer = getTracer();
if (process.env.NODE_ENV === 'production' && tracer) {
// Add the tracer middleware
mdw.push(captureLambdaHandler(tracer, { captureResponse: false }));
// Add the logger middleware
mdw.push(injectLambdaContext(logger));
}
mdw.push(
...(this.middlewares || []),
);
return middy(this.execute.bind(this)).use(mdw);
}
//
protected execute(event: Event): Promise<Response> {
this.logger.setContext(`${this.constructor.name}:execute`);
return this.handle(event);
}
// When you extend this class you implement this handler with your logic
protected abstract handle(event: Event): Promise<Response>;
}
Infraestrutura de configuração
Queremos simplificar ao máximo a configuração de funções, por isso adicionamos as configurações necessárias às variáveis de ambiente. Para poder fazer isso rapidamente, criamos uma utility
função que faz isso para todas as nossas funções. Além disso, queremos poder ouvir todos os logs, para que possamos enviar alertas ao Slack quando encontrarmos logs de erro.
Primeiro, configuramos a pilha que implantará nossa função lambda e ouviremos nossos logs.
import * as sst from "@serverless-stack/resources";
import { StackContext } from "@serverless-stack/resources";
import { CfnPermission } from "aws-cdk-lib/aws-lambda";
export function MonitoringStack({ stack }: StackContext) {
const errorLogHandler = new sst.Function(stack, `ErrorLogHandler`, {
handler:
"src/check-error-logs.handler",
bind: [
// slack instructions https://api.slack.com/messaging/webhooks
new sst.Config.Parameter(stack, "SLACK_WEBHOOK_URL", {
value: process.env.SLACK_WEBHOOK_URL || "",
}),
],
});
const permission = new CfnPermission(
stack,
"ErrorLogLambdaPermission",
{
action: "lambda:InvokeFunction",
functionName: errorLogHandler.functionArn,
principal: "logs.amazonaws.com",
}
);
return {
errorLogHandler,
permission,
};
}
export function addInstrumentation(stack: sst.Stack, module: string) {
if (!process.env.IS_LOCAL) {
const { errorLogHandler, permission } = use(MonitoringStack);
deferredFunctions.push(() => {
stack.getAllFunctions().forEach((f) => {
const sf = new logs.SubscriptionFilter(stack, f.id + "Subscription", {
logGroup: f.logGroup,
destination: new destinations.LambdaDestination(errorLogHandler, {
addPermissions: false,
}),
// the filter pattern depends on what your logs look like
filterPattern: logs.FilterPattern.anyTerm("ERROR", "error"),
});
// this is a hack to make cloudformation wait for the permissions to be set
sf.node.addDependency(permission);
f.addEnvironment("POWERTOOLS_SERVICE_NAME", module);
f.addEnvironment("POWERTOOLS_METRICS_NAMESPACE", module);
});
});
}
}
export function MyModuleStack({ stack }: StackContext) {
const apiStack = use(ApiStack);
apiStack.api.addRoutes(stack, {
"GET /": {
function: "src/myFunc.handler",
},
});
addInstrumentation(stack, "myModule");
}
Também adicionamos retenção de log padrão e configuração de rastreamento a todas as nossas funções.
app.setDefaultFunctionProps({
tracing: process.env.IS_LOCAL ? "disabled" : "active",
logRetention: "two_weeks",
// we had to do this because we have a lot of lambda functions
// and we reached rate limiting issues in cloudformation
logRetentionRetryOptions: {
maxRetries: 12,
base: Duration.millis(500),
},
})

Além disso, você verá todos os detalhes dos rastreamentos e também a lista de rastreamentos.

Com o código acima, temos o que precisamos para monitorar nossos rastros e uso geral. Também podemos saber se algo está errado; traces mostrará todos os erros e onde eles ocorrem. Além disso, os traces agruparão os logs.
Enviando alertas para o Slack
Um de nossos requisitos era que precisávamos ser notificados sobre erros e encontrar uma maneira fácil de examinar os logs/rastreamento de erros.
Decidimos que a maneira mais fácil de fazer isso é enviar logs de erro para o Slack.
Na DevStride, usamos CQRS e todos os nossos manipuladores chamam comandos/consultas que executam a lógica de negócios.
Aqui está o nosso manipulador de logs de erro:
export class CheckErrorLogsLambdaHandler extends LambdaBaseHandler<
CloudWatchLogsEvent,
boolean
> {
constructor(logger: Logger) {
super(logger);
}
protected async handle(event: CloudWatchLogsEvent): Promise<boolean> {
const payload = JSON.parse(
zlib.unzipSync(Buffer.from(event.awslogs.data, 'base64')).toString()
);
const logs = payload.logEvents.map((e: any) => {
try {
const data = JSON.parse(e.message.replaceAll('\n', ''));
return {
message: data.message,
service: data.service,
timestamp: data.timestamp,
xrayTraceId: data.xray_trace_id,
correlationId: data.correlationId,
context: data.context,
};
} catch {
return {
message: e.message,
};
}
});
const response: Result<boolean> = await CommandBus.execute(
new CheckErrorLogsCommand({
correlationId: this.getCorrelationId(event),
logs: logs,
logGroup: payload.logGroup,
logStream: payload.logStream,
})
);
if (response.isErr) {
throw response.error;
}
return response.unwrap();
}
}
export class CheckErrorLogsService extends CommandHandlerBase<CheckErrorLogsCommand> {
constructor(logger: Logger) {
super(CheckErrorLogsCommand, logger);
}
async handle(
command: CheckErrorLogsCommand
): Promise<Result<boolean, Error>> {
const logGroupUrl = `https://${
process.env.REGION
}.console.aws.amazon.com/cloudwatch/home?region=${
process.env.REGION
}#logsV2:log-groups/log-group/${encodeURIComponent(command.logGroup)}`;
const logStreamUrl = `https://${
process.env.REGION
}.console.aws.amazon.com/cloudwatch/home?region=${
process.env.REGION
}#logsV2:log-groups/log-group/${encodeURIComponent(
command.logGroup
)}/log-events/${encodeURIComponent(command.logStream)}`;
const errorBlocks = [];
for (const log of command.logs) {
const traceUrl = `https://${process.env.REGION}.console.aws.amazon.com/cloudwatch/home?region=${process.env.REGION}#xray:traces/${log.xrayTraceId}`;
if (!log.service) {
errorBlocks.push(
...[
{
color: '#D13213',
blocks: [
{
type: 'divider',
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: log.message,
},
},
],
},
]
);
}
errorBlocks.push(
...[
{
color: '#D13213',
blocks: [
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*TraceId:*\n<${traceUrl}|${log.xrayTraceId}>`,
},
{
type: 'mrkdwn',
text: `*CorrelationId:*\n${log.correlationId}`,
},
],
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Environment:*\n${process.env.STAGE}`,
},
{
type: 'mrkdwn',
text: `*Service:*\n${log.service}`,
},
],
},
{
type: 'section',
text: {
text: `*Context:* ${log.context}`,
type: 'mrkdwn',
},
},
{
type: 'section',
text: {
text: '*Message:*\n' + '```' + log.message + '```',
type: 'mrkdwn',
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'Go to trace',
emoji: true,
},
url: traceUrl,
},
],
},
],
},
]
);
}
const functionName = command.logGroup.replace('/aws/lambda/', '');
await axios.post(Config.SLACK_WEBHOOK_URL, {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `${functionName} error logs`,
emoji: true,
},
},
],
attachments: [
{
color: '#D13213',
blocks: [
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*LogGroup:*\n<${logGroupUrl}|${command.logGroup}>`,
},
{
type: 'mrkdwn',
text: `*LogStream:*\n<${logStreamUrl}|${command.logStream}>`,
},
],
},
],
},
...errorBlocks,
],
});
return Result.ok(true);
}
}

Qual é o próximo?
Existem mais melhorias que acho que podem nos ajudar a avançar em nosso monitoramento, como:
- Adicionar mais métricas
- Anotar rastreamentos com mais dados
- Anotar logs com mais dados
- Adicione mais lógica ao gerenciador de logs de erro para enviar apenas novos erros para o Slack
Se você quiser saber mais sobre nossa arquitetura, este repositório descreve muitos dos padrões que usamos.
Pretendo escrever mais, então siga-me no Twitter enquanto continuo a postar sobre nossos próximos passos e outras soluções que tivemos que encontrar.