วิธีที่ DevStride ย้ายจาก Datadog ไปยัง AWS X-Ray และ Cloudwatch เพื่อความสามารถในการสังเกตการณ์
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ขณะที่ฉันยังคงโพสต์เกี่ยวกับขั้นตอนถัดไปและวิธีแก้ปัญหาอื่นๆ ที่เราต้องดำเนินการ