วิธีที่ DevStride ย้ายจาก Datadog ไปยัง AWS X-Ray และ Cloudwatch เพื่อความสามารถในการสังเกตการณ์

Dec 02 2022
วิธีที่คุ้มค่ากว่าในการตรวจสอบแอปของคุณ
DevStride มีฟังก์ชันแลมบ์ดามากกว่า 200 ฟังก์ชันที่เราต้องการเพื่อให้แน่ใจว่าทำงานตามที่คาดไว้และทำงานอย่างถูกต้อง เราต้องการเครื่องมือเพื่อให้ภาพรวมว่าโครงสร้างพื้นฐานของเราทำงานเป็นอย่างไร และประเมินความสมบูรณ์ของระบบโดยรวม

DevStrideมีฟังก์ชันแลมบ์ดามากกว่า 200 ฟังก์ชันที่เราต้องการเพื่อให้แน่ใจว่าทำงานตามที่คาดไว้และทำงานอย่างถูกต้อง เราต้องการเครื่องมือเพื่อให้ภาพรวมว่าโครงสร้างพื้นฐานของเราทำงานเป็นอย่างไร และประเมินความสมบูรณ์ของระบบโดยรวม

เราต้องการอะไร?

กรณีการใช้งานของเรานั้นง่ายมาก และเราต้องตอบคำถามสองสามข้อเป็นหลัก:

  • เวลาแฝงโดยรวมของเราคืออะไร?
  • การใช้งานของเราคืออะไร?
  • เรามีข้อผิดพลาดในการผลิตหรือไม่?
  • รับการแจ้งเตือนใน Slack สำหรับปัญหาใหม่ๆ ที่ปรากฏ
  • ทำให้ง่ายต่อการติดตามข้อผิดพลาดและค้นหาสาเหตุที่แท้จริง
  • เชื่อมโยงบันทึกทั้งหมดตั้งแต่เริ่มต้นจนถึงสิ้นสุดการดำเนินการ (ในทุกฟังก์ชัน)

Datadogเป็นบริการที่ยอดเยี่ยมพร้อมคุณสมบัติมากมาย มากกว่าที่เราต้องการในปัจจุบันอย่างมาก เราใช้ APM, Logs และ RUM เป็นหลัก

แม้ว่า Datadog จะมอบวิธีที่ยอดเยี่ยมในการบรรลุสิ่งที่เราต้องการ แต่ค่าใช้จ่ายคือเหตุผลหลักที่เรากำลังดำเนินการ โดยเฉพาะอย่างยิ่ง ค่าใช้จ่ายสำหรับAPM สำหรับเซิร์ฟเวอร์แบบไร้เซิร์ฟเวอร์ซึ่ง ณ ตอนนี้คือ$6 /เดือนต่อฟังก์ชัน + ค่าใช้จ่ายเพิ่มเติมตามการใช้งาน

เราจะแทนที่ Datadog อย่างไร

ตามที่กล่าวไว้ก่อนหน้านี้DevStrideใช้โครงสร้างพื้นฐานแบบไร้เซิร์ฟเวอร์ และเรามีฟังก์ชันแลมบ์ดามากกว่า 200 ฟังก์ชันที่เราต้องปรับใช้และติดตาม

ที่ DevStride เราใช้SSTซึ่งเป็นนามธรรมของAWS CDK สำหรับคำจำกัดความของโครงสร้างพื้นฐานและรหัสฟังก์ชัน เราใช้ TypeScript

เราต้องการแทนที่ตัวแทน Datadog ปัจจุบันของเราด้วยAWS X-RayและCloudWatchซึ่งจำเป็นต้องมีการเปลี่ยนแปลงสองสามอย่างในโค้ดฟังก์ชันและโครงสร้างพื้นฐานของเรา

ฟังก์ชั่นการตั้งค่า

เพื่อให้การเปลี่ยนแปลงของเราง่ายที่สุดเท่าที่จะเป็นไปได้ เราตัดสินใจใช้AWS Lambda PowerToolsใหม่ แพ็คเกจนี้ทำให้การตั้งค่าบางอย่างง่ายขึ้นและมีประโยชน์มาก

จากเครื่องมือไฟฟ้าที่เราได้รับ:

  • ตัวติดตามที่เราสามารถใช้เพื่ออธิบายร่องรอย
  • คนตัดไม้ที่จะใส่คำอธิบายประกอบในบันทึกด้วยร่องรอยและพารามิเตอร์ที่เป็นประโยชน์ของเรา
  • มิดเดิลแวร์ที่จะใส่คำอธิบายประกอบการติดตาม/บันทึกโดยอัตโนมัติ
  • ผู้ช่วยในการเพิ่มเมตริกที่กำหนดเอง

เราสร้างตัวติดตามและตัวบันทึกและเปิดเผยฟังก์ชันที่เราสามารถใช้ในแอปของเรา

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

ใน DevStride เราใช้middyสำหรับมิดเดิลแวร์ของเรา และเรานามธรรมด้วยคลาส นี่คือตัวอย่างของตัวจัดการแลมบ์ดาฐานที่ตัวจัดการอื่น ๆ ทั้งหมดขยาย

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

ตั้งค่าโครงสร้างพื้นฐาน

เราต้องการทำให้มันง่ายที่สุดในการตั้งค่าฟังก์ชัน ดังนั้นเราจึงเพิ่มการกำหนดค่าที่จำเป็นให้กับตัวแปรสภาพแวดล้อม เพื่อให้สามารถดำเนินการได้อย่างรวดเร็ว เราได้สร้างutilityฟังก์ชันที่ทำสิ่งนั้นสำหรับทุกฟังก์ชันของเรา นอกจากนี้ เราต้องการฟังบันทึกทั้งหมด ดังนั้นเราจึงสามารถส่งการแจ้งเตือนไปยัง Slack เมื่อเราพบบันทึกข้อผิดพลาด

ขั้นแรก เราตั้งค่าสแต็กที่จะใช้ฟังก์ชันแลมบ์ดาของเราและฟังบันทึกของเรา

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

นอกจากนี้ เรายังเพิ่มการเก็บรักษาบันทึกเริ่มต้นและการกำหนดค่าการติดตามให้กับฟังก์ชันทั้งหมดของเรา

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

ตัวอย่างแผนที่บริการ

นอกจากนั้น คุณจะเห็นรายละเอียดทั้งหมดของการติดตามและรายการติดตาม

ด้วยโค้ดข้างต้น เรามีสิ่งที่จำเป็นในการตรวจสอบร่องรอยและการใช้งานโดยรวมของเรา เราสามารถรู้ได้ด้วยว่ามีบางอย่างผิดปกติเกิดขึ้น การติดตามจะแสดงข้อผิดพลาดทั้งหมดและตำแหน่งที่เกิดขึ้น นอกจากนั้น การติดตามจะจัดกลุ่มบันทึกเข้าด้วยกัน

กำลังส่งการแจ้งเตือนไปยัง Slack

ข้อกำหนดข้อหนึ่งของเราคือเราต้องได้รับแจ้งเกี่ยวกับข้อผิดพลาดและหาวิธีง่ายๆ ในการดูการติดตาม/บันทึกข้อผิดพลาด

เราตัดสินใจว่าวิธีที่ง่ายที่สุดในการทำเช่นนั้นสำหรับเราคือการส่งบันทึกข้อผิดพลาดไปยัง Slack

ที่ DevStride เราใช้CQRSและตัวจัดการทั้งหมดของเราเรียกคำสั่ง/แบบสอบถามที่ดำเนินการตามตรรกะทางธุรกิจ

นี่คือตัวจัดการบันทึกข้อผิดพลาดของเรา:

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

อะไรต่อไป?

มีการปรับปรุงเพิ่มเติมที่ฉันคิดว่าสามารถช่วยให้เราก้าวหน้าในการตรวจสอบ เช่น:

  • เพิ่มเมตริกเพิ่มเติม
  • ใส่คำอธิบายประกอบร่องรอยด้วยข้อมูลเพิ่มเติม
  • ใส่คำอธิบายประกอบในบันทึกด้วยข้อมูลเพิ่มเติม
  • เพิ่มตรรกะให้กับตัวจัดการบันทึกข้อผิดพลาดเพื่อส่งเฉพาะข้อผิดพลาดใหม่ไปยัง Slack

หากคุณต้องการทราบข้อมูลเพิ่มเติมเกี่ยวกับสถาปัตยกรรมของเรา repo นี้จะอธิบายรูปแบบต่างๆ มากมายที่เราใช้

ฉันวางแผนที่จะเขียนเพิ่มเติมดังนั้นโปรดติดตามฉันบน Twitterขณะที่ฉันยังคงโพสต์เกี่ยวกับขั้นตอนถัดไปและวิธีแก้ปัญหาอื่นๆ ที่เราต้องดำเนินการ