lambda_loadout 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'cgi'
5
+ require 'time'
6
+
7
+ module LambdaLoadout
8
+ # ErrorNotifier sends detailed error notifications via SNS when Lambda functions fail
9
+ #
10
+ # @example Basic usage
11
+ # notifier = LambdaLoadout::ErrorNotifier.new(
12
+ # sns_topic_arn: ENV['ERROR_NOTIFICATION_TOPIC_ARN'],
13
+ # logger: logger
14
+ # )
15
+ # notifier.notify(error: exception, context: context, event: event)
16
+ #
17
+ # @example With custom region
18
+ # notifier = LambdaLoadout::ErrorNotifier.new(
19
+ # sns_topic_arn: 'arn:aws:sns:us-west-2:123456789012:alerts',
20
+ # logger: logger,
21
+ # region: 'us-west-2'
22
+ # )
23
+ class ErrorNotifier
24
+ attr_reader :sns_topic_arn, :logger, :region
25
+
26
+ # Initialize ErrorNotifier
27
+ #
28
+ # @param sns_topic_arn [String] ARN of SNS topic for error notifications
29
+ # @param logger [LambdaLoadout::Logger] Logger instance for logging notification status
30
+ # @param region [String, nil] AWS region (defaults to AWS_REGION env var or us-east-1)
31
+ def initialize(sns_topic_arn:, logger:, region: nil)
32
+ @sns_topic_arn = sns_topic_arn
33
+ @logger = logger
34
+ @region = region || ENV['AWS_REGION'] || 'us-east-1'
35
+ @sns_client = nil # Lazy initialize
36
+ end
37
+
38
+ # Send error notification via SNS
39
+ #
40
+ # @param error [Exception] The exception that occurred
41
+ # @param context [LambdaContext] AWS Lambda context object
42
+ # @param event [Hash] Lambda event hash
43
+ # @return [void]
44
+ def notify(error:, context:, event:)
45
+ return if sns_topic_arn.nil? || sns_topic_arn.empty?
46
+
47
+ begin
48
+ ensure_sns_client
49
+
50
+ subject = build_subject(error, context)
51
+ message = build_message(error, context, event)
52
+
53
+ @sns_client.publish(
54
+ topic_arn: sns_topic_arn,
55
+ subject: subject,
56
+ message: message
57
+ )
58
+
59
+ logger.info('Error notification sent',
60
+ topic_arn: sns_topic_arn,
61
+ error_class: error.class.name)
62
+ rescue StandardError => e
63
+ # Don't let notification errors prevent the original error from being raised
64
+ logger.warn('Failed to send error notification', e,
65
+ topic_arn: sns_topic_arn,
66
+ original_error: error.class.name)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Lazy initialize SNS client
73
+ def ensure_sns_client
74
+ @ensure_sns_client ||= begin
75
+ require 'aws-sdk-sns'
76
+ Aws::SNS::Client.new(region: region)
77
+ end
78
+ end
79
+
80
+ # Build email subject line
81
+ def build_subject(error, context)
82
+ "🚨 Lambda Error: #{error.class.name} in #{context.function_name}"
83
+ end
84
+
85
+ # Build detailed email message body
86
+ def build_message(error, context, event)
87
+ <<~MESSAGE
88
+ 🚨 Lambda Function Error Alert
89
+
90
+ 📋 Function Details
91
+ ══════════════════
92
+ Name: #{context.function_name}
93
+ Environment: #{ENV['ENVIRONMENT'] || ENV['STAGE'] || 'unknown'}
94
+ Request ID: #{context.aws_request_id}
95
+ Region: #{region}
96
+ Timestamp: #{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}
97
+ Memory Limit: #{context.memory_limit_in_mb}MB
98
+ Remaining Time: #{context.get_remaining_time_in_millis}ms
99
+
100
+ ❌ Error Details
101
+ ════════════════
102
+ Type: #{error.class.name}
103
+ Message: #{truncate_message(error.message)}
104
+
105
+ 🌐 Request Context
106
+ ══════════════════
107
+ #{format_request_context(event)}
108
+
109
+ 📚 Stack Trace (first 15 lines)
110
+ ═══════════════════════════════
111
+ #{format_backtrace(error)}
112
+
113
+ 🔍 View Full Logs
114
+ ═════════════════
115
+
116
+ CloudWatch Logs Insights (filtered to this error):
117
+ #{build_logs_insights_url(context, error)}
118
+
119
+ CloudWatch Recent Logs:
120
+ #{build_logs_stream_url(context)}
121
+
122
+ 📊 JSON Data
123
+ ════════════
124
+ #{format_error_json(error, context, event)}
125
+ MESSAGE
126
+ end
127
+
128
+ # Truncate very long error messages
129
+ def truncate_message(message, max_length = 500)
130
+ return message if message.length <= max_length
131
+
132
+ "#{message[0...max_length]}... (truncated, #{message.length} chars total)"
133
+ end
134
+
135
+ # Format request context based on event type
136
+ def format_request_context(event)
137
+ if event['httpMethod'] # API Gateway
138
+ <<~CONTEXT.strip
139
+ Path: #{event['path'] || 'N/A'}
140
+ Method: #{event['httpMethod']}
141
+ Source IP: #{event.dig('requestContext', 'identity', 'sourceIp') || 'N/A'}
142
+ User Agent: #{event.dig('headers', 'User-Agent') || event.dig('headers', 'user-agent') || 'N/A'}
143
+ CONTEXT
144
+ elsif event['Records'] # SQS, SNS, DynamoDB Streams, etc.
145
+ first_record = event['Records'].first || {}
146
+ event_source = first_record['eventSource'] || 'Unknown'
147
+ <<~CONTEXT.strip
148
+ Event Source: #{event_source}
149
+ Records Count: #{event['Records'].size}
150
+ First Record ID: #{first_record['messageId'] || first_record['eventID'] || 'N/A'}
151
+ CONTEXT
152
+ elsif event['source'] # EventBridge
153
+ <<~CONTEXT.strip
154
+ Event Source: #{event['source']}
155
+ Detail Type: #{event['detail-type'] || 'N/A'}
156
+ Resources: #{event['resources']&.join(', ') || 'N/A'}
157
+ CONTEXT
158
+ else # Unknown event type
159
+ "Event Type: Unknown\nEvent Keys: #{event.keys.sort.join(', ')}"
160
+ end
161
+ end
162
+
163
+ # Format exception backtrace
164
+ def format_backtrace(error)
165
+ backtrace = error.backtrace || []
166
+ if backtrace.empty?
167
+ 'No backtrace available'
168
+ else
169
+ backtrace.first(15).join("\n")
170
+ end
171
+ end
172
+
173
+ # Build CloudWatch Logs Insights deep link with pre-filled query
174
+ # Note: This URL format is specific to the AWS Console as of 2025
175
+ # If AWS changes the console URL structure, this may need updates
176
+ def build_logs_insights_url(context, error)
177
+ log_group = "/aws/lambda/#{context.function_name}"
178
+
179
+ # Use current time minus 5 minutes to capture the error
180
+ start_time = (Time.now - 300).to_i * 1000 # milliseconds
181
+ end_time = Time.now.to_i * 1000
182
+
183
+ # CloudWatch Logs Insights query to find this specific error
184
+ error_class_escaped = Regexp.escape(error.class.name)
185
+ query = "fields @timestamp, @message | filter @message like /#{error_class_escaped}/ | sort @timestamp desc | limit 20"
186
+
187
+ # URL encode the query and log group
188
+ encoded_query = CGI.escape(query)
189
+ encoded_log_group = CGI.escape(log_group)
190
+
191
+ # Deep link to CloudWatch Logs Insights with pre-filled query
192
+ # Format: AWS Console uses a specific encoding scheme for Logs Insights URLs
193
+ "https://console.aws.amazon.com/cloudwatch/home?region=#{region}#logsV2:logs-insights$3FqueryDetail$3D~(end~#{end_time}~start~#{start_time}~timeType~'ABSOLUTE~tz~'UTC~editorString~'#{encoded_query}~isLiveTail~false~source~(~'#{encoded_log_group}))"
194
+ end
195
+
196
+ # Build CloudWatch Logs stream deep link
197
+ def build_logs_stream_url(context)
198
+ # CloudWatch uses special encoding for forward slashes in URLs
199
+ encoded_log_group = "/aws/lambda/#{CGI.escape(context.function_name)}".gsub('/', '$252F')
200
+
201
+ "https://console.aws.amazon.com/cloudwatch/home?region=#{region}#logsV2:log-groups/log-group/#{encoded_log_group}/log-events"
202
+ end
203
+
204
+ # Format error details as JSON
205
+ def format_error_json(error, context, event)
206
+ error_data = {
207
+ function: context.function_name,
208
+ request_id: context.aws_request_id,
209
+ error_class: error.class.name,
210
+ error_message: error.message,
211
+ timestamp: Time.now.utc.iso8601,
212
+ backtrace: error.backtrace&.first(15) || [],
213
+ event_summary: build_event_summary(event)
214
+ }
215
+
216
+ JSON.pretty_generate(error_data)
217
+ rescue StandardError => e
218
+ # Fallback if JSON formatting fails
219
+ "{\"error\": \"Failed to format error data as JSON: #{e.message}\"}"
220
+ end
221
+
222
+ # Build compact event summary for JSON output
223
+ def build_event_summary(event)
224
+ if event['httpMethod']
225
+ {
226
+ type: 'API Gateway',
227
+ path: event['path'],
228
+ method: event['httpMethod']
229
+ }
230
+ elsif event['Records']
231
+ {
232
+ type: event['Records'].first&.dig('eventSource') || 'Records',
233
+ count: event['Records'].size
234
+ }
235
+ elsif event['source']
236
+ {
237
+ type: 'EventBridge',
238
+ source: event['source']
239
+ }
240
+ else
241
+ {
242
+ type: 'Unknown',
243
+ keys: event.keys.sort
244
+ }
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaLoadout
4
+ # Custom error classes
5
+ module Errors
6
+ # Base error for all LambdaLoadout errors
7
+ class Error < StandardError; end
8
+
9
+ # Raised when metric validation fails
10
+ class MetricValidationError < Error; end
11
+
12
+ # Raised when EMF schema validation fails
13
+ class SchemaValidationError < Error; end
14
+
15
+ # Raised when configuration is invalid
16
+ class ConfigurationError < Error; end
17
+ end
18
+
19
+ # Error handler for Lambda functions
20
+ #
21
+ # Automatically captures exceptions, logs them, and creates error metrics.
22
+ #
23
+ # @example Basic usage
24
+ # handler = LambdaLoadout::ErrorHandler.new(
25
+ # logger: logger,
26
+ # metrics: metrics,
27
+ # capture_stack_trace: true
28
+ # )
29
+ #
30
+ # def lambda_handler(event:, context:)
31
+ # handler.handle(context) do
32
+ # # Your code that might raise errors
33
+ # process_event(event)
34
+ # end
35
+ # end
36
+ class ErrorHandler
37
+ attr_reader :logger, :metrics
38
+
39
+ # Initialize error handler
40
+ #
41
+ # @param logger [LambdaLoadout::Logger] Logger instance
42
+ # @param metrics [LambdaLoadout::Metrics] Metrics instance
43
+ # @param capture_stack_trace [Boolean] Whether to include stack trace in logs
44
+ # @param error_metric_name [String] Name of error metric (default: "LambdaError")
45
+ def initialize(logger: nil, metrics: nil, capture_stack_trace: true, error_metric_name: 'LambdaError')
46
+ @logger = logger
47
+ @metrics = metrics
48
+ @capture_stack_trace = capture_stack_trace
49
+ @error_metric_name = error_metric_name
50
+ end
51
+
52
+ # Handle block execution with error capturing
53
+ #
54
+ # @param context [Object] Lambda context
55
+ # @yield Block to execute
56
+ # @return [Object] Result of block execution
57
+ # @raise Re-raises the original exception after logging and metrics
58
+ def handle(context = nil, &block)
59
+ block.call
60
+ rescue StandardError => e
61
+ capture_error(e, context)
62
+ raise
63
+ end
64
+
65
+ # Capture and log an error
66
+ #
67
+ # @param error [Exception] Exception to capture
68
+ # @param context [Object] Lambda context
69
+ # @param custom_fields [Hash] Additional fields to log
70
+ # @return [void]
71
+ def capture_error(error, context = nil, **custom_fields)
72
+ error_details = {
73
+ error_type: error.class.name,
74
+ error_message: error.message
75
+ }
76
+
77
+ error_details[:stack_trace] = error.backtrace&.first(20) if @capture_stack_trace
78
+
79
+ if context
80
+ error_details[:function_name] = context.function_name
81
+ error_details[:request_id] = context.aws_request_id
82
+ end
83
+
84
+ error_details.merge!(custom_fields)
85
+
86
+ # Log error
87
+ @logger&.error('Lambda execution error', **error_details)
88
+
89
+ # Add error metric
90
+ return unless @metrics
91
+
92
+ @metrics.add_metric(name: @error_metric_name, unit: 'Count', value: 1)
93
+ @metrics.add_dimension(name: 'error_type', value: error.class.name)
94
+ end
95
+ end
96
+
97
+ # Alarm configuration helper
98
+ #
99
+ # Helps define CloudWatch Alarms for metrics created via EMF.
100
+ # Note: This generates CloudFormation/SAM template snippets, actual alarm
101
+ # creation should be done via IaC (CloudFormation, Terraform, etc.)
102
+ #
103
+ # @example Generate alarm configuration
104
+ # alarm = LambdaLoadout::AlarmConfig.new(
105
+ # metric_name: "LambdaError",
106
+ # namespace: "MyApp",
107
+ # threshold: 1,
108
+ # evaluation_periods: 1
109
+ # )
110
+ #
111
+ # puts alarm.to_cloudformation
112
+ class AlarmConfig
113
+ attr_reader :metric_name, :namespace, :threshold, :statistic,
114
+ :evaluation_periods, :period, :comparison_operator
115
+
116
+ # Initialize alarm configuration
117
+ #
118
+ # @param metric_name [String] CloudWatch metric name
119
+ # @param namespace [String] CloudWatch metric namespace
120
+ # @param threshold [Numeric] Alarm threshold
121
+ # @param statistic [String] Statistic type (Sum, Average, Maximum, etc.)
122
+ # @param evaluation_periods [Integer] Number of periods to evaluate
123
+ # @param period [Integer] Period in seconds
124
+ # @param comparison_operator [String] Comparison operator
125
+ def initialize(
126
+ metric_name:,
127
+ namespace:,
128
+ threshold: 1,
129
+ statistic: 'Sum',
130
+ evaluation_periods: 1,
131
+ period: 60,
132
+ comparison_operator: 'GreaterThanOrEqualToThreshold'
133
+ )
134
+ @metric_name = metric_name
135
+ @namespace = namespace
136
+ @threshold = threshold
137
+ @statistic = statistic
138
+ @evaluation_periods = evaluation_periods
139
+ @period = period
140
+ @comparison_operator = comparison_operator
141
+ end
142
+
143
+ # Generate CloudFormation YAML for alarm
144
+ #
145
+ # @param alarm_name [String] CloudFormation logical name
146
+ # @param sns_topic_arn [String] SNS topic ARN for notifications (optional)
147
+ # @param dimensions [Hash] Metric dimensions
148
+ # @return [String] CloudFormation YAML snippet
149
+ def to_cloudformation(alarm_name: "#{metric_name}Alarm", sns_topic_arn: nil, dimensions: {})
150
+ cf = {
151
+ alarm_name => {
152
+ 'Type' => 'AWS::CloudWatch::Alarm',
153
+ 'Properties' => {
154
+ 'AlarmName' => alarm_name,
155
+ 'AlarmDescription' => "Alarm for #{metric_name} in #{namespace}",
156
+ 'MetricName' => metric_name,
157
+ 'Namespace' => namespace,
158
+ 'Statistic' => statistic,
159
+ 'Period' => period,
160
+ 'EvaluationPeriods' => evaluation_periods,
161
+ 'Threshold' => threshold,
162
+ 'ComparisonOperator' => comparison_operator,
163
+ 'TreatMissingData' => 'notBreaching'
164
+ }
165
+ }
166
+ }
167
+
168
+ # Add dimensions if provided
169
+ unless dimensions.empty?
170
+ dims = dimensions.map { |k, v| { 'Name' => k.to_s, 'Value' => v.to_s } }
171
+ cf[alarm_name]['Properties']['Dimensions'] = dims
172
+ end
173
+
174
+ # Add SNS notification if provided
175
+ cf[alarm_name]['Properties']['AlarmActions'] = [sns_topic_arn] if sns_topic_arn
176
+
177
+ require 'yaml'
178
+ YAML.dump(cf)
179
+ end
180
+
181
+ # Generate Terraform HCL for alarm
182
+ #
183
+ # @param resource_name [String] Terraform resource name
184
+ # @param sns_topic_arn [String] SNS topic ARN for notifications (optional)
185
+ # @param dimensions [Hash] Metric dimensions
186
+ # @return [String] Terraform HCL snippet
187
+ def to_terraform(resource_name: metric_name.downcase, sns_topic_arn: nil, dimensions: {})
188
+ hcl = <<~HCL
189
+ resource "aws_cloudwatch_metric_alarm" "#{resource_name}" {
190
+ alarm_name = "#{resource_name}"
191
+ alarm_description = "Alarm for #{metric_name} in #{namespace}"
192
+ comparison_operator = "#{comparison_operator}"
193
+ evaluation_periods = #{evaluation_periods}
194
+ metric_name = "#{metric_name}"
195
+ namespace = "#{namespace}"
196
+ period = #{period}
197
+ statistic = "#{statistic}"
198
+ threshold = #{threshold}
199
+ treat_missing_data = "notBreaching"
200
+ HCL
201
+
202
+ # Add dimensions if provided
203
+ unless dimensions.empty?
204
+ hcl += "\n dimensions = {\n"
205
+ dimensions.each do |k, v|
206
+ hcl += " #{k} = \"#{v}\"\n"
207
+ end
208
+ hcl += " }\n"
209
+ end
210
+
211
+ # Add SNS notification if provided
212
+ hcl += "\n alarm_actions = [\"#{sns_topic_arn}\"]\n" if sns_topic_arn
213
+
214
+ hcl += "}\n"
215
+ hcl
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LambdaLoadout
4
+ # Module-level convenience methods for quick access
5
+ #
6
+ # Provides a simple API similar to common logging modules:
7
+ # LambdaLoadout.logger.info("message")
8
+ # LambdaLoadout.metrics.add_metric(...)
9
+ #
10
+ # @example Module-level usage
11
+ # LambdaLoadout.configure do |config|
12
+ # config.service = "my-service"
13
+ # config.namespace = "MyApp"
14
+ # end
15
+ #
16
+ # LambdaLoadout.logger.info("Processing request")
17
+ # LambdaLoadout.metrics.add_metric(name: "Request", unit: "Count", value: 1)
18
+ module Global
19
+ class << self
20
+ attr_writer :logger, :metrics
21
+
22
+ # Configuration
23
+ attr_accessor :service, :namespace, :log_level
24
+
25
+ # Get or create the global logger instance
26
+ #
27
+ # @return [LambdaLoadout::Logger]
28
+ def logger
29
+ @logger ||= LambdaLoadout::Logger.new(
30
+ service: service || ENV.fetch('POWERTOOLS_SERVICE_NAME', nil),
31
+ level: log_level || :info
32
+ )
33
+ end
34
+
35
+ # Get or create the global metrics instance
36
+ #
37
+ # @return [LambdaLoadout::Metrics]
38
+ def metrics
39
+ @metrics ||= LambdaLoadout::Metrics.new(
40
+ namespace: namespace || ENV['POWERTOOLS_METRICS_NAMESPACE'] || 'LambdaLoadout',
41
+ service: service || ENV.fetch('POWERTOOLS_SERVICE_NAME', nil)
42
+ )
43
+ end
44
+
45
+ # Reset global instances (useful for testing)
46
+ #
47
+ # @return [void]
48
+ def reset!
49
+ @logger = nil
50
+ @metrics = nil
51
+ end
52
+ end
53
+ end
54
+
55
+ # Configure LambdaLoadout globally
56
+ #
57
+ # @yield [Global] Configuration block
58
+ # @return [void]
59
+ #
60
+ # @example
61
+ # LambdaLoadout.configure do |config|
62
+ # config.service = "payment-api"
63
+ # config.namespace = "MyApp"
64
+ # config.log_level = :debug
65
+ # end
66
+ def self.configure
67
+ yield Global
68
+ end
69
+
70
+ # Access global logger
71
+ #
72
+ # @return [LambdaLoadout::Logger]
73
+ def self.logger
74
+ Global.logger
75
+ end
76
+
77
+ # Access global metrics
78
+ #
79
+ # @return [LambdaLoadout::Metrics]
80
+ def self.metrics
81
+ Global.metrics
82
+ end
83
+ end