1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
|
# Jamstack structured logging (R2): leveled, structured (NDJSON) records with a
# monotonic frame counter, an in-memory ring buffer the agent can query over the
# bridge (Log.tail / Log.grep), and stdout + optional file sinks. This is the
# "game-console" stream (Ruby/engine intent). The browser-console (web platform
# console) is deferred (see roadmap R2/R4).
#
# Usage:
# Jamstack::Log.info("spawned", tag: "spawn", entity: id)
# Jamstack::Log.error("bad state", entity: id)
# Jamstack::Log.exception(e, tag: "physics")
# Jamstack::Log.tail(50) # last N records (Array of Hashes)
# Jamstack::Log.grep("physics") # substring match (no Regexp in this gembox)
# Jamstack::Log.tail_ndjson(50) # last N as an NDJSON string (for the agent)
#
# Env (read at setup, via Jamstack.getenv): JAMSTACK_LOG=<path> (file sink),
# JAMSTACK_LOG_LEVEL=debug|info|warn|error (min level).
module Jamstack
module Log
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }
@level = :debug
@frame = 0
@ring = []
@ring_max = 1000
@to_stdout = true
@file = nil
@setup_done = false
class << self
attr_accessor :ring_max, :to_stdout
attr_reader :level, :frame
def level=(sym)
@level = sym.to_sym if LEVELS.key?(sym.to_sym)
end
def setup
return if @setup_done
@setup_done = true
lvl = Jamstack.getenv('JAMSTACK_LOG_LEVEL')
self.level = lvl if lvl && !lvl.empty?
path = Jamstack.getenv('JAMSTACK_LOG')
open_file(path) if path && !path.empty?
end
def open_file(path)
@file = File.open(path, 'a')
rescue StandardError
@file = nil
end
def tick!; @frame += 1; end
def log(level, msg = nil, fields = {})
return nil if LEVELS[level].to_i < LEVELS[@level].to_i
rec = build(level, msg, fields)
sink(Jamstack::JSON.generate(rec)) # render BEFORE pushing (cheap, ordered)
push(rec)
rec
end
def debug(msg = nil, fields = {}); log(:debug, msg, fields); end
def info(msg = nil, fields = {}); log(:info, msg, fields); end
def warn(msg = nil, fields = {}); log(:warn, msg, fields); end
def error(msg = nil, fields = {}); log(:error, msg, fields); end
# Capture an exception as an error record with class + backtrace.
def exception(e, fields = {})
f = { error: "#{e.class}: #{e.message}", backtrace: (e.backtrace || []) }
f.merge!(fields)
log(:error, e.message, f)
end
# --- agent query surface (read the ring without a file) ---
def tail(n = 50); @ring.last(n); end
def grep(substr, n = 200)
s = substr.to_s
@ring.select { |r| Jamstack::JSON.generate(r).include?(s) }.last(n)
end
def tail_ndjson(n = 50)
tail(n).map { |r| Jamstack::JSON.generate(r) }.join("\n")
end
def clear!; @ring = []; end
private
def build(level, msg, fields)
rec = {}
rec["ts"] = now
rec["frame"] = @frame
rec["level"] = level.to_s
rec["msg"] = msg.to_s unless msg.nil?
fields.each { |k, v| rec[k.to_s] = v }
rec
end
def now
Time.now.to_f
rescue StandardError
0.0
end
def push(rec)
@ring << rec
@ring.shift while @ring.length > @ring_max
end
# NOTE: stdout writes to C fd 1. If a log is emitted *during* a bridge eval
# (while fd 1 is redirected for stdout capture), the line lands in that
# eval's captured stdout instead of the console — the ring buffer still
# records it, which is the canonical query path. See agent-bridge.md.
def sink(line)
puts line if @to_stdout
if @file
@file.write(line)
@file.write("\n")
@file.flush
end
end
end
end
end
|