Skip to content
Ayhan Sipahi Ayhan Sipahi

AWS Lambda Production Monitoring and Debugging: Proven Strategies

Instrument AWS Lambda for production: CloudWatch custom metrics, X-Ray tracing, structured logging, and alerts that track business impact.

Lambda functions fail silently in production when default CloudWatch metrics, cold-start noise, and missing distributed traces combine to obscure the root cause. Without a structured observability strategy, debugging shifts from data-driven analysis to guesswork under pressure. The setup that holds for most Lambda workloads is small: custom metrics keyed to business outcomes, X-Ray active tracing, and JSON logs carrying the request ID on every line.

Those three signals do different jobs. Metrics tell you that something broke, traces tell you where, and logs tell you why. Drop one and the other two leave a gap that surfaces at the worst moment: a payment handler that times out without logging an error, or a retry storm that never trips an alarm.

The Three Pillars of Lambda Observability

1. Metrics: The Early Warning System

Essential Metrics You Must Monitor:

// Custom metrics for Lambda observability
// Compatible with Node.js 20.x and 22.x runtimes
import { CloudWatch } from '@aws-sdk/client-cloudwatch';

const cloudwatch = new CloudWatch({});

export const publishCustomMetrics = async (
  functionName: string,
  duration: number,
  success: boolean,
  businessContext?: { userId?: string, feature?: string }
) => {
  const metrics = [
    {
      MetricName: 'FunctionDuration',
      Value: duration,
      Unit: 'Milliseconds',
      Dimensions: [
        { Name: 'FunctionName', Value: functionName },
        { Name: 'Feature', Value: businessContext?.feature || 'unknown' }
      ]
    },
    {
      MetricName: success ? 'FunctionSuccess' : 'FunctionFailure',
      Value: 1,
      Unit: 'Count',
      Dimensions: [
        { Name: 'FunctionName', Value: functionName }
      ]
    }
  ];

  // Business-specific metrics. Keep userId out of the dimensions:
  // every distinct dimension combination is billed as its own custom
  // metric, so a user ID turns one metric into one metric per user.
  if (businessContext?.userId) {
    metrics.push({
      MetricName: 'UserAction',
      Value: 1,
      Unit: 'Count',
      Dimensions: [
        { Name: 'FunctionName', Value: functionName },
        { Name: 'ActionType', Value: success ? 'completed' : 'failed' }
      ]
    });
  }

  await cloudwatch.putMetricData({
    Namespace: 'Lambda/Business',
    MetricData: metrics
  });
};

2. Traces: The Request Flow

X-Ray shows the full request path, including calls to services you never instrumented yourself:

import AWSXRay from 'aws-xray-sdk-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Instrument AWS SDK v3
const dynamoClient = AWSXRay.captureAWSv3Client(new DynamoDBClient({}));
const dynamoDB = DynamoDBDocumentClient.from(dynamoClient);

// With active tracing enabled, Lambda opens the segment before the handler
// runs. captureAsyncFunc executes its callback immediately, so it belongs
// inside the handler around a specific call, never wrapped around the export.
export const handler = async (event: any) => {
  // Add custom annotations for filtering
  const segment = AWSXRay.getSegment();
  segment?.addAnnotation('userId', event.userId);
  segment?.addAnnotation('paymentMethod', event.paymentMethod);
  segment?.addAnnotation('environment', process.env.STAGE);

  // Trace external API calls
  const subsegment = segment?.addNewSubsegment('payment-provider-api');

  try {
    const paymentResult = await processPayment(event);
    subsegment?.close();

    // Add business metadata
    segment?.addMetadata('payment', {
      amount: event.amount,
      currency: event.currency,
      processingTime: Date.now() - event.timestamp
    });

    return { success: true, paymentId: paymentResult.id };
  } catch (error) {
    // Closing with the error keeps the subsegment from hanging open
    subsegment?.close(error as Error);
    segment?.addError(error as Error);
    segment?.addMetadata('errorContext', {
      userId: event.userId,
      errorType: (error as Error).name,
      requestId: event.requestId
    });
    throw error;
  }
};

3. Logs: The Historical Record

Structured Logging Pattern That Works:

import { createLogger, format, transports } from 'winston';

const logger = createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: format.combine(
    format.timestamp(),
    format.errors({ stack: true }),
    format.json()
  ),
  transports: [
    new transports.Console()
  ]
});

// Lambda context-aware logging
export const createContextLogger = (context: any, event: any) => {
  const requestId = context.awsRequestId;
  const functionName = context.functionName;
  
  return {
    info: (message: string, meta?: any) => logger.info({
      message,
      requestId,
      functionName,
      stage: process.env.STAGE,
      ...meta
    }),
    
    error: (message: string, error?: Error, meta?: any) => logger.error({
      message,
      error: error?.stack || error?.message,
      requestId,
      functionName,
      stage: process.env.STAGE,
      ...meta
    }),
    
    // Business event logging
    business: (event: string, data: any) => logger.info({
      message: `Business Event: ${event}`,
      businessEvent: event,
      data,
      requestId,
      functionName,
      timestamp: new Date().toISOString()
    })
  };
};

// Usage in handler
export const handler = async (event: any, context: any) => {
  const log = createContextLogger(context, event);
  
  log.info('Function invoked', { eventType: event.Records?.[0]?.eventName });
  
  try {
    const result = await processEvent(event);
    log.business('order-processed', { orderId: result.orderId, amount: result.amount });
    return result;
  } catch (error) {
    log.error('Processing failed', error as Error, { eventData: event });
    throw error;
  }
};

CloudWatch Dashboard Setup

Business Dashboard for Stakeholder Communication

When stakeholders need visibility into system health, showing business-focused metrics proves more valuable than technical details:

# CloudFormation template for business-focused dashboard
Resources:
  BusinessDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: "Lambda-Business-Health"
      DashboardBody: !Sub |
        {
          "widgets": [
            {
              "type": "metric",
              "properties": {
                "metrics": [
                  ["Lambda/Business", "OrdersProcessed", "FunctionName", "order-processor"],
                  ["Lambda/Business", "PaymentsCompleted", "FunctionName", "payment-processor"],
                  ["Lambda/Business", "UserRegistrations", "FunctionName", "user-registration"]
                ],
                "period": 300,
                "stat": "Sum",
                "region": "${AWS::Region}",
                "title": "Business Transactions (Last 24h)"
              }
            },
            {
              "type": "metric",
              "properties": {
                "metrics": [
                  ["AWS/Lambda", "Errors", "FunctionName", "order-processor"],
                  ["AWS/Lambda", "Throttles", "FunctionName", "payment-processor"]
                ],
                "period": 300,
                "stat": "Sum",
                "region": "${AWS::Region}",
                "title": "System Health Issues"
              }
            }
          ]
        }

Technical Dashboard for Debugging

  TechnicalDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: "Lambda-Technical-Deep-Dive"
      DashboardBody: !Sub |
        {
          "widgets": [
            {
              "type": "metric",
              "properties": {
                "metrics": [
                  ["AWS/Lambda", "Duration", "FunctionName", "payment-processor", { "stat": "Average" }],
                  ["AWS/Lambda", "Duration", "FunctionName", "payment-processor", { "stat": "p99" }]
                ],
                "period": 60,
                "region": "${AWS::Region}",
                "title": "Function Duration (Average vs P99)"
              }
            },
            {
              "type": "log",
              "properties": {
                "query": "SOURCE '/aws/lambda/payment-processor'\n| fields @timestamp, @message, @requestId\n| filter @message like /ERROR/\n| sort @timestamp desc\n| limit 100",
                "region": "${AWS::Region}",
                "title": "Recent Errors (Last 1 Hour)"
              }
            }
          ]
        }

Alerting Strategies

Business-Impact Based Alerts

Alert on business impact, not on everything:

# CloudFormation alert configuration
Resources:
  # Critical: Payment processing failures
  PaymentFailureAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: "Lambda-PaymentProcessor-CriticalFailures"
      AlarmDescription: "Payment processing failures above threshold"
      MetricName: Errors
      Namespace: AWS/Lambda
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 2
      Threshold: 5  # More than 5 errors in 10 minutes
      ComparisonOperator: GreaterThanThreshold
      Dimensions:
        - Name: FunctionName
          Value: !Ref PaymentProcessorFunction
      AlarmActions:
        - !Ref CriticalAlertTopic
      TreatMissingData: notBreaching

  # Warning: Slower than usual processing
  PaymentLatencyAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: "Lambda-PaymentProcessor-HighLatency"
      MetricName: Duration
      Namespace: AWS/Lambda
      Statistic: Average
      Period: 300
      EvaluationPeriods: 3
      Threshold: 5000  # 5 seconds average
      ComparisonOperator: GreaterThanThreshold
      AlarmActions:
        - !Ref WarningAlertTopic

  # Composite alarm for overall system health
  SystemHealthAlarm:
    Type: AWS::CloudWatch::CompositeAlarm
    Properties:
      AlarmName: "Lambda-SystemHealth-Critical"
      AlarmRule: !Sub |
        ALARM("${PaymentFailureAlarm}") OR 
        ALARM("${OrderProcessingAlarm}") OR
        ALARM("${DatabaseConnectionAlarm}")
      AlarmActions:
        - !Ref EmergencyAlertTopic

Near-Timeout Detection

Timeouts surface as Duration spikes long before they surface as Errors. This metric fires while the invocation still has enough budget left to report it:

export const detectNearTimeout = async (functionName: string, context: any) => {
  const remainingTime = context.getRemainingTimeInMillis();

  // Under a second of budget left: the next slow dependency call ends the invocation
  if (remainingTime < 1000) {
    await cloudwatch.putMetricData({
      Namespace: 'Lambda/Performance',
      MetricData: [{
        MetricName: 'NearTimeout',
        Value: 1,
        Unit: 'Count',
        // remainingTime stays out of the dimensions; as a millisecond value
        // it would create a new custom metric on almost every invocation
        Dimensions: [
          { Name: 'FunctionName', Value: functionName }
        ]
      }]
    });
  }
};

Error Handling and Dead Letter Queues

Strategic Error Handling

// Error categorization for better debugging
export enum ErrorCategory {
  TRANSIENT = 'TRANSIENT',  // Retry makes sense
  CLIENT_ERROR = 'CLIENT_ERROR', // User input issue
  SYSTEM_ERROR = 'SYSTEM_ERROR', // Infrastructure problem
  BUSINESS_ERROR = 'BUSINESS_ERROR' // Business logic violation
}

export class CategorizedError extends Error {
  constructor(
    message: string,
    public category: ErrorCategory,
    public retryable: boolean = false,
    public context?: any
  ) {
    super(message);
    this.name = 'CategorizedError';
  }
}

export const handleError = async (error: Error, event: any, context: any) => {
  const log = createContextLogger(context, event);
  
  if (error instanceof CategorizedError) {
    // Handle categorized errors
    switch (error.category) {
      case ErrorCategory.TRANSIENT:
        log.info('Transient error - will retry', { 
          error: error.message, 
          retryable: error.retryable 
        });
        throw error; // Let Lambda retry mechanism handle
        
      case ErrorCategory.CLIENT_ERROR:
        log.info('Client error - no retry needed', { error: error.message });
        return { 
          statusCode: 400, 
          body: JSON.stringify({ error: 'Invalid request' })
        };
        
      case ErrorCategory.SYSTEM_ERROR:
        log.error('System error detected', error, { 
          requiresInvestigation: true 
        });
        // Send to DLQ for investigation
        throw error;
        
      case ErrorCategory.BUSINESS_ERROR:
        log.business('business-rule-violation', {
          rule: error.message,
          context: error.context
        });
        return {
          statusCode: 422,
          body: JSON.stringify({ error: error.message })
        };
    }
  } else {
    // Unknown error - treat as system error
    log.error('Uncategorized error', error);
    throw new CategorizedError(
      error.message,
      ErrorCategory.SYSTEM_ERROR,
      false,
      { originalError: error.stack }
    );
  }
};

Dead Letter Queue Analysis

// DLQ processor for error pattern analysis
export const dlqProcessor = async (event: any, context: any) => {
  const log = createContextLogger(context, event);
  
  for (const record of event.Records) {
    try {
      const failedEvent = JSON.parse(record.body);
      const errorInfo = {
        functionName: record.eventSourceARN?.split(':')[6],
        errorCount: record.attributes?.ApproximateReceiveCount || '1',
        // Lambda attaches RequestID, ErrorCode and ErrorMessage as SQS
        // message attributes when it writes a failed async event to the DLQ
        errorCode: record.messageAttributes?.ErrorCode?.stringValue || 'unknown',
        failureReason: record.messageAttributes?.ErrorMessage?.stringValue || 'unknown',
        originalTimestamp: failedEvent.timestamp,
        retryCount: parseInt(record.attributes?.ApproximateReceiveCount || '0')
      };
      
      // Pattern detection
      if (errorInfo.retryCount > 3) {
        log.business('recurring-failure-pattern', {
          pattern: 'high-retry-count',
          functionName: errorInfo.functionName,
          suggestion: 'investigate-configuration'
        });
      }
      
      // Store for analysis
      await storeErrorPattern(errorInfo, failedEvent);
      
    } catch (processingError) {
      log.error('Failed to process DLQ record', processingError as Error);
    }
  }
};

Advanced Debugging Techniques

Lambda Function URL Debugging

// Debug endpoint for production troubleshooting
export const debugHandler = async (event: any, context: any) => {
  // Only allow in non-production or with special header
  const allowDebug = process.env.STAGE !== 'prod' || 
                     event.headers?.['x-debug-token'] === process.env.DEBUG_TOKEN;
  
  if (!allowDebug) {
    return { statusCode: 403, body: 'Debug access denied' };
  }
  
  const debugInfo = {
    environment: {
      stage: process.env.STAGE,
      region: context.invokedFunctionArn.split(':')[3],
      memorySize: context.memoryLimitInMB,
      remainingTimeMs: context.getRemainingTimeInMillis()
    },
    runtime: {
      nodeVersion: process.version,
      platform: process.platform,
      uptime: process.uptime()
    },
    lastErrors: await getRecentErrors(context.functionName),
    healthChecks: {
      database: await checkDatabaseConnection(),
      externalAPI: await checkExternalServices(),
      memory: process.memoryUsage()
    }
  };
  
  return {
    statusCode: 200,
    body: JSON.stringify(debugInfo, null, 2)
  };
};

Performance Profiling in Production

// Safe production profiling
export const profileHandler = (originalHandler: Function) => {
  return async (event: any, context: any) => {
    const shouldProfile = Math.random() < 0.01; // Profile 1% of requests
    
    if (!shouldProfile) {
      return originalHandler(event, context);
    }
    
    const startTime = Date.now();
    const startMemory = process.memoryUsage();
    
    try {
      const result = await originalHandler(event, context);
      
      const endTime = Date.now();
      const endMemory = process.memoryUsage();
      
      // Send profiling data
      await cloudwatch.putMetricData({
        Namespace: 'Lambda/Profiling',
        MetricData: [
          {
            MetricName: 'ExecutionDuration',
            Value: endTime - startTime,
            Unit: 'Milliseconds'
          },
          {
            MetricName: 'MemoryUsed',
            Value: endMemory.heapUsed - startMemory.heapUsed,
            Unit: 'Bytes'
          }
        ]
      });
      
      return result;
    } catch (error) {
      // Profile error scenarios too
      const errorTime = Date.now();
      await cloudwatch.putMetricData({
        Namespace: 'Lambda/Profiling',
        MetricData: [{
          MetricName: 'ErrorDuration',
          Value: errorTime - startTime,
          Unit: 'Milliseconds'
        }]
      });
      throw error;
    }
  };
};

Troubleshooting Workflows

The 5-Minute Debug Protocol

When things go wrong during peak traffic, you need a systematic approach:

// Emergency debug checklist
export const emergencyDebugChecklist = {
  step1_quickHealth: async (functionName: string) => {
    const metrics = await cloudwatch.getMetricStatistics({
      Namespace: 'AWS/Lambda',
      MetricName: 'Errors',
      Dimensions: [{ Name: 'FunctionName', Value: functionName }],
      StartTime: new Date(Date.now() - 10 * 60 * 1000), // Last 10 minutes
      EndTime: new Date(),
      Period: 300,
      Statistics: ['Sum']
    });
    
    return {
      recentErrors: metrics.Datapoints?.reduce((sum, dp) => sum + (dp.Sum || 0), 0),
      timeframe: 'last-10-minutes'
    };
  },
  
  step2_checkDependencies: async () => {
    return {
      database: await checkDatabaseConnection(),
      externalAPIs: await checkExternalServices(),
      downstream: await checkDownstreamServices()
    };
  },
  
  step3_analyzeLogs: async (functionName: string) => {
    // CloudWatch Logs Insights query for recent errors
    const query = `
      fields @timestamp, @message, @requestId
      | filter @message like /ERROR/ or @message like /TIMEOUT/
      | sort @timestamp desc
      | limit 20
    `;
    
    // Implementation would use CloudWatch Logs API
    return { recentErrorPatterns: 'implementation-needed' };
  }
};

Memory Leak Detection

// Detect memory leaks in long-running Lambda containers
let requestCount = 0;
const memorySnapshots: Array<{ count: number; memory: NodeJS.MemoryUsage }> = [];

export const memoryTrackingWrapper = (handler: Function) => {
  return async (event: any, context: any) => {
    requestCount++;
    
    const beforeMemory = process.memoryUsage();
    const result = await handler(event, context);
    const afterMemory = process.memoryUsage();
    
    // Track memory growth over requests
    if (requestCount % 10 === 0) {
      memorySnapshots.push({ count: requestCount, memory: afterMemory });
      // Cap the buffer so the leak detector does not leak
      if (memorySnapshots.length > 20) memorySnapshots.shift();
      
      if (memorySnapshots.length > 10) {
        const oldSnapshot = memorySnapshots[memorySnapshots.length - 10];
        const currentSnapshot = memorySnapshots[memorySnapshots.length - 1];
        
        const heapGrowth = currentSnapshot.memory.heapUsed - oldSnapshot.memory.heapUsed;
        
        if (heapGrowth > 50 * 1024 * 1024) { // 50MB growth
          await cloudwatch.putMetricData({
            Namespace: 'Lambda/MemoryLeak',
            MetricData: [{
              MetricName: 'SuspectedMemoryLeak',
              Value: heapGrowth,
              Unit: 'Bytes',
              Dimensions: [
                { Name: 'FunctionName', Value: context.functionName }
              ]
            }]
          });
        }
      }
    }
    
    return result;
  };
};

Cost-Conscious Monitoring

Sampling Strategy for High-Volume Functions

// Intelligent sampling based on business value
export const createSampler = (baseSampleRate: number = 0.01) => {
  return (event: any): boolean => {
    // Always sample errors
    if (event.errorType) return true;
    
    // Always sample high-value transactions
    if (event.transactionValue > 1000) return true;
    
    // Sample new users more frequently
    if (event.userType === 'new') return Math.random() < baseSampleRate * 5;
    
    // Regular sampling
    return Math.random() < baseSampleRate;
  };
};

const sampler = createSampler(0.005); // 0.5% base rate

export const handler = async (event: any, context: any) => {
  const shouldMonitor = sampler(event);
  
  if (shouldMonitor) {
    // Full monitoring and tracing
    return AWSXRay.captureAsyncFunc('handler', async () => {
      return processWithFullLogging(event, context);
    });
  } else {
    // Minimal monitoring
    return processWithBasicLogging(event, context);
  }
};

Log Retention Strategy

# Different retention periods based on log importance
Resources:
  BusinessLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${BusinessProcessorFunction}"
      RetentionInDays: 90  # Keep business logs longer
      
  DebugLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${UtilityFunction}"
      RetentionInDays: 7  # Debug logs can be shorter

What’s Next: Advanced Patterns and Cost Optimization

The final part of this series covers advanced Lambda patterns that reduce both complexity and cost:

  • Multi-tenant architecture patterns
  • Event-driven cost optimization
  • Advanced deployment strategies
  • Performance vs cost trade-offs

When This Setup Is Enough

Custom business metrics, active X-Ray tracing, and JSON logs answer what broke, where, and why for most Lambda workloads, with no third-party agent to run. Two situations justify more. When a request crosses many services and X-Ray sampling keeps dropping the trace you need, move to OpenTelemetry through the ADOT Lambda layer and send spans to a backend that keeps all of them. When the synchronous putMetricData call starts showing up in your function duration, switch to Embedded Metric Format, where the metric rides inside a log line you are already writing and nothing blocks the invocation. Custom metric charges are the same either way, so cardinality discipline still applies. Start with the three signals and add either of these only when a specific question keeps going unanswered.

References

AWS Lambda Production Guide: 5 Years of Real-World Experience

A comprehensive guide to AWS Lambda based on 5+ years of production experience, covering cold start optimization, performance tuning, monitoring, and cost optimization with real war stories and practical solutions.

Progress 3/4 posts completed

Related posts