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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +499 -0
- data/certs/stowzilla.pem +26 -0
- data/lib/lambda_loadout/error_notifier.rb +248 -0
- data/lib/lambda_loadout/errors.rb +218 -0
- data/lib/lambda_loadout/global.rb +83 -0
- data/lib/lambda_loadout/logger.rb +256 -0
- data/lib/lambda_loadout/metrics.rb +296 -0
- data/lib/lambda_loadout/middleware.rb +137 -0
- data/lib/lambda_loadout/version.rb +5 -0
- data/lib/lambda_loadout.rb +101 -0
- data.tar.gz.sig +3 -0
- metadata +168 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'logger'
|
|
5
|
+
|
|
6
|
+
module LambdaLoadout
|
|
7
|
+
# Structured logger with Lambda context enrichment
|
|
8
|
+
#
|
|
9
|
+
# Provides JSON structured logging with automatic Lambda context injection,
|
|
10
|
+
# correlation IDs, and support for custom fields.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# logger = LambdaLoadout::Logger.new(service: "payment")
|
|
14
|
+
# logger.info("Processing payment", order_id: "12345")
|
|
15
|
+
# # => {"level":"INFO","timestamp":"2025-01-01T12:00:00.000Z","message":"Processing payment","service":"payment","order_id":"12345"}
|
|
16
|
+
#
|
|
17
|
+
# @example With Lambda context
|
|
18
|
+
# def lambda_handler(event:, context:)
|
|
19
|
+
# logger = LambdaLoadout::Logger.new(service: "payment")
|
|
20
|
+
# logger.inject_lambda_context(context)
|
|
21
|
+
# logger.info("Handler invoked")
|
|
22
|
+
# # => Includes function_name, request_id, etc.
|
|
23
|
+
# end
|
|
24
|
+
class Logger
|
|
25
|
+
attr_reader :service, :level, :output
|
|
26
|
+
attr_accessor :lambda_context
|
|
27
|
+
|
|
28
|
+
LOG_LEVELS = {
|
|
29
|
+
debug: 0,
|
|
30
|
+
info: 1,
|
|
31
|
+
warn: 2,
|
|
32
|
+
error: 3,
|
|
33
|
+
fatal: 4
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Initialize a new structured logger
|
|
37
|
+
#
|
|
38
|
+
# @param service [String] Service name (adds 'service' field to all logs)
|
|
39
|
+
# @param level [Symbol, String] Log level (:debug, :info, :warn, :error, :fatal)
|
|
40
|
+
# @param output [IO] Output stream (defaults to $stdout)
|
|
41
|
+
# @param sampling_rate [Float] Sampling rate for debug logs (0.0-1.0)
|
|
42
|
+
def initialize(service: nil, level: :info, output: $stdout, sampling_rate: nil)
|
|
43
|
+
@service = service || ENV.fetch('POWERTOOLS_SERVICE_NAME', nil)
|
|
44
|
+
@level = LOG_LEVELS[level.to_sym] || LOG_LEVELS[:info]
|
|
45
|
+
@output = output
|
|
46
|
+
@sampling_rate = sampling_rate&.to_f
|
|
47
|
+
@lambda_context = nil
|
|
48
|
+
@persistent_fields = {}
|
|
49
|
+
@persistent_fields['service'] = @service if @service
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inject Lambda context into logs
|
|
53
|
+
#
|
|
54
|
+
# @param context [Object] Lambda context object
|
|
55
|
+
# @return [void]
|
|
56
|
+
def inject_lambda_context(context)
|
|
57
|
+
@lambda_context = context
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Add persistent fields that will be included in all log entries
|
|
61
|
+
#
|
|
62
|
+
# @param fields [Hash] Fields to add to all logs
|
|
63
|
+
# @return [void]
|
|
64
|
+
def append_keys(**fields)
|
|
65
|
+
@persistent_fields.merge!(fields.transform_keys(&:to_s))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Remove persistent fields
|
|
69
|
+
#
|
|
70
|
+
# @param keys [Array<Symbol, String>] Keys to remove
|
|
71
|
+
# @return [void]
|
|
72
|
+
def remove_keys(*keys)
|
|
73
|
+
keys.each { |key| @persistent_fields.delete(key.to_s) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Log debug message
|
|
77
|
+
#
|
|
78
|
+
# @param message [String] Log message
|
|
79
|
+
# @param exception [Exception, nil] Optional exception object
|
|
80
|
+
# @param fields [Hash] Additional fields
|
|
81
|
+
# @return [void]
|
|
82
|
+
def debug(message, exception = nil, **fields)
|
|
83
|
+
return unless should_log?(:debug)
|
|
84
|
+
|
|
85
|
+
if exception.is_a?(Exception)
|
|
86
|
+
fields[:error] = exception.message
|
|
87
|
+
fields[:error_class] = exception.class.name
|
|
88
|
+
fields[:backtrace] = exception.backtrace&.first(10)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
log(:debug, message, **fields)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Log info message
|
|
95
|
+
#
|
|
96
|
+
# @param message [String] Log message
|
|
97
|
+
# @param exception [Exception, nil] Optional exception object
|
|
98
|
+
# @param fields [Hash] Additional fields
|
|
99
|
+
# @return [void]
|
|
100
|
+
def info(message, exception = nil, **fields)
|
|
101
|
+
return unless should_log?(:info)
|
|
102
|
+
|
|
103
|
+
if exception.is_a?(Exception)
|
|
104
|
+
fields[:error] = exception.message
|
|
105
|
+
fields[:error_class] = exception.class.name
|
|
106
|
+
fields[:backtrace] = exception.backtrace&.first(10)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
log(:info, message, **fields)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Log warning message
|
|
113
|
+
#
|
|
114
|
+
# @param message [String] Log message
|
|
115
|
+
# @param exception [Exception, nil] Optional exception object
|
|
116
|
+
# @param fields [Hash] Additional fields
|
|
117
|
+
# @return [void]
|
|
118
|
+
def warn(message, exception = nil, **fields)
|
|
119
|
+
return unless should_log?(:warn)
|
|
120
|
+
|
|
121
|
+
if exception.is_a?(Exception)
|
|
122
|
+
fields[:error] = exception.message
|
|
123
|
+
fields[:error_class] = exception.class.name
|
|
124
|
+
fields[:backtrace] = exception.backtrace&.first(10)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
log(:warn, message, **fields)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Log error message
|
|
131
|
+
#
|
|
132
|
+
# @param message [String] Log message
|
|
133
|
+
# @param exception [Exception, nil] Optional exception object
|
|
134
|
+
# @param fields [Hash] Additional fields
|
|
135
|
+
# @return [void]
|
|
136
|
+
def error(message, exception = nil, **fields)
|
|
137
|
+
return unless should_log?(:error)
|
|
138
|
+
|
|
139
|
+
if exception.is_a?(Exception)
|
|
140
|
+
fields[:error] = exception.message
|
|
141
|
+
fields[:error_class] = exception.class.name
|
|
142
|
+
fields[:backtrace] = exception.backtrace&.first(10)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
log(:error, message, **fields)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Log fatal message
|
|
149
|
+
#
|
|
150
|
+
# @param message [String] Log message
|
|
151
|
+
# @param fields [Hash] Additional fields
|
|
152
|
+
# @return [void]
|
|
153
|
+
def fatal(message, **fields)
|
|
154
|
+
return unless should_log?(:fatal)
|
|
155
|
+
|
|
156
|
+
log(:fatal, message, **fields)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Log an exception with full details
|
|
160
|
+
#
|
|
161
|
+
# @param exception [Exception] Exception to log
|
|
162
|
+
# @param message [String] Additional message
|
|
163
|
+
# @param fields [Hash] Additional fields
|
|
164
|
+
# @return [void]
|
|
165
|
+
def exception(exception, message: nil, **fields)
|
|
166
|
+
error(
|
|
167
|
+
message || exception.message,
|
|
168
|
+
exception: exception.class.name,
|
|
169
|
+
error_message: exception.message,
|
|
170
|
+
backtrace: exception.backtrace&.first(10),
|
|
171
|
+
**fields
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Check if log level should be logged
|
|
178
|
+
#
|
|
179
|
+
# @param log_level [Symbol] Level to check
|
|
180
|
+
# @return [Boolean]
|
|
181
|
+
def should_log?(log_level)
|
|
182
|
+
level_value = LOG_LEVELS[log_level]
|
|
183
|
+
return false if level_value < @level
|
|
184
|
+
|
|
185
|
+
# Apply sampling for debug logs if configured
|
|
186
|
+
if log_level == :debug && @sampling_rate
|
|
187
|
+
rand <= @sampling_rate
|
|
188
|
+
else
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Core logging method
|
|
194
|
+
#
|
|
195
|
+
# @param level [Symbol] Log level
|
|
196
|
+
# @param message [String] Log message
|
|
197
|
+
# @param fields [Hash] Additional fields
|
|
198
|
+
# @return [void]
|
|
199
|
+
def log(level, message, **fields)
|
|
200
|
+
log_entry = build_log_entry(level, message, fields)
|
|
201
|
+
@output.puts JSON.generate(log_entry)
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
# Fallback to basic output if JSON serialization fails
|
|
204
|
+
@output.puts "[LambdaLoadout::Logger] Error serializing log: #{e.message}"
|
|
205
|
+
@output.puts "Original message: #{message}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Build structured log entry
|
|
209
|
+
#
|
|
210
|
+
# @param level [Symbol] Log level
|
|
211
|
+
# @param message [String] Log message
|
|
212
|
+
# @param fields [Hash] Additional fields
|
|
213
|
+
# @return [Hash]
|
|
214
|
+
def build_log_entry(level, message, fields)
|
|
215
|
+
entry = {
|
|
216
|
+
'level' => level.to_s.upcase,
|
|
217
|
+
'timestamp' => Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ'),
|
|
218
|
+
'message' => message
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Add persistent fields
|
|
222
|
+
entry.merge!(@persistent_fields)
|
|
223
|
+
|
|
224
|
+
# Add Lambda context if available
|
|
225
|
+
entry.merge!(lambda_context_fields) if @lambda_context
|
|
226
|
+
|
|
227
|
+
# Add custom fields
|
|
228
|
+
entry.merge!(stringify_keys(fields))
|
|
229
|
+
|
|
230
|
+
entry
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Extract fields from Lambda context
|
|
234
|
+
#
|
|
235
|
+
# @return [Hash]
|
|
236
|
+
def lambda_context_fields
|
|
237
|
+
{
|
|
238
|
+
'function_name' => @lambda_context.function_name,
|
|
239
|
+
'function_version' => @lambda_context.function_version,
|
|
240
|
+
'function_memory_size' => @lambda_context.memory_limit_in_mb,
|
|
241
|
+
'function_request_id' => @lambda_context.aws_request_id,
|
|
242
|
+
'cold_start' => LambdaLoadout.cold_start?
|
|
243
|
+
}.compact
|
|
244
|
+
rescue StandardError
|
|
245
|
+
{}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Convert hash keys to strings
|
|
249
|
+
#
|
|
250
|
+
# @param hash [Hash] Hash with symbol or string keys
|
|
251
|
+
# @return [Hash] Hash with string keys
|
|
252
|
+
def stringify_keys(hash)
|
|
253
|
+
hash.transform_keys(&:to_s)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module LambdaLoadout
|
|
7
|
+
# CloudWatch Metrics using Embedded Metric Format (EMF)
|
|
8
|
+
#
|
|
9
|
+
# Creates custom metrics asynchronously by outputting metrics to stdout
|
|
10
|
+
# following Amazon CloudWatch Embedded Metric Format (EMF).
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# metrics = LambdaLoadout::Metrics.new(namespace: "MyApp", service: "payment")
|
|
14
|
+
# metrics.add_metric(name: "PaymentProcessed", unit: "Count", value: 1)
|
|
15
|
+
# metrics.add_dimension(name: "region", value: "us-east-1")
|
|
16
|
+
# metrics.flush
|
|
17
|
+
#
|
|
18
|
+
# @example With middleware
|
|
19
|
+
# def lambda_handler(event:, context:)
|
|
20
|
+
# metrics = LambdaLoadout::Metrics.new(namespace: "MyApp")
|
|
21
|
+
#
|
|
22
|
+
# begin
|
|
23
|
+
# metrics.add_metric(name: "RequestReceived", unit: "Count", value: 1)
|
|
24
|
+
# # Your logic here
|
|
25
|
+
# ensure
|
|
26
|
+
# metrics.flush
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
class Metrics
|
|
30
|
+
# CloudWatch metric units
|
|
31
|
+
UNITS = %w[
|
|
32
|
+
Seconds Microseconds Milliseconds
|
|
33
|
+
Bytes Kilobytes Megabytes Gigabytes Terabytes
|
|
34
|
+
Bits Kilobits Megabits Gigabits Terabits
|
|
35
|
+
Percent Count
|
|
36
|
+
Bytes/Second Kilobytes/Second Megabytes/Second Gigabytes/Second Terabytes/Second
|
|
37
|
+
Bits/Second Kilobits/Second Megabits/Second Gigabits/Second Terabits/Second
|
|
38
|
+
Count/Second None
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# Metric resolutions in seconds
|
|
42
|
+
RESOLUTIONS = [1, 60].freeze
|
|
43
|
+
|
|
44
|
+
MAX_DIMENSIONS = 29
|
|
45
|
+
MAX_METRICS = 100
|
|
46
|
+
|
|
47
|
+
attr_reader :namespace, :service, :output
|
|
48
|
+
|
|
49
|
+
# Initialize metrics
|
|
50
|
+
#
|
|
51
|
+
# @param namespace [String] CloudWatch metric namespace (required)
|
|
52
|
+
# @param service [String] Service name (added as default dimension)
|
|
53
|
+
# @param output [IO] Output stream for EMF (defaults to $stdout)
|
|
54
|
+
def initialize(namespace: nil, service: nil, output: $stdout)
|
|
55
|
+
@namespace = namespace || ENV.fetch('POWERTOOLS_METRICS_NAMESPACE', nil)
|
|
56
|
+
@service = service || ENV.fetch('POWERTOOLS_SERVICE_NAME', nil)
|
|
57
|
+
@output = output
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, 'Namespace is required' if @namespace.nil? || @namespace.empty?
|
|
60
|
+
|
|
61
|
+
@metrics = {}
|
|
62
|
+
@dimensions = {}
|
|
63
|
+
@metadata = {}
|
|
64
|
+
@default_dimensions = {}
|
|
65
|
+
@timestamp = nil
|
|
66
|
+
@cold_start_captured = false
|
|
67
|
+
|
|
68
|
+
# Add service as default dimension if provided
|
|
69
|
+
add_dimension(name: 'service', value: @service) if @service
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add a metric
|
|
73
|
+
#
|
|
74
|
+
# @param name [String] Metric name
|
|
75
|
+
# @param unit [String] Metric unit (must be valid CloudWatch unit)
|
|
76
|
+
# @param value [Numeric] Metric value
|
|
77
|
+
# @param resolution [Integer] Storage resolution in seconds (1 or 60)
|
|
78
|
+
# @return [void]
|
|
79
|
+
# @raise [ArgumentError] if parameters are invalid
|
|
80
|
+
def add_metric(name:, unit:, value:, resolution: 60)
|
|
81
|
+
validate_metric_name!(name)
|
|
82
|
+
validate_metric_unit!(unit)
|
|
83
|
+
validate_metric_value!(value)
|
|
84
|
+
validate_metric_resolution!(resolution)
|
|
85
|
+
|
|
86
|
+
# Auto-flush if we've hit the max metrics limit
|
|
87
|
+
flush_metrics if @metrics.size >= MAX_METRICS
|
|
88
|
+
|
|
89
|
+
metric_key = name.to_s
|
|
90
|
+
@metrics[metric_key] ||= { 'Unit' => unit.to_s, 'StorageResolution' => resolution, 'Values' => [] }
|
|
91
|
+
@metrics[metric_key]['Values'] << value.to_f
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Add a dimension to all metrics
|
|
95
|
+
#
|
|
96
|
+
# @param name [String] Dimension name
|
|
97
|
+
# @param value [String] Dimension value
|
|
98
|
+
# @return [void]
|
|
99
|
+
# @raise [ArgumentError] if max dimensions exceeded
|
|
100
|
+
def add_dimension(name:, value:)
|
|
101
|
+
raise ArgumentError, "Maximum number of dimensions (#{MAX_DIMENSIONS}) exceeded" if @dimensions.size >= MAX_DIMENSIONS
|
|
102
|
+
|
|
103
|
+
@dimensions[name.to_s] = value.to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Add metadata (searchable in CloudWatch Logs but not as a metric dimension)
|
|
107
|
+
#
|
|
108
|
+
# @param key [String] Metadata key
|
|
109
|
+
# @param value [Object] Metadata value
|
|
110
|
+
# @return [void]
|
|
111
|
+
def add_metadata(key:, value:)
|
|
112
|
+
@metadata[key.to_s] = value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Set default dimensions that persist across flushes
|
|
116
|
+
#
|
|
117
|
+
# @param dimensions [Hash] Default dimensions
|
|
118
|
+
# @return [void]
|
|
119
|
+
def set_default_dimensions(**dimensions)
|
|
120
|
+
@default_dimensions.merge!(dimensions.transform_keys(&:to_s))
|
|
121
|
+
@dimensions.merge!(@default_dimensions)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Set custom timestamp for metrics
|
|
125
|
+
#
|
|
126
|
+
# @param timestamp [Time, Integer] Timestamp (Time object or epoch in milliseconds)
|
|
127
|
+
# @return [void]
|
|
128
|
+
def set_timestamp(timestamp)
|
|
129
|
+
@timestamp = case timestamp
|
|
130
|
+
when Time
|
|
131
|
+
(timestamp.to_f * 1000).to_i
|
|
132
|
+
when Integer
|
|
133
|
+
timestamp
|
|
134
|
+
else
|
|
135
|
+
raise ArgumentError, 'Timestamp must be Time object or Integer (epoch ms)'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Validate timestamp is within CloudWatch limits (14 days past, 2 hours future)
|
|
139
|
+
now = (Time.now.to_f * 1000).to_i
|
|
140
|
+
min_time = now - (14 * 24 * 60 * 60 * 1000)
|
|
141
|
+
max_time = now + (2 * 60 * 60 * 1000)
|
|
142
|
+
|
|
143
|
+
return unless @timestamp < min_time || @timestamp > max_time
|
|
144
|
+
|
|
145
|
+
warn '[LambdaLoadout::Metrics] Timestamp outside CloudWatch limits (14 days past, 2 hours future)'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Add cold start metric
|
|
149
|
+
#
|
|
150
|
+
# @param context [Object] Lambda context
|
|
151
|
+
# @return [void]
|
|
152
|
+
def add_cold_start_metric(context)
|
|
153
|
+
return if @cold_start_captured
|
|
154
|
+
return unless LambdaLoadout.cold_start?
|
|
155
|
+
|
|
156
|
+
@cold_start_captured = true
|
|
157
|
+
|
|
158
|
+
# Create a separate EMF blob for cold start metric
|
|
159
|
+
cold_start_metrics = self.class.new(namespace: @namespace, output: @output)
|
|
160
|
+
cold_start_metrics.add_metric(name: 'ColdStart', unit: 'Count', value: 1)
|
|
161
|
+
cold_start_metrics.add_dimension(name: 'function_name', value: context.function_name)
|
|
162
|
+
cold_start_metrics.add_dimension(name: 'service', value: @service) if @service
|
|
163
|
+
cold_start_metrics.flush
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
warn "[LambdaLoadout::Metrics] Failed to add cold start metric: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Serialize and flush all metrics
|
|
169
|
+
#
|
|
170
|
+
# Outputs metrics in CloudWatch Embedded Metric Format (EMF) to stdout.
|
|
171
|
+
# Metrics are automatically ingested by CloudWatch when running in Lambda.
|
|
172
|
+
#
|
|
173
|
+
# @param raise_on_empty [Boolean] Raise error if no metrics to flush
|
|
174
|
+
# @return [void]
|
|
175
|
+
def flush(raise_on_empty: false)
|
|
176
|
+
if @metrics.empty?
|
|
177
|
+
warn '[LambdaLoadout::Metrics] No metrics to flush' unless raise_on_empty
|
|
178
|
+
raise 'No metrics to flush' if raise_on_empty
|
|
179
|
+
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
flush_metrics
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Clear all metrics, dimensions, and metadata
|
|
187
|
+
#
|
|
188
|
+
# @return [void]
|
|
189
|
+
def clear
|
|
190
|
+
@metrics.clear
|
|
191
|
+
@dimensions.clear
|
|
192
|
+
@metadata.clear
|
|
193
|
+
@dimensions.merge!(@default_dimensions)
|
|
194
|
+
@timestamp = nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# Flush metrics to output in EMF format
|
|
200
|
+
#
|
|
201
|
+
# @return [void]
|
|
202
|
+
def flush_metrics
|
|
203
|
+
return if @metrics.empty?
|
|
204
|
+
|
|
205
|
+
emf_output = serialize_metrics
|
|
206
|
+
@output.puts JSON.generate(emf_output)
|
|
207
|
+
|
|
208
|
+
# Clear metrics but keep dimensions and metadata
|
|
209
|
+
@metrics.clear
|
|
210
|
+
@timestamp = nil
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
warn "[LambdaLoadout::Metrics] Failed to serialize metrics: #{e.message}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Serialize metrics to EMF format
|
|
216
|
+
#
|
|
217
|
+
# @return [Hash] EMF formatted metrics
|
|
218
|
+
def serialize_metrics
|
|
219
|
+
# Build metric definitions for EMF
|
|
220
|
+
metric_definitions = @metrics.map do |name, metric|
|
|
221
|
+
definition = {
|
|
222
|
+
'Name' => name,
|
|
223
|
+
'Unit' => metric['Unit']
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Add storage resolution for high-resolution metrics
|
|
227
|
+
definition['StorageResolution'] = metric['StorageResolution'] if metric['StorageResolution'] == 1
|
|
228
|
+
|
|
229
|
+
definition
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Build metric values
|
|
233
|
+
metric_values = @metrics.transform_values do |metric|
|
|
234
|
+
values = metric['Values']
|
|
235
|
+
values.size == 1 ? values.first : values
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Build EMF output
|
|
239
|
+
{
|
|
240
|
+
'_aws' => {
|
|
241
|
+
'Timestamp' => @timestamp || (Time.now.to_f * 1000).to_i,
|
|
242
|
+
'CloudWatchMetrics' => [
|
|
243
|
+
{
|
|
244
|
+
'Namespace' => @namespace,
|
|
245
|
+
'Dimensions' => [@dimensions.keys],
|
|
246
|
+
'Metrics' => metric_definitions
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
}.merge(@dimensions)
|
|
251
|
+
.merge(@metadata)
|
|
252
|
+
.merge(metric_values)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Validate metric name
|
|
256
|
+
#
|
|
257
|
+
# @param name [String] Metric name
|
|
258
|
+
# @raise [ArgumentError] if invalid
|
|
259
|
+
def validate_metric_name!(name)
|
|
260
|
+
name_str = name.to_s
|
|
261
|
+
return unless name_str.empty? || name_str.length > 255
|
|
262
|
+
|
|
263
|
+
raise ArgumentError, 'Metric name must be between 1 and 255 characters'
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Validate metric unit
|
|
267
|
+
#
|
|
268
|
+
# @param unit [String] Metric unit
|
|
269
|
+
# @raise [ArgumentError] if invalid
|
|
270
|
+
def validate_metric_unit!(unit)
|
|
271
|
+
return if UNITS.include?(unit.to_s)
|
|
272
|
+
|
|
273
|
+
raise ArgumentError, "Invalid metric unit: #{unit}. Must be one of: #{UNITS.join(', ')}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Validate metric value
|
|
277
|
+
#
|
|
278
|
+
# @param value [Numeric] Metric value
|
|
279
|
+
# @raise [ArgumentError] if invalid
|
|
280
|
+
def validate_metric_value!(value)
|
|
281
|
+
return if value.is_a?(Numeric)
|
|
282
|
+
|
|
283
|
+
raise ArgumentError, 'Metric value must be a number'
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Validate metric resolution
|
|
287
|
+
#
|
|
288
|
+
# @param resolution [Integer] Storage resolution
|
|
289
|
+
# @raise [ArgumentError] if invalid
|
|
290
|
+
def validate_metric_resolution!(resolution)
|
|
291
|
+
return if RESOLUTIONS.include?(resolution)
|
|
292
|
+
|
|
293
|
+
raise ArgumentError, "Invalid resolution: #{resolution}. Must be 1 or 60 seconds"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LambdaLoadout
|
|
4
|
+
# Middleware for Ruby Lambda handlers
|
|
5
|
+
#
|
|
6
|
+
# Provides a clean way to wrap Lambda handlers with logging, metrics,
|
|
7
|
+
# and error handling without repetitive boilerplate code.
|
|
8
|
+
#
|
|
9
|
+
# @example Using as a wrapper
|
|
10
|
+
# class MyHandler
|
|
11
|
+
# include LambdaLoadout::Middleware
|
|
12
|
+
#
|
|
13
|
+
# def initialize
|
|
14
|
+
# @logger = LambdaLoadout::Logger.new(service: "payment")
|
|
15
|
+
# @metrics = LambdaLoadout::Metrics.new(namespace: "MyApp", service: "payment")
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def call(event:, context:)
|
|
19
|
+
# with_observability(context) do
|
|
20
|
+
# @logger.info("Processing payment", event: event)
|
|
21
|
+
# @metrics.add_metric(name: "PaymentProcessed", unit: "Count", value: 1)
|
|
22
|
+
#
|
|
23
|
+
# { statusCode: 200, body: "Success" }
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
module Middleware
|
|
28
|
+
# Execute block with full observability stack
|
|
29
|
+
#
|
|
30
|
+
# Automatically:
|
|
31
|
+
# - Injects Lambda context into logger
|
|
32
|
+
# - Captures cold start metric
|
|
33
|
+
# - Flushes metrics after execution
|
|
34
|
+
# - Handles errors with proper logging and metrics
|
|
35
|
+
#
|
|
36
|
+
# @param context [Object] Lambda context
|
|
37
|
+
# @param capture_cold_start [Boolean] Whether to capture cold start
|
|
38
|
+
# @param logger [LambdaLoadout::Logger] Logger instance (uses @logger if not provided)
|
|
39
|
+
# @param metrics [LambdaLoadout::Metrics] Metrics instance (uses @metrics if not provided)
|
|
40
|
+
# @yield Block containing handler logic
|
|
41
|
+
# @return [Object] Result of block execution
|
|
42
|
+
def with_observability(context, capture_cold_start: true, logger: nil, metrics: nil, &block)
|
|
43
|
+
logger ||= @logger
|
|
44
|
+
metrics ||= @metrics
|
|
45
|
+
|
|
46
|
+
logger&.inject_lambda_context(context)
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
result = block.call
|
|
50
|
+
metrics&.add_cold_start_metric(context) if capture_cold_start
|
|
51
|
+
result
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
logger&.exception(e, message: 'Lambda execution failed')
|
|
54
|
+
metrics&.add_metric(name: 'LambdaError', unit: 'Count', value: 1)
|
|
55
|
+
raise
|
|
56
|
+
ensure
|
|
57
|
+
begin
|
|
58
|
+
metrics&.flush
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Decorator-style wrapper for Lambda handlers
|
|
67
|
+
#
|
|
68
|
+
# Provides a simple DSL for wrapping Lambda handlers with observability.
|
|
69
|
+
#
|
|
70
|
+
# @example Basic usage
|
|
71
|
+
# LambdaLoadout::Handler.configure do |config|
|
|
72
|
+
# config.service = "payment"
|
|
73
|
+
# config.namespace = "MyApp"
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# def lambda_handler(event:, context:)
|
|
77
|
+
# LambdaLoadout::Handler.call(event, context) do |logger, metrics|
|
|
78
|
+
# logger.info("Processing event")
|
|
79
|
+
# metrics.add_metric(name: "EventProcessed", unit: "Count", value: 1)
|
|
80
|
+
#
|
|
81
|
+
# { statusCode: 200 }
|
|
82
|
+
# end
|
|
83
|
+
# end
|
|
84
|
+
class Handler
|
|
85
|
+
class << self
|
|
86
|
+
attr_accessor :service, :namespace, :log_level, :capture_cold_start
|
|
87
|
+
|
|
88
|
+
# Configure global handler defaults
|
|
89
|
+
#
|
|
90
|
+
# @yield [self] Configuration block
|
|
91
|
+
# @return [void]
|
|
92
|
+
def configure
|
|
93
|
+
yield self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Call handler with observability
|
|
97
|
+
#
|
|
98
|
+
# @param event [Hash] Lambda event
|
|
99
|
+
# @param context [Object] Lambda context
|
|
100
|
+
# @param capture_cold_start [Boolean] Override cold start capture
|
|
101
|
+
# @yield [logger, metrics] Block with logger and metrics instances
|
|
102
|
+
# @return [Object] Result of block execution
|
|
103
|
+
def call(event, context, capture_cold_start: @capture_cold_start || true, &block)
|
|
104
|
+
logger = LambdaLoadout::Logger.new(
|
|
105
|
+
service: @service,
|
|
106
|
+
level: @log_level || :info
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
metrics = LambdaLoadout::Metrics.new(
|
|
110
|
+
namespace: @namespace || 'LambdaLoadout',
|
|
111
|
+
service: @service
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
logger.inject_lambda_context(context)
|
|
115
|
+
logger.info('Lambda invocation started', event_keys: event.keys)
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
result = block.call(logger, metrics)
|
|
119
|
+
metrics.add_cold_start_metric(context) if capture_cold_start
|
|
120
|
+
logger.info('Lambda invocation completed successfully')
|
|
121
|
+
result
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
logger.exception(e, message: 'Lambda invocation failed')
|
|
124
|
+
metrics.add_metric(name: 'LambdaError', unit: 'Count', value: 1)
|
|
125
|
+
metrics.add_dimension(name: 'error_type', value: e.class.name)
|
|
126
|
+
raise
|
|
127
|
+
ensure
|
|
128
|
+
begin
|
|
129
|
+
metrics.flush
|
|
130
|
+
rescue StandardError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|