Comment DevStride passe de Datadog à AWS X-Ray et Cloudwatch pour l'observabilité
DevStride a plus de 200 fonctions lambda dont nous avons besoin pour nous assurer qu'elles fonctionnent comme prévu et fonctionnent correctement. Nous avons besoin d'un outil pour nous donner un aperçu de l'état de notre infrastructure et évaluer la santé globale du système.
De quoi avons nous besoin?
Notre cas d'utilisation est très simple et nous devons principalement répondre à quelques questions :
- Quelle est notre latence globale ?
- Quelle est notre utilisation ?
- Avons-nous des erreurs de production ?
- Recevez des alertes dans Slack pour tout nouveau problème qui apparaît.
- Facilitez le traçage des erreurs et trouvez la cause première.
- Corrélez tous les journaux du début à la fin de l'exécution (sur toutes les fonctions).
Datadog est un excellent service avec de nombreuses fonctionnalités - bien plus que ce dont nous avons actuellement besoin. Nous avons principalement utilisé APM, Logs et RUM.
Même si Datadog fournit un excellent moyen d'accomplir ce dont nous avons besoin, le coût est la principale raison pour laquelle nous déménageons. En particulier, le coût de l'APM pour le sans serveur qui, à l'heure actuelle, est de 6 $ /mois par fonction + des coûts supplémentaires en fonction de l'utilisation.
Comment remplaçons-nous Datadog ?
Comme mentionné précédemment, DevStride utilise une infrastructure sans serveur et nous avons plus de 200 fonctions lambda que nous devons déployer et suivre.
Chez DevStride, nous utilisons SST qui est une abstraction du AWS CDK . Pour nos définitions d'infrastructure et notre code de fonction, nous utilisons TypeScript.
Nous souhaitons remplacer notre agent Datadog actuel par AWS X-Ray et CloudWatch , ce qui nécessite quelques modifications dans notre code de fonction et notre infrastructure.
Fonctions de configuration
Pour rendre notre transition aussi simple que possible, nous avons décidé d'utiliser les nouveaux AWS Lambda PowerTools . Ce package simplifie une partie de la configuration et est très utile.
À partir des outils électriques, nous obtenons :
- Le traceur que nous pouvons utiliser pour annoter les traces
- Le logger pour annoter les logs avec nos traces et paramètres utiles
- Le middleware pour annoter automatiquement les traces/logs
- Aides pour l'ajout de métriques personnalisées
Nous créons le traceur et l'enregistreur et exposons les fonctionnalités que nous pouvons utiliser dans notre application.
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;
}
Dans DevStride, nous utilisons middy pour notre middleware et nous abstrait avec des classes. Ceci est un exemple du gestionnaire lambda de base que tous les autres gestionnaires étendent.
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>;
}
Configurer l'infrastructure
Nous voulons simplifier au maximum la configuration des fonctions, nous avons donc ajouté les configurations nécessaires aux variables d'environnement. Pour pouvoir le faire rapidement, nous avons créé une utility
fonction qui le fait pour toutes nos fonctions. De plus, nous voulons pouvoir écouter tous les journaux, afin de pouvoir envoyer des alertes à Slack lorsque nous trouvons des journaux d'erreurs.
Tout d'abord, nous mettons en place la pile qui va déployer notre fonction lambda et écouter nos 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");
}
Nous ajoutons également une configuration de conservation et de traçage des journaux par défaut à toutes nos fonctions.
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),
},
})

En plus de cela, vous verrez tous les détails des traces ainsi que la liste des traces.

Avec le code ci-dessus, nous avons ce dont nous avons besoin pour surveiller nos traces et notre utilisation globale. Nous pouvons également savoir si quelque chose ne va pas; les traces montreront toutes les erreurs et où elles se produisent. En plus de cela, les traces regrouperont les journaux.
Envoi d'alertes à Slack
L'une de nos exigences était que nous devions être informés des erreurs et trouver un moyen simple de consulter la trace/les journaux d'erreurs.
Nous avons décidé que le moyen le plus simple de le faire pour nous était d'envoyer les journaux d'erreurs à Slack.
Chez DevStride, nous utilisons CQRS et tous nos gestionnaires appellent des commandes/requêtes qui exécutent la logique métier.
Voici notre gestionnaire de journaux d'erreur :
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);
}
}

Et après?
Il y a d'autres améliorations qui, je pense, pourraient nous aider à faire progresser notre surveillance, telles que :
- Ajouter plus de métriques
- Annotez les traces avec plus de données
- Annotez les journaux avec plus de données
- Ajoutez plus de logique au gestionnaire de journaux d'erreurs pour envoyer uniquement les nouvelles erreurs à Slack
Si vous souhaitez en savoir plus sur notre architecture, ce référentiel décrit de nombreux modèles que nous utilisons.
Je prévois d'écrire plus, alors suivez-moi sur Twitter pendant que je continue à publier sur nos prochaines étapes et d'autres solutions que nous avons dû trouver.