belt 0.2.11 → 0.2.13
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 +4 -4
- data/CHANGELOG.md +42 -1
- data/lib/belt/cli/app_detection.rb +19 -5
- data/lib/belt/cli/contracts_command.rb +142 -0
- data/lib/belt/cli/destroy_command.rb +74 -0
- data/lib/belt/cli/frontend_command.rb +23 -0
- data/lib/belt/cli/generate_command.rb +11 -9
- data/lib/belt/cli/logs_command.rb +634 -0
- data/lib/belt/cli/new_command.rb +2 -2
- data/lib/belt/cli/routes_command/schema_loader.rb +17 -7
- data/lib/belt/cli/routes_command.rb +7 -3
- data/lib/belt/cli/setup_command.rb +1 -1
- data/lib/belt/cli/views_command.rb +96 -11
- data/lib/belt/cli.rb +6 -0
- data/lib/belt/root.rb +12 -3
- data/lib/belt/route_dsl.rb +9 -6
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -0
- data/lib/templates/frontend_infra/frontend.tf.erb +2 -1
- data/lib/templates/generate/controller.rb.erb +13 -18
- data/lib/templates/generate/model.rb.erb +1 -14
- data/lib/templates/module/frontend.tf.erb +2 -1
- data/lib/templates/module/main.tf.erb +1 -1
- data/lib/templates/new_app/AGENTS.md.erb +4 -4
- data/lib/templates/new_app/Gemfile.erb +0 -2
- data/lib/templates/new_app/README.md.erb +2 -2
- data/lib/templates/new_app/gitignore.erb +3 -0
- data/lib/templates/new_app/lambda/api.rb.erb +0 -6
- data/lib/templates/new_app/lambda/config/environment.rb.erb +0 -6
- data/lib/templates/new_app/lambda/models/application_record.rb.erb +0 -2
- data/lib/templates/plugin/AGENTS.md.erb +1 -1
- data/lib/templates/plugin/README.md.erb +1 -1
- metadata +11 -3
- /data/lib/templates/new_app/config/{schema.tf.rb.erb → contracts.rb.erb} +0 -0
- /data/lib/templates/new_app/config/{routes.tf.rb.erb → routes.rb.erb} +0 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class LogsCommand
|
|
9
|
+
include AppDetection
|
|
10
|
+
|
|
11
|
+
COLORS = {
|
|
12
|
+
red: "\e[0;31m",
|
|
13
|
+
green: "\e[0;32m",
|
|
14
|
+
yellow: "\e[0;33m",
|
|
15
|
+
blue: "\e[0;34m",
|
|
16
|
+
magenta: "\e[0;35m",
|
|
17
|
+
cyan: "\e[0;36m",
|
|
18
|
+
gray: "\e[0;90m",
|
|
19
|
+
bold: "\e[1m",
|
|
20
|
+
dim: "\e[2m",
|
|
21
|
+
reset: "\e[0m"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def self.run(args)
|
|
25
|
+
if args.include?('--help') || args.include?('-h')
|
|
26
|
+
puts usage
|
|
27
|
+
exit 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
new(args).run
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.usage
|
|
34
|
+
<<~USAGE
|
|
35
|
+
Usage: belt logs [lambda] [options]
|
|
36
|
+
|
|
37
|
+
View Lambda function logs. Without arguments, tails all Lambdas for the current environment.
|
|
38
|
+
|
|
39
|
+
Arguments:
|
|
40
|
+
lambda Lambda function short name (e.g., api, worker). Optional — shows all if omitted.
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
-e, --env ENV Environment (default: BELT_ENV or first detected)
|
|
44
|
+
-f, --follow Follow logs in real-time
|
|
45
|
+
-s, --since PERIOD Time range (default: 5m). Examples: 5m, 30m, 1h, 2h
|
|
46
|
+
-l, --level LEVEL Minimum log level: DEBUG, INFO, WARN, ERROR (default: INFO)
|
|
47
|
+
--error Show only the most recent error and exit
|
|
48
|
+
--raw Show raw JSON logs without formatting
|
|
49
|
+
--no-color Disable colorized output
|
|
50
|
+
-h, --help Show this help
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
belt logs # Last 5m of all lambdas (current env)
|
|
54
|
+
belt logs api # Last 5m of api lambda
|
|
55
|
+
belt logs api -f # Follow api lambda logs
|
|
56
|
+
belt logs -e prod # Last 5m of all lambdas in prod
|
|
57
|
+
belt logs api -s 30m # Last 30 minutes
|
|
58
|
+
belt logs --error # Show most recent error across all lambdas
|
|
59
|
+
belt logs api --error # Show most recent error for api lambda
|
|
60
|
+
USAGE
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(args)
|
|
64
|
+
@lambda_name = nil
|
|
65
|
+
@env = nil
|
|
66
|
+
@follow = false
|
|
67
|
+
@since = '5m'
|
|
68
|
+
@level = 'INFO'
|
|
69
|
+
@error_mode = false
|
|
70
|
+
@raw = false
|
|
71
|
+
@color = $stdout.tty?
|
|
72
|
+
parse_args(args)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run
|
|
76
|
+
@env ||= detect_environment
|
|
77
|
+
abort 'Error: Cannot determine environment. Pass -e ENV or set BELT_ENV.' unless @env
|
|
78
|
+
|
|
79
|
+
@app_name = detect_app_name
|
|
80
|
+
abort 'Error: Cannot determine app name.' unless @app_name
|
|
81
|
+
|
|
82
|
+
if @lambda_name
|
|
83
|
+
tail_single(@lambda_name)
|
|
84
|
+
else
|
|
85
|
+
tail_all
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def parse_args(args)
|
|
92
|
+
i = 0
|
|
93
|
+
while i < args.length
|
|
94
|
+
case args[i]
|
|
95
|
+
when '-e', '--env'
|
|
96
|
+
@env = args[i + 1]
|
|
97
|
+
i += 2
|
|
98
|
+
when '-f', '--follow'
|
|
99
|
+
@follow = true
|
|
100
|
+
i += 1
|
|
101
|
+
when '-s', '--since'
|
|
102
|
+
@since = args[i + 1]
|
|
103
|
+
i += 2
|
|
104
|
+
when '-l', '--level'
|
|
105
|
+
@level = args[i + 1]&.upcase
|
|
106
|
+
i += 2
|
|
107
|
+
when '--error'
|
|
108
|
+
@error_mode = true
|
|
109
|
+
i += 1
|
|
110
|
+
when '--raw'
|
|
111
|
+
@raw = true
|
|
112
|
+
i += 1
|
|
113
|
+
when '--no-color'
|
|
114
|
+
@color = false
|
|
115
|
+
i += 1
|
|
116
|
+
else
|
|
117
|
+
@lambda_name = args[i] unless args[i].start_with?('-')
|
|
118
|
+
i += 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def detect_environment
|
|
124
|
+
ENV.fetch('BELT_ENV', nil) || detect_environments.first
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def tail_single(lambda_name)
|
|
128
|
+
log_group = log_group_for(lambda_name)
|
|
129
|
+
|
|
130
|
+
unless log_group_exists?(log_group)
|
|
131
|
+
abort "#{c(:red)}✗ No log group found: #{log_group}#{c(:reset)}\n " \
|
|
132
|
+
'The Lambda may not have been deployed yet.'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if @error_mode
|
|
136
|
+
find_recent_error(log_group, lambda_name)
|
|
137
|
+
elsif @follow
|
|
138
|
+
follow_logs(log_group, lambda_name)
|
|
139
|
+
else
|
|
140
|
+
fetch_historical(log_group, lambda_name)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def tail_all
|
|
145
|
+
lambdas = discover_lambda_names
|
|
146
|
+
if lambdas.empty?
|
|
147
|
+
abort "#{c(:red)}✗ No Lambda functions found for #{@app_name}-#{@env}#{c(:reset)}\n " \
|
|
148
|
+
"Deploy with `belt deploy #{@env}` first, or specify a lambda name: `belt logs api`"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if @error_mode
|
|
152
|
+
find_errors_across(lambdas)
|
|
153
|
+
elsif @follow
|
|
154
|
+
follow_all(lambdas)
|
|
155
|
+
else
|
|
156
|
+
fetch_all_historical(lambdas)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def discover_lambda_names
|
|
161
|
+
names = lambda_names_from_terraform
|
|
162
|
+
return names if names.any?
|
|
163
|
+
|
|
164
|
+
lambda_names_from_log_groups
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def lambda_names_from_terraform
|
|
168
|
+
infra_dir = find_infra_dir
|
|
169
|
+
env_dir = infra_dir ? File.join(infra_dir, @env) : nil
|
|
170
|
+
return [] unless env_dir && Dir.exist?(File.join(env_dir, '.terraform'))
|
|
171
|
+
|
|
172
|
+
Dir.chdir(env_dir) do
|
|
173
|
+
output, status = Open3.capture2('terraform', 'output', '-json')
|
|
174
|
+
return [] unless status.success?
|
|
175
|
+
|
|
176
|
+
data = begin
|
|
177
|
+
JSON.parse(output)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
{}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if data['lambda_functions']
|
|
183
|
+
funcs = data['lambda_functions']['value']
|
|
184
|
+
if funcs.is_a?(Hash)
|
|
185
|
+
return funcs.keys
|
|
186
|
+
elsif funcs.is_a?(Array)
|
|
187
|
+
return funcs.map { |f| f.is_a?(String) ? f.split('-').last : nil }.compact
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
data.keys.grep(/_function_name$/).map { |k| data[k]['value']&.split('-')&.last }.compact
|
|
192
|
+
end
|
|
193
|
+
rescue StandardError
|
|
194
|
+
[]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def lambda_names_from_log_groups
|
|
198
|
+
prefix = "/aws/lambda/#{@app_name}-#{@env}-"
|
|
199
|
+
output, status = Open3.capture2(
|
|
200
|
+
'aws', 'logs', 'describe-log-groups',
|
|
201
|
+
'--log-group-name-prefix', prefix,
|
|
202
|
+
'--query', 'logGroups[].logGroupName',
|
|
203
|
+
'--output', 'json'
|
|
204
|
+
)
|
|
205
|
+
return [] unless status.success?
|
|
206
|
+
|
|
207
|
+
groups = begin
|
|
208
|
+
JSON.parse(output)
|
|
209
|
+
rescue JSON::ParserError
|
|
210
|
+
[]
|
|
211
|
+
end
|
|
212
|
+
groups.map { |g| g.sub(prefix, '') }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def log_group_for(lambda_name)
|
|
216
|
+
"/aws/lambda/#{@app_name}-#{@env}-#{lambda_name}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def log_group_exists?(log_group)
|
|
220
|
+
_, status = Open3.capture2(
|
|
221
|
+
'aws', 'logs', 'describe-log-groups',
|
|
222
|
+
'--log-group-name-prefix', log_group,
|
|
223
|
+
'--query', "logGroups[?logGroupName=='#{log_group}'].logGroupName",
|
|
224
|
+
'--output', 'text'
|
|
225
|
+
)
|
|
226
|
+
status.success?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def follow_logs(log_group, lambda_name)
|
|
230
|
+
print_header(lambda_name)
|
|
231
|
+
puts "#{c(:yellow)}Following logs (Ctrl+C to stop)...#{c(:reset)}\n\n"
|
|
232
|
+
|
|
233
|
+
cmd = ['aws', 'logs', 'tail', log_group, '--follow', '--format', 'short']
|
|
234
|
+
IO.popen(cmd, err: %i[child out]) do |io|
|
|
235
|
+
io.each_line { |line| process_tail_line(line) }
|
|
236
|
+
end
|
|
237
|
+
rescue Interrupt
|
|
238
|
+
puts "\n#{c(:dim)}Stopped.#{c(:reset)}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def follow_all(lambdas)
|
|
242
|
+
puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
243
|
+
puts "#{c(:bold)}Lambda Logs: #{c(:magenta)}#{@app_name}-#{@env}#{c(:reset)} (#{lambdas.join(', ')})"
|
|
244
|
+
puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
245
|
+
puts "#{c(:yellow)}Following logs (Ctrl+C to stop)...#{c(:reset)}\n\n"
|
|
246
|
+
|
|
247
|
+
threads = lambdas.map do |name|
|
|
248
|
+
log_group = log_group_for(name)
|
|
249
|
+
Thread.new do
|
|
250
|
+
cmd = ['aws', 'logs', 'tail', log_group, '--follow', '--format', 'short']
|
|
251
|
+
IO.popen(cmd, err: %i[child out]) do |io|
|
|
252
|
+
io.each_line { |line| process_tail_line(line, prefix: name) }
|
|
253
|
+
end
|
|
254
|
+
rescue StandardError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
threads.each(&:join)
|
|
260
|
+
rescue Interrupt
|
|
261
|
+
puts "\n#{c(:dim)}Stopped.#{c(:reset)}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def fetch_historical(log_group, lambda_name)
|
|
265
|
+
print_header(lambda_name)
|
|
266
|
+
puts "#{c(:dim)}Fetching last #{@since} of logs...#{c(:reset)}\n\n"
|
|
267
|
+
|
|
268
|
+
events = fetch_log_events(log_group)
|
|
269
|
+
if events.empty?
|
|
270
|
+
puts "#{c(:yellow)}No logs found in the last #{@since}#{c(:reset)}"
|
|
271
|
+
return
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
puts "#{c(:dim)}Found #{events.length} log events#{c(:reset)}\n\n"
|
|
275
|
+
events.each { |event| process_event(event) }
|
|
276
|
+
|
|
277
|
+
puts "\n#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
278
|
+
puts "#{c(:dim)}Tip: Use -f to follow logs in real-time#{c(:reset)}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def fetch_all_historical(lambdas)
|
|
282
|
+
puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
283
|
+
puts "#{c(:bold)}Lambda Logs: #{c(:magenta)}#{@app_name}-#{@env}#{c(:reset)} (#{lambdas.join(', ')})"
|
|
284
|
+
puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
285
|
+
puts "#{c(:dim)}Fetching last #{@since} of logs...#{c(:reset)}\n\n"
|
|
286
|
+
|
|
287
|
+
all_events = []
|
|
288
|
+
lambdas.each do |name|
|
|
289
|
+
log_group = log_group_for(name)
|
|
290
|
+
events = fetch_log_events(log_group)
|
|
291
|
+
events.each { |e| e['_lambda'] = name }
|
|
292
|
+
all_events.concat(events)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
all_events.sort_by! { |e| e['timestamp'] || 0 }
|
|
296
|
+
|
|
297
|
+
if all_events.empty?
|
|
298
|
+
puts "#{c(:yellow)}No logs found in the last #{@since}#{c(:reset)}"
|
|
299
|
+
return
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
puts "#{c(:dim)}Found #{all_events.length} log events#{c(:reset)}\n\n"
|
|
303
|
+
all_events.each { |event| process_event(event, prefix: event['_lambda']) }
|
|
304
|
+
|
|
305
|
+
puts "\n#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
306
|
+
puts "#{c(:dim)}Tip: Use -f to follow logs in real-time#{c(:reset)}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def find_recent_error(log_group, lambda_name)
|
|
310
|
+
events = fetch_log_events(log_group, since: '30m', limit: 500)
|
|
311
|
+
error = find_error_in_events(events)
|
|
312
|
+
|
|
313
|
+
if error
|
|
314
|
+
puts "#{c(:bold)}Most recent error in #{c(:magenta)}#{lambda_name}#{c(:reset)}:\n\n"
|
|
315
|
+
format_error_event(error)
|
|
316
|
+
else
|
|
317
|
+
puts "#{c(:green)}✓ No errors found in the last 30 minutes for #{lambda_name}#{c(:reset)}"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def find_errors_across(lambdas)
|
|
322
|
+
latest_error = nil
|
|
323
|
+
latest_lambda = nil
|
|
324
|
+
|
|
325
|
+
lambdas.each do |name|
|
|
326
|
+
log_group = log_group_for(name)
|
|
327
|
+
events = fetch_log_events(log_group, since: '30m', limit: 500)
|
|
328
|
+
error = find_error_in_events(events)
|
|
329
|
+
next unless error
|
|
330
|
+
|
|
331
|
+
if latest_error.nil? || (error['timestamp'] || 0) > (latest_error['timestamp'] || 0)
|
|
332
|
+
latest_error = error
|
|
333
|
+
latest_lambda = name
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
if latest_error
|
|
338
|
+
puts "#{c(:bold)}Most recent error in #{c(:magenta)}#{latest_lambda}#{c(:reset)}:\n\n"
|
|
339
|
+
format_error_event(latest_error)
|
|
340
|
+
else
|
|
341
|
+
puts "#{c(:green)}✓ No errors found in the last 30 minutes#{c(:reset)}"
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def find_error_in_events(events)
|
|
346
|
+
events.reverse_each do |event|
|
|
347
|
+
msg = event['message'] || ''
|
|
348
|
+
json = parse_json(msg)
|
|
349
|
+
next unless json
|
|
350
|
+
|
|
351
|
+
return event if json['errorMessage'] && json['errorType'] && json['stackTrace']
|
|
352
|
+
return event if json['level'] == 'ERROR'
|
|
353
|
+
return event if json['status_code'].to_i >= 500
|
|
354
|
+
end
|
|
355
|
+
nil
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def format_error_event(event)
|
|
359
|
+
msg = event['message'] || ''
|
|
360
|
+
json = parse_json(msg)
|
|
361
|
+
return puts(msg) unless json
|
|
362
|
+
|
|
363
|
+
timestamp = format_timestamp(event['timestamp'])
|
|
364
|
+
|
|
365
|
+
if json['errorMessage'] && json['errorType']
|
|
366
|
+
format_init_error(json, timestamp)
|
|
367
|
+
else
|
|
368
|
+
format_structured_log(json, timestamp)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def format_init_error(json, timestamp)
|
|
373
|
+
puts "#{c(:gray)}#{timestamp}#{c(:reset)} #{c(:red)}#{c(:bold)}INIT ERROR#{c(:reset)}"
|
|
374
|
+
puts " #{c(:dim)}Type:#{c(:reset)} #{c(:red)}#{json['errorType']}#{c(:reset)}"
|
|
375
|
+
puts " #{c(:dim)}Message:#{c(:reset)} #{json['errorMessage']}"
|
|
376
|
+
return unless json['stackTrace'].is_a?(Array)
|
|
377
|
+
|
|
378
|
+
puts " #{c(:dim)}Stack:#{c(:reset)}"
|
|
379
|
+
json['stackTrace'].first(15).each do |line|
|
|
380
|
+
if line.match?(%r{(controllers|models|lib|helpers)/})
|
|
381
|
+
puts " #{c(:yellow)}→#{c(:reset)} #{line}"
|
|
382
|
+
elsif line.include?('/var/task/')
|
|
383
|
+
puts " #{c(:cyan)}→#{c(:reset)} #{line}"
|
|
384
|
+
else
|
|
385
|
+
puts " #{c(:gray)} #{line}#{c(:reset)}"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def fetch_log_events(log_group, since: @since, limit: 1000)
|
|
391
|
+
duration_ms = parse_since(since)
|
|
392
|
+
start_time = (Time.now.to_i * 1000) - duration_ms
|
|
393
|
+
|
|
394
|
+
output, status = Open3.capture2(
|
|
395
|
+
'aws', 'logs', 'filter-log-events',
|
|
396
|
+
'--log-group-name', log_group,
|
|
397
|
+
'--start-time', start_time.to_s,
|
|
398
|
+
'--limit', limit.to_s,
|
|
399
|
+
'--output', 'json'
|
|
400
|
+
)
|
|
401
|
+
return [] unless status.success?
|
|
402
|
+
|
|
403
|
+
data = begin
|
|
404
|
+
JSON.parse(output)
|
|
405
|
+
rescue JSON::ParserError
|
|
406
|
+
{}
|
|
407
|
+
end
|
|
408
|
+
data['events'] || []
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def process_tail_line(line, prefix: nil)
|
|
412
|
+
return if line.match?(/\b(START|END|REPORT|INIT_START|INIT_REPORT)\s/)
|
|
413
|
+
return if line.include?('[LambdaLoadout') || line.include?('"_aws":')
|
|
414
|
+
|
|
415
|
+
if line.include?('Critical exception from handler')
|
|
416
|
+
prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
|
|
417
|
+
puts "#{prefix_str}#{c(:red)}#{c(:bold)}CRITICAL EXCEPTION#{c(:reset)}"
|
|
418
|
+
return
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
json_part = line.sub(/\A[\dT:.+\-Z ]+\s*/, '').strip
|
|
422
|
+
json = parse_json(json_part)
|
|
423
|
+
|
|
424
|
+
if json
|
|
425
|
+
if json['errorMessage'] && json['errorType'] && json['stackTrace']
|
|
426
|
+
format_aws_error(json, prefix: prefix)
|
|
427
|
+
elsif @raw
|
|
428
|
+
puts json_part
|
|
429
|
+
else
|
|
430
|
+
timestamp = extract_time_from_line(line)
|
|
431
|
+
format_structured_log(json, timestamp, prefix: prefix)
|
|
432
|
+
end
|
|
433
|
+
elsif line.strip.length.positive?
|
|
434
|
+
prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
|
|
435
|
+
puts "#{prefix_str}#{c(:gray)}#{line.strip}#{c(:reset)}"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def process_event(event, prefix: nil)
|
|
440
|
+
msg = event['message'] || ''
|
|
441
|
+
|
|
442
|
+
return if msg.match?(/\A(START|END|REPORT|INIT_START|INIT_REPORT)\s/)
|
|
443
|
+
return if msg.include?('[LambdaLoadout') || msg.include?('"_aws":')
|
|
444
|
+
|
|
445
|
+
if msg.include?('Critical exception from handler')
|
|
446
|
+
prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
|
|
447
|
+
puts "#{prefix_str}#{c(:red)}#{c(:bold)}CRITICAL EXCEPTION#{c(:reset)}"
|
|
448
|
+
return
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
json = parse_json(msg)
|
|
452
|
+
timestamp = format_timestamp(event['timestamp'])
|
|
453
|
+
|
|
454
|
+
if json
|
|
455
|
+
if json['errorMessage'] && json['errorType'] && json['stackTrace']
|
|
456
|
+
format_aws_error(json, prefix: prefix)
|
|
457
|
+
elsif @raw
|
|
458
|
+
puts JSON.pretty_generate(json)
|
|
459
|
+
else
|
|
460
|
+
format_structured_log(json, timestamp, prefix: prefix)
|
|
461
|
+
end
|
|
462
|
+
elsif msg.strip.length.positive?
|
|
463
|
+
prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
|
|
464
|
+
puts "#{prefix_str}#{c(:gray)}#{msg.strip}#{c(:reset)}"
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def format_structured_log(json, timestamp, prefix: nil)
|
|
469
|
+
level = json['level']
|
|
470
|
+
message = json['message'] || ''
|
|
471
|
+
|
|
472
|
+
return unless should_show_level?(level)
|
|
473
|
+
|
|
474
|
+
prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
|
|
475
|
+
|
|
476
|
+
case level
|
|
477
|
+
when 'ERROR'
|
|
478
|
+
format_error_log(json, message, timestamp, prefix_str)
|
|
479
|
+
when 'WARN'
|
|
480
|
+
format_warn_log(json, message, timestamp, prefix_str)
|
|
481
|
+
else
|
|
482
|
+
format_info_log(json, message, timestamp, prefix_str)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def format_error_log(json, message, timestamp, prefix_str)
|
|
487
|
+
puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} " \
|
|
488
|
+
"#{c(:red)}ERROR#{c(:reset)} #{c(:bold)}#{message}#{c(:reset)}"
|
|
489
|
+
puts " #{c(:dim)}Action:#{c(:reset)} #{json['action']}" if json['action']
|
|
490
|
+
if json['error_class']
|
|
491
|
+
puts " #{c(:dim)}Error:#{c(:reset)} " \
|
|
492
|
+
"#{c(:red)}#{json['error_class']}#{c(:reset)}: #{json['error_message']}"
|
|
493
|
+
end
|
|
494
|
+
puts " #{c(:dim)}Path:#{c(:reset)} #{json['path']}" if json['path']
|
|
495
|
+
format_backtrace(json['backtrace'])
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def format_warn_log(json, message, timestamp, prefix_str)
|
|
499
|
+
puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} #{c(:yellow)}WARN#{c(:reset)} #{message}"
|
|
500
|
+
if json['error_class']
|
|
501
|
+
puts " #{c(:dim)}Error:#{c(:reset)} " \
|
|
502
|
+
"#{c(:red)}#{json['error_class']}#{c(:reset)}: #{json['error_message']}"
|
|
503
|
+
end
|
|
504
|
+
puts " #{c(:dim)}Path:#{c(:reset)} #{json['path']}" if json['path']
|
|
505
|
+
format_backtrace(json['backtrace'])
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def format_info_log(json, message, timestamp, prefix_str)
|
|
509
|
+
case message
|
|
510
|
+
when 'Lambda invoked'
|
|
511
|
+
path = strip_namespace(json['path'])
|
|
512
|
+
puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} " \
|
|
513
|
+
"#{c(:cyan)}Started#{c(:reset)} #{c(:bold)}#{json['http_method']}#{c(:reset)} #{path}"
|
|
514
|
+
when 'Request completed'
|
|
515
|
+
path = strip_namespace(json['path'])
|
|
516
|
+
sc = json['status_code'].to_i
|
|
517
|
+
sc_color = status_color(sc)
|
|
518
|
+
puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} " \
|
|
519
|
+
"#{c(:cyan)}Completed#{c(:reset)} #{c(sc_color)}#{sc}#{c(:reset)} #{path}"
|
|
520
|
+
else
|
|
521
|
+
level = json['level']
|
|
522
|
+
level_col = level_color_for(level)
|
|
523
|
+
puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} #{level_col}#{level}#{c(:reset)} #{message}" if level
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def format_aws_error(json, prefix: nil)
|
|
528
|
+
prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
|
|
529
|
+
puts "#{prefix_str} #{c(:dim)}Error Type:#{c(:reset)} #{c(:red)}#{json['errorType']}#{c(:reset)}"
|
|
530
|
+
puts "#{prefix_str} #{c(:dim)}Error Message:#{c(:reset)} #{json['errorMessage']}"
|
|
531
|
+
return unless json['stackTrace'].is_a?(Array)
|
|
532
|
+
|
|
533
|
+
puts "#{prefix_str} #{c(:dim)}Stack Trace:#{c(:reset)}"
|
|
534
|
+
json['stackTrace'].first(20).each do |line|
|
|
535
|
+
if line.match?(%r{(controllers|models|lib|helpers)/})
|
|
536
|
+
puts "#{prefix_str} #{c(:yellow)}→#{c(:reset)} #{line}"
|
|
537
|
+
elsif line.include?('/var/task/')
|
|
538
|
+
puts "#{prefix_str} #{c(:cyan)}→#{c(:reset)} #{line}"
|
|
539
|
+
else
|
|
540
|
+
puts "#{prefix_str} #{c(:gray)} #{line}#{c(:reset)}"
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def format_backtrace(backtrace)
|
|
546
|
+
return unless backtrace.is_a?(Array) && backtrace.any?
|
|
547
|
+
|
|
548
|
+
puts " #{c(:dim)}Backtrace:#{c(:reset)}"
|
|
549
|
+
backtrace.first(15).each do |line|
|
|
550
|
+
if line.match?(%r{(controllers|models|lib|helpers)/})
|
|
551
|
+
puts " #{c(:yellow)}→#{c(:reset)} #{line}"
|
|
552
|
+
else
|
|
553
|
+
puts " #{c(:gray)} #{line}#{c(:reset)}"
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def should_show_level?(level)
|
|
559
|
+
return true unless level
|
|
560
|
+
|
|
561
|
+
levels = { 'DEBUG' => 1, 'INFO' => 2, 'WARN' => 3, 'ERROR' => 4 }
|
|
562
|
+
(levels[level] || 0) >= (levels[@level] || 2)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def level_color_for(level)
|
|
566
|
+
case level
|
|
567
|
+
when 'INFO' then c(:green)
|
|
568
|
+
when 'WARN' then c(:yellow)
|
|
569
|
+
when 'ERROR' then c(:red)
|
|
570
|
+
else c(:gray)
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def status_color(code)
|
|
575
|
+
if code >= 500
|
|
576
|
+
:red
|
|
577
|
+
elsif code >= 400
|
|
578
|
+
:yellow
|
|
579
|
+
else
|
|
580
|
+
:green
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def strip_namespace(path)
|
|
585
|
+
return path unless path
|
|
586
|
+
|
|
587
|
+
path.sub(%r{\A/[^/]+}, '')
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def print_header(lambda_name)
|
|
591
|
+
full_name = "#{@app_name}-#{@env}-#{lambda_name}"
|
|
592
|
+
puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
593
|
+
puts "#{c(:bold)}Lambda Logs: #{c(:magenta)}#{full_name}#{c(:reset)}"
|
|
594
|
+
puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def format_timestamp(epoch_ms)
|
|
598
|
+
return '' unless epoch_ms
|
|
599
|
+
|
|
600
|
+
Time.at(epoch_ms / 1000.0).strftime('%H:%M:%S')
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def extract_time_from_line(line)
|
|
604
|
+
match = line.match(/\A(\d{4}-\d{2}-\d{2}T[\d:]+)/)
|
|
605
|
+
match ? match[1].split('T').last : ''
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def parse_since(value)
|
|
609
|
+
num = value.to_i
|
|
610
|
+
case value
|
|
611
|
+
when /h\z/ then num * 60 * 60 * 1000
|
|
612
|
+
when /m\z/ then num * 60 * 1000
|
|
613
|
+
when /s\z/ then num * 1000
|
|
614
|
+
else num * 60 * 1000
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def parse_json(str)
|
|
619
|
+
JSON.parse(str)
|
|
620
|
+
rescue StandardError
|
|
621
|
+
nil
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def find_infra_dir
|
|
625
|
+
candidates = %w[infrastructure infra]
|
|
626
|
+
candidates.map { |d| File.join(Dir.pwd, d) }.find { |d| Dir.exist?(d) }
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def c(name)
|
|
630
|
+
@color ? COLORS[name].to_s : ''
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -167,8 +167,8 @@ module Belt
|
|
|
167
167
|
'lambda/controllers/application_controller.rb.erb' =>
|
|
168
168
|
"#{@app_name}/lambda/controllers/api/application_controller.rb",
|
|
169
169
|
'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
|
|
170
|
-
'config/routes.
|
|
171
|
-
'config/
|
|
170
|
+
'config/routes.rb.erb' => "#{@app_name}/config/routes.rb",
|
|
171
|
+
'config/contracts.rb.erb' => "#{@app_name}/config/contracts.rb",
|
|
172
172
|
'config/lambda/api.yml.erb' => "#{@app_name}/config/lambda/api.yml",
|
|
173
173
|
'README.md.erb' => "#{@app_name}/README.md",
|
|
174
174
|
'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
|
|
@@ -8,14 +8,14 @@ module Belt
|
|
|
8
8
|
private
|
|
9
9
|
|
|
10
10
|
def load_schema_models(routes_file)
|
|
11
|
-
schema_file =
|
|
11
|
+
schema_file = resolve_contracts_file(routes_file)
|
|
12
12
|
return [] unless schema_file && File.exist?(schema_file)
|
|
13
13
|
|
|
14
14
|
Belt.instance_variable_set(:@application, nil)
|
|
15
15
|
begin
|
|
16
16
|
eval(File.read(schema_file), binding, schema_file) # rubocop:disable Security/Eval
|
|
17
17
|
rescue StandardError => e
|
|
18
|
-
warn "Warning: Failed to load
|
|
18
|
+
warn "Warning: Failed to load contracts file #{schema_file}: #{e.message}"
|
|
19
19
|
return []
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -23,15 +23,25 @@ module Belt
|
|
|
23
23
|
build_models_from_schema(schema)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def resolve_contracts_file(routes_file)
|
|
27
27
|
schema_file = @options[:schema_file]
|
|
28
28
|
unless schema_file
|
|
29
29
|
routes_dir = File.dirname(File.expand_path(routes_file))
|
|
30
|
-
|
|
30
|
+
# Check new convention first, then legacy names
|
|
31
|
+
candidates = [
|
|
32
|
+
File.join(routes_dir, 'contracts.rb'),
|
|
33
|
+
File.join(routes_dir, 'contracts.tf.rb'),
|
|
34
|
+
File.join(routes_dir, 'schema.tf.rb')
|
|
35
|
+
]
|
|
36
|
+
schema_file = candidates.find { |f| File.exist?(f) }
|
|
37
|
+
|
|
31
38
|
# Fall back to infrastructure/ if not found in same directory as routes
|
|
32
|
-
unless
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
unless schema_file
|
|
40
|
+
legacy_candidates = [
|
|
41
|
+
'infrastructure/contracts.rb',
|
|
42
|
+
'infrastructure/schema.tf.rb'
|
|
43
|
+
]
|
|
44
|
+
schema_file = legacy_candidates.find { |f| File.exist?(f) }
|
|
35
45
|
end
|
|
36
46
|
end
|
|
37
47
|
schema_file
|