Cómo DevStride está pasando de Datadog a AWS X-Ray y Cloudwatch para la observabilidad

Dec 02 2022
Una forma más rentable de monitorear su aplicación
DevStride tiene más de 200 funciones lambda que debemos asegurarnos de que se ejecuten como se espera y funcionen correctamente. Necesitamos una herramienta que nos brinde una descripción general de cómo está funcionando nuestra infraestructura y evaluar el estado general del sistema.

DevStride tiene más de 200 funciones lambda que debemos asegurarnos de que se ejecuten como se espera y funcionen correctamente. Necesitamos una herramienta que nos brinde una descripción general de cómo está funcionando nuestra infraestructura y evaluar el estado general del sistema.

¿Qué necesitamos?

Nuestro caso de uso es muy simple y principalmente necesitamos responder un par de preguntas:

  • ¿Cuál es nuestra latencia general?
  • ¿Cuál es nuestro uso?
  • ¿Tenemos algún error en la producción?
  • Recibe alertas en Slack para cualquier problema nuevo que aparezca.
  • Facilite el rastreo de errores y encuentre la causa raíz.
  • Correlacione todos los registros desde el principio hasta el final de la ejecución (en todas las funciones).

Datadog es un gran servicio con muchas características, muchas más de las que necesitamos actualmente. Utilizamos principalmente APM, Logs y RUM.

Aunque Datadog proporciona una excelente manera de lograr lo que necesitamos, el costo es la razón principal por la que nos mudamos. En particular, el costo de APM sin servidor que, a partir de ahora, es de $ 6 / mes por función + costos adicionales según el uso.

¿Cómo reemplazamos a Datadog?

Como se mencionó anteriormente, DevStride utiliza una infraestructura sin servidor y tenemos más de 200 funciones lambda que debemos implementar y realizar un seguimiento.

En DevStride, usamos SST , que es una abstracción de AWS CDK . Para nuestras definiciones de infraestructura y código de función, usamos TypeScript.

Queremos reemplazar nuestro agente Datadog actual con AWS X-Ray y CloudWatch y eso requiere un par de cambios en nuestro código de funciones e infraestructura.

Funciones de configuración

Para que nuestra transición sea lo más simple posible, decidimos utilizar las nuevas PowerTools de AWS Lambda . Este paquete simplifica parte de la configuración y es muy útil.

De las herramientas eléctricas obtenemos:

  • El trazador que podemos usar para anotar huellas
  • El registrador para anotar registros con nuestros rastros y parámetros útiles
  • El middleware para anotar trazas/registros automáticamente
  • Asistentes para agregar métricas personalizadas

Creamos el rastreador y el registrador y exponemos la funcionalidad que podemos usar en nuestra aplicación.

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;
}

En DevStride, usamos middy para nuestro middleware y hacemos abstracción con clases. Este es un ejemplo del controlador lambda base que se extienden todos los demás controladores.

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>;
}

Infraestructura de configuración

Queremos que la configuración de funciones sea lo más simple posible, por lo que agregamos las configuraciones necesarias a las variables de entorno. Para poder hacer eso rápidamente, creamos una utilityfunción que hace eso para todas nuestras funciones. Además, queremos poder escuchar todos los registros, de modo que podamos enviar alertas a Slack cuando encontremos registros de errores.

Primero, configuramos la pila que implementará nuestra función lambda y escuchará nuestros registros.

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");
}

También añadimos retención de registros predeterminada y configuración de seguimiento a todas nuestras funciones.

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),
  },
})

Ejemplo de mapa de servicio

Además de eso, verá todos los detalles de los rastros y también la lista de rastros.

Con el código anterior, tenemos lo que necesitamos para monitorear nuestros rastros y el uso general. También podemos saber si algo anda mal; traces mostrará todos los errores y dónde ocurren. Además de eso, las trazas agruparán los registros.

Envío de alertas a Slack

Uno de nuestros requisitos era que necesitábamos ser notificados sobre los errores y encontrar una manera fácil de ver el seguimiento/los registros de errores.

Decidimos que la forma más fácil de hacerlo para nosotros es enviar registros de errores a Slack.

En DevStride, usamos CQRS y todos nuestros controladores llaman comandos/consultas que ejecutan la lógica empresarial.

Aquí está nuestro controlador de registros de errores:

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);
  }
}

¿Que sigue?

Hay más mejoras que creo que podrían ayudarnos a avanzar en nuestro monitoreo, como:

  • Agregar más métricas
  • Anotar trazos con más datos
  • Anotar registros con más datos
  • Agregue más lógica al controlador de registros de errores para enviar solo nuevos errores a Slack

Si desea saber más sobre nuestra arquitectura, este repositorio describe muchos de los patrones que usamos.

Planeo escribir más, así que síganme en Twitter mientras sigo publicando sobre nuestros próximos pasos y otras soluciones que hemos tenido que encontrar.