tina4ruby 3.13.97 → 3.13.99
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 +84 -0
- data/lib/tina4/ai.rb +32 -3
- data/lib/tina4/api.rb +5 -0
- data/lib/tina4/auto_crud.rb +62 -4
- data/lib/tina4/background.rb +112 -31
- data/lib/tina4/cache.rb +3 -2
- data/lib/tina4/cli.rb +55 -67
- data/lib/tina4/database.rb +97 -49
- data/lib/tina4/database_adapter.rb +169 -15
- data/lib/tina4/dev_admin.rb +137 -9
- data/lib/tina4/dispatch_pipeline.rb +145 -4
- data/lib/tina4/drivers/firebird_driver.rb +59 -12
- data/lib/tina4/drivers/mongodb_driver.rb +98 -14
- data/lib/tina4/drivers/mssql_driver.rb +39 -2
- data/lib/tina4/drivers/mysql_driver.rb +43 -3
- data/lib/tina4/drivers/odbc_driver.rb +36 -2
- data/lib/tina4/drivers/postgres_driver.rb +5 -0
- data/lib/tina4/drivers/sqlite_driver.rb +11 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +43 -49
- data/lib/tina4/field_types.rb +33 -16
- data/lib/tina4/frond.rb +24 -2
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
- data/lib/tina4/graphql.rb +2 -2
- data/lib/tina4/log.rb +652 -485
- data/lib/tina4/mcp.rb +9 -1
- data/lib/tina4/messenger.rb +25 -0
- data/lib/tina4/middleware.rb +189 -76
- data/lib/tina4/migration.rb +47 -15
- data/lib/tina4/orm.rb +280 -59
- data/lib/tina4/port_takeover.rb +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
- data/lib/tina4/rack_app.rb +201 -59
- data/lib/tina4/realtime.rb +6 -1
- data/lib/tina4/request.rb +259 -51
- data/lib/tina4/router.rb +20 -2
- data/lib/tina4/seeder.rb +68 -19
- data/lib/tina4/shutdown.rb +4 -0
- data/lib/tina4/sql_translator.rb +115 -86
- data/lib/tina4/swagger.rb +19 -3
- data/lib/tina4/template.rb +61 -6
- data/lib/tina4/test_client.rb +49 -3
- data/lib/tina4/testing.rb +16 -11
- data/lib/tina4/validator.rb +7 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -40
- data/lib/tina4.rb +12 -1
- metadata +3 -19
- data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
- data/lib/tina4/scss/tina4css/_badges.scss +0 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
- data/lib/tina4/scss/tina4css/_cards.scss +0 -49
- data/lib/tina4/scss/tina4css/_forms.scss +0 -156
- data/lib/tina4/scss/tina4css/_grid.scss +0 -81
- data/lib/tina4/scss/tina4css/_modals.scss +0 -84
- data/lib/tina4/scss/tina4css/_nav.scss +0 -149
- data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
- data/lib/tina4/scss/tina4css/_reset.scss +0 -94
- data/lib/tina4/scss/tina4css/_tables.scss +0 -54
- data/lib/tina4/scss/tina4css/_typography.scss +0 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
- data/lib/tina4/scss/tina4css/_variables.scss +0 -117
- data/lib/tina4/scss/tina4css/base.scss +0 -1
- data/lib/tina4/scss/tina4css/colors.scss +0 -48
- data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/log.rb
CHANGED
|
@@ -2,575 +2,742 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
-
require "
|
|
5
|
+
require "digest"
|
|
6
6
|
|
|
7
7
|
module Tina4
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
# Invalid setting, removed setting, or an inaccessible selected sink.
|
|
9
|
+
class LogConfigurationError < ArgumentError
|
|
10
|
+
attr_accessor :setting, :value, :accepted, :sink, :operation
|
|
11
|
+
|
|
12
|
+
def initialize(message, setting: nil, value: nil, accepted: nil, sink: nil, operation: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@setting = setting
|
|
15
|
+
@value = value
|
|
16
|
+
@accepted = accepted
|
|
17
|
+
@sink = sink
|
|
18
|
+
@operation = operation
|
|
19
|
+
end
|
|
20
|
+
end
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
# Invalid argument to a public logger method.
|
|
23
|
+
class LogArgumentError < ArgumentError
|
|
24
|
+
attr_accessor :argument, :accepted
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
def initialize(message, argument: nil, accepted: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@argument = argument
|
|
29
|
+
@accepted = accepted
|
|
30
|
+
end
|
|
31
|
+
end
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
# A selected sink failed after configuration succeeded, under strict mode.
|
|
34
|
+
class LogWriteError < RuntimeError
|
|
35
|
+
attr_accessor :sink, :operation
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
def initialize(message, sink: nil, operation: nil)
|
|
38
|
+
super(message)
|
|
39
|
+
@sink = sink
|
|
40
|
+
@operation = operation
|
|
41
|
+
end
|
|
42
|
+
end
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
# One owned log file: bounded, PREDICTIVE rotation guarded by a single
|
|
45
|
+
# in-process (thread) exclusive lock over the size check, rotation and
|
|
46
|
+
# append.
|
|
47
|
+
#
|
|
48
|
+
# Decision 20 (2026-08-10 owner override): SINGLE FILE + IN-PROCESS LOCK
|
|
49
|
+
# ONLY. Cross-process exclusive locking is deliberately not implemented;
|
|
50
|
+
# concurrent PROCESSES writing the same file may interleave. Run one file
|
|
51
|
+
# per process, or route through a log shipper, for that case.
|
|
52
|
+
class LogFileSink
|
|
53
|
+
LOCK_TIMEOUT_SECONDS = 2.0
|
|
54
|
+
|
|
55
|
+
attr_reader :path
|
|
56
|
+
|
|
57
|
+
def initialize(path, rotate_size, rotate_keep)
|
|
58
|
+
@path = path
|
|
59
|
+
@rotate_size = rotate_size
|
|
60
|
+
@rotate_keep = rotate_keep
|
|
61
|
+
@mutex = Mutex.new
|
|
62
|
+
end
|
|
41
63
|
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# MEASURED 2026-08-01 on a genuinely full 1MB HFS+ ram disk (0 KB free),
|
|
52
|
-
# Ruby 4.0.2: with TINA4_LOG_STRICT=true, Tina4::Log.info(...) printed
|
|
53
|
-
# "log writing failed. No space left on device @ rb_sys_fail_on_write" to
|
|
54
|
-
# stderr and returned normally. The operator got a stderr warning and strict
|
|
55
|
-
# mode did nothing.
|
|
56
|
-
#
|
|
57
|
-
# ::Logger has a first-class seam for exactly this — `reraise_write_errors:`
|
|
58
|
-
# (logger >= 1.5.0), which handle_write_errors re-raises through instead of
|
|
59
|
-
# warning. Listing the two classes #write_to_file already rescues keeps the
|
|
60
|
-
# two ends of the strict path in agreement. Note Errno::ENOSPC (and every
|
|
61
|
-
# other errno) is a SystemCallError, so the real disk-full case is covered.
|
|
62
|
-
STRICT_WRITE_ERRORS = [IOError, SystemCallError].freeze
|
|
63
|
-
|
|
64
|
-
# A log device that writes ONLY log lines.
|
|
65
|
-
#
|
|
66
|
-
# stdlib ::Logger stamps a banner as the FIRST LINE of every file it creates
|
|
67
|
-
# — and it creates one on every rotation too:
|
|
68
|
-
#
|
|
69
|
-
# # Logfile created on 2026-08-01 21:49:10 +0200 by logger.rb/v1.7.0
|
|
70
|
-
#
|
|
71
|
-
# In JSON mode (TINA4_LOG_FORMAT=json) that is not JSON, so any line-oriented
|
|
72
|
-
# shipper — Filebeat, Fluent Bit, Vector, promtail, `jq -c` — fails on line 1
|
|
73
|
-
# of every file and every rotated file. It is a Ruby-only artifact: no other
|
|
74
|
-
# Tina4 framework emits it, so a JSON pipeline that works on Python/PHP/Node
|
|
75
|
-
# breaks here and nowhere else.
|
|
76
|
-
#
|
|
77
|
-
# Suppressing it in the DEVICE (rather than pre-creating the file) covers
|
|
78
|
-
# rotation as well, and keeps ::Logger's own rotation/locking untouched —
|
|
79
|
-
# add_log_header is the single method whose whole body is that banner.
|
|
80
|
-
class HeaderlessLogDevice < ::Logger::LogDevice
|
|
81
|
-
def add_log_header(file); end
|
|
64
|
+
# Create the directory and prove the file is writable.
|
|
65
|
+
def open
|
|
66
|
+
dir = File.dirname(@path)
|
|
67
|
+
FileUtils.mkdir_p(dir)
|
|
68
|
+
File.open(@path, "a") {}
|
|
69
|
+
rescue SystemCallError, IOError => e
|
|
70
|
+
raise LogConfigurationError.new(
|
|
71
|
+
"cannot open log sink #{@path}: #{e.message}", sink: @path, operation: "open"
|
|
72
|
+
)
|
|
82
73
|
end
|
|
83
74
|
|
|
75
|
+
def rotate_if_needed(next_record_bytes)
|
|
76
|
+
current_size = File.exist?(@path) ? File.size(@path) : 0
|
|
77
|
+
return if current_size.zero?
|
|
78
|
+
return if current_size + next_record_bytes <= @rotate_size
|
|
79
|
+
|
|
80
|
+
if @rotate_keep <= 0
|
|
81
|
+
File.delete(@path)
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
oldest = "#{@path}.#{@rotate_keep}"
|
|
86
|
+
File.delete(oldest) if File.exist?(oldest)
|
|
87
|
+
(@rotate_keep - 1).downto(1) do |n|
|
|
88
|
+
src = "#{@path}.#{n}"
|
|
89
|
+
dst = "#{@path}.#{n + 1}"
|
|
90
|
+
File.rename(src, dst) if File.exist?(src)
|
|
91
|
+
end
|
|
92
|
+
File.rename(@path, "#{@path}.1")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Append one complete encoded record, rotating first if it would cross
|
|
96
|
+
# the threshold. Raises LogWriteError (timeout/write) to the caller,
|
|
97
|
+
# which applies the sink failure policy.
|
|
98
|
+
def write(encoded_line)
|
|
99
|
+
clean = encoded_line.gsub(/\e\[[0-9;]*m/, "")
|
|
100
|
+
payload = clean.b
|
|
101
|
+
|
|
102
|
+
acquired = try_lock_with_timeout
|
|
103
|
+
raise LogWriteError.new("timed out acquiring the log sink lock for #{@path}", sink: @path, operation: "lock") unless acquired
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
rotate_if_needed(payload.bytesize)
|
|
107
|
+
File.open(@path, "ab") { |f| f.write(payload) }
|
|
108
|
+
rescue SystemCallError, IOError => e
|
|
109
|
+
raise LogWriteError.new("cannot write log sink #{@path}: #{e.message}", sink: @path, operation: "write")
|
|
110
|
+
ensure
|
|
111
|
+
@mutex.unlock if @mutex.owned?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def close; end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def try_lock_with_timeout
|
|
120
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + LOCK_TIMEOUT_SECONDS
|
|
121
|
+
until @mutex.try_lock
|
|
122
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
123
|
+
|
|
124
|
+
sleep(0.005)
|
|
125
|
+
end
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Structured logger. Conformant to the shared cross-framework contract at
|
|
131
|
+
# plan/v3/fixtures/logger_contract.json (feature 2), decided in
|
|
132
|
+
# plan/v3/features/002-structured-logger.md and ADR-0041.
|
|
133
|
+
#
|
|
134
|
+
# BREAKING CHANGES from the pre-3.14 logger (this pass, 2026-08-13):
|
|
135
|
+
#
|
|
136
|
+
# * Format defaults to JSON in production and TEXT only when TINA4_DEBUG
|
|
137
|
+
# is truthy (Decision 3) -- unchanged in spirit, restated as the shared
|
|
138
|
+
# contract's canonical rule.
|
|
139
|
+
# * TINA4_LOG_APPEND is REMOVED -- setting it is now a hard configuration
|
|
140
|
+
# error.
|
|
141
|
+
# * TINA4_LOG_STRICT / TINA4_LOG_FUNC accept ONLY the literal tokens
|
|
142
|
+
# "true"/"false" (case-insensitive) -- not "1"/"yes"/"on" (Decision 19:
|
|
143
|
+
# "native booleans, not private truth-token parsing").
|
|
144
|
+
# * The legacy bracket level spelling ("[TINA4_LOG_ERROR]") is REMOVED --
|
|
145
|
+
# it now hard-fails configuration; use the plain name ("ERROR").
|
|
146
|
+
# * Embedded CR/LF in a message is now ESCAPED in text format rather than
|
|
147
|
+
# passed through raw (Decision 11), and rotation is delegated to a
|
|
148
|
+
# hand-written, PREDICTIVE, byte-exact LogFileSink rather than stdlib
|
|
149
|
+
# ::Logger (whose backup numbering starts at ".0", not ".1", and whose
|
|
150
|
+
# rotation is reactive).
|
|
151
|
+
# * New TINA4_LOG_FILE_LEVEL (default ALL) independently gates the FILE
|
|
152
|
+
# sink; TINA4_LOG_LEVEL now gates the CONSOLE only (2026-08-10 owner
|
|
153
|
+
# override of Decision 8). `enabled?` accepts an optional sink: and is
|
|
154
|
+
# sink-aware.
|
|
155
|
+
# * `reset` is new: flushes/closes owned sinks and clears the snapshot AND
|
|
156
|
+
# the current thread's request id.
|
|
157
|
+
# * `close_file_logger` is removed (LOG-A02 prohibits it); `reset` is the
|
|
158
|
+
# one lifecycle method now.
|
|
159
|
+
module Log
|
|
160
|
+
LEVELS = { "ALL" => 0, "DEBUG" => 1, "INFO" => 2, "WARNING" => 3, "ERROR" => 4, "CRITICAL" => 5, "NONE" => 6 }.freeze
|
|
161
|
+
DEFAULT_LEVEL = "INFO"
|
|
162
|
+
DEFAULT_FILE_LEVEL = "ALL"
|
|
163
|
+
DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024
|
|
164
|
+
DEFAULT_ROTATE_KEEP = 5
|
|
165
|
+
MIN_ROTATE_SIZE = 1024
|
|
166
|
+
STDOUT_MAX_BYTES = 8192
|
|
167
|
+
OVERFLOW_MESSAGE = "Log event omitted: encoded size exceeds sink limit"
|
|
168
|
+
|
|
169
|
+
REMOVED_SETTINGS = {
|
|
170
|
+
"TINA4_LOG_MAX_SIZE" => "removed setting -- use TINA4_LOG_ROTATE_SIZE (bytes, not megabytes)",
|
|
171
|
+
"TINA4_LOG_KEEP" => "removed setting -- use TINA4_LOG_ROTATE_KEEP",
|
|
172
|
+
"TINA4_LOG_APPEND" => "removed setting -- logs always append; truncate explicitly outside logger startup",
|
|
173
|
+
"TINA4_DEBUG_LEVEL" => "removed setting -- use TINA4_LOG_LEVEL",
|
|
174
|
+
"TINA4_LOG_CRITICAL" => "removed setting -- critical always emits, subject only to TINA4_LOG_LEVEL"
|
|
175
|
+
}.freeze
|
|
176
|
+
|
|
177
|
+
COLORS = { "DEBUG" => "\e[36m", "INFO" => "\e[32m", "WARNING" => "\e[33m", "ERROR" => "\e[31m", "CRITICAL" => "\e[35m" }.freeze
|
|
178
|
+
RESET = "\e[0m"
|
|
179
|
+
|
|
180
|
+
JSON_KEY_ORDER = %w[timestamp level message request_id function context].freeze
|
|
181
|
+
CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.freeze
|
|
182
|
+
|
|
84
183
|
class << self
|
|
85
|
-
|
|
184
|
+
# ── configuration ──────────────────────────────────────────────
|
|
86
185
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
# Logs land in a `logs/` folder by default. The argument OVERRIDES that,
|
|
90
|
-
# and it accepts a DIRECTORY or a FILE PATH:
|
|
91
|
-
#
|
|
92
|
-
# configure -> ./logs/tina4.log + ./logs/error.log
|
|
93
|
-
# configure("/var/log/myapp") -> /var/log/myapp/tina4.log + error.log
|
|
94
|
-
# configure("/var/log/myapp/app.log") -> that exact file (no error.log sibling)
|
|
95
|
-
# TINA4_LOG_DIR=log -> ./log/
|
|
186
|
+
# Resolve and activate a new configuration snapshot.
|
|
96
187
|
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
188
|
+
# Precedence for every field (ADR-0041): explicit argument, then the
|
|
189
|
+
# matching TINA4_LOG_* environment value, then the built-in default.
|
|
190
|
+
# Every field is validated BEFORE any directory is created or file is
|
|
191
|
+
# opened; a failed reconfiguration leaves the prior snapshot untouched.
|
|
192
|
+
def configure(log_dir: nil, log_file: nil, level: nil, file_level: nil, format: nil,
|
|
193
|
+
output: nil, rotate_size: nil, rotate_keep: nil, strict: nil, caller_capture: nil)
|
|
194
|
+
REMOVED_SETTINGS.each do |name, hint|
|
|
195
|
+
raise LogConfigurationError.new("#{name} is a removed setting -- #{hint}", setting: name, value: ENV[name]) if ENV.key?(name)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
resolved_level = resolve_level(level, "TINA4_LOG_LEVEL", DEFAULT_LEVEL)
|
|
199
|
+
resolved_file_level = resolve_level(file_level, "TINA4_LOG_FILE_LEVEL", DEFAULT_FILE_LEVEL)
|
|
200
|
+
resolved_format = resolve_format(format)
|
|
201
|
+
stdout_enabled, file_enabled = resolve_output(output)
|
|
202
|
+
resolved_rotate_size = resolve_int(rotate_size, "TINA4_LOG_ROTATE_SIZE", DEFAULT_ROTATE_SIZE, MIN_ROTATE_SIZE)
|
|
203
|
+
resolved_rotate_keep = resolve_int(rotate_keep, "TINA4_LOG_ROTATE_KEEP", DEFAULT_ROTATE_KEEP, 0)
|
|
204
|
+
resolved_strict = resolve_bool(strict, "TINA4_LOG_STRICT", false)
|
|
205
|
+
resolved_caller = resolve_bool(caller_capture, "TINA4_LOG_FUNC", false)
|
|
206
|
+
|
|
207
|
+
dir_raw = resolve_str(log_dir, "TINA4_LOG_DIR", "logs", allow_empty: false)
|
|
208
|
+
file_raw = resolve_str(log_file, "TINA4_LOG_FILE", nil, allow_empty: true)
|
|
209
|
+
|
|
210
|
+
project_root = Dir.pwd
|
|
211
|
+
dir_candidate = dir_raw
|
|
212
|
+
file_candidate = file_raw
|
|
213
|
+
if file_candidate.nil? && target_is_file?(dir_candidate)
|
|
214
|
+
file_candidate = File.basename(dir_candidate)
|
|
215
|
+
dir_candidate = File.dirname(dir_candidate)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
resolved_log_dir = File.absolute_path?(dir_candidate) ? dir_candidate : File.join(project_root, dir_candidate)
|
|
219
|
+
resolved_log_dir = resolved_log_dir.chomp("/")
|
|
220
|
+
|
|
221
|
+
if file_candidate && !file_candidate.empty?
|
|
222
|
+
resolved_log_file = File.absolute_path?(file_candidate) ? file_candidate : File.join(resolved_log_dir, file_candidate)
|
|
223
|
+
layout = "single"
|
|
118
224
|
else
|
|
119
|
-
|
|
120
|
-
|
|
225
|
+
resolved_log_file = nil
|
|
226
|
+
layout = "directory"
|
|
121
227
|
end
|
|
122
|
-
FileUtils.mkdir_p(@log_dir)
|
|
123
|
-
|
|
124
|
-
# A file path passed to configure() wins, then TINA4_LOG_FILE (absolute
|
|
125
|
-
# or relative to log_dir). Default: <log_dir>/tina4.log.
|
|
126
|
-
log_file_env = ENV["TINA4_LOG_FILE"]
|
|
127
|
-
log_file_env = nil if log_file_env && log_file_env.empty?
|
|
128
|
-
@log_file_path = if explicit_target_file
|
|
129
|
-
explicit_target_file
|
|
130
|
-
elsif log_file_env
|
|
131
|
-
File.absolute_path?(log_file_env) ? log_file_env : File.join(@log_dir, log_file_env)
|
|
132
|
-
else
|
|
133
|
-
File.join(@log_dir, "tina4.log")
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# TINA4_LOG_ROTATE_SIZE — bytes per file before rotation. 0 = no rotation.
|
|
137
|
-
@rotate_size = (ENV["TINA4_LOG_ROTATE_SIZE"] || DEFAULT_ROTATE_SIZE).to_i
|
|
138
|
-
# TINA4_LOG_ROTATE_KEEP — number of rotated backups to keep.
|
|
139
|
-
@rotate_keep = (ENV["TINA4_LOG_ROTATE_KEEP"] || DEFAULT_ROTATE_KEEP).to_i
|
|
140
|
-
|
|
141
|
-
# TINA4_LOG_FORMAT — "text" or "json". TEXT IS THE DEFAULT, always.
|
|
142
|
-
#
|
|
143
|
-
# Owner decision 2026-08-01: nothing but an explicit TINA4_LOG_FORMAT=json
|
|
144
|
-
# selects JSON. The implicit "production means JSON" switch is DELETED in
|
|
145
|
-
# all four frameworks, because MEASURED, "production" meant four different
|
|
146
|
-
# things and it silently picked your log format:
|
|
147
|
-
#
|
|
148
|
-
# node !isTruthy(TINA4_DEBUG) -> JSON with TINA4_DEBUG unset
|
|
149
|
-
# ruby TINA4_ENV|RACK_ENV|RUBY_ENV == "production"
|
|
150
|
-
# python only via configure(production=True)
|
|
151
|
-
# php no switch at all — JSON was the shipped default
|
|
152
|
-
#
|
|
153
|
-
# Same machine, same .env, four formats. An OBJECT (Hash/Array) passed as
|
|
154
|
-
# the message is still JSON-encoded INLINE inside the text line — that is
|
|
155
|
-
# coerce_message's job and it is unchanged.
|
|
156
|
-
format_env = ENV["TINA4_LOG_FORMAT"]
|
|
157
|
-
@format = format_env && !format_env.empty? ? format_env.downcase : "text"
|
|
158
|
-
@json_mode = @format == "json"
|
|
159
|
-
|
|
160
|
-
# TINA4_LOG_OUTPUT — "stdout", "file", or "both".
|
|
161
|
-
#
|
|
162
|
-
# Default (UNSET): stdout is ALWAYS on. The log FILE (tina4.log + any
|
|
163
|
-
# error log) is written ONLY in development — i.e. when TINA4_DEBUG is
|
|
164
|
-
# truthy. In production / containers (TINA4_DEBUG falsy) the logger is
|
|
165
|
-
# stdout-only: writing a log file inside a container just bloats the
|
|
166
|
-
# writable layer + disk, and 12-factor wants logs on stdout for the
|
|
167
|
-
# platform to capture. An explicit TINA4_LOG_OUTPUT=file/both (or an
|
|
168
|
-
# explicit TINA4_LOG_FILE path) overrides this and STILL writes a file.
|
|
169
|
-
# Mirrors the Python master (debug/__init__.py configure()).
|
|
170
|
-
# An explicit TINA4_LOG_FILE always wins: a path the operator named must
|
|
171
|
-
# be written even in production (parity with the Python master, where an
|
|
172
|
-
# explicit log_file builds a writer unconditionally), so the dev-gated
|
|
173
|
-
# default below resolves to "both" (stdout + file) rather than "stdout".
|
|
174
|
-
# "The operator named ONE file" — via TINA4_LOG_FILE or by passing a file
|
|
175
|
-
# path to configure(). Either way a file must be written even in
|
|
176
|
-
# production, and no error.log sibling appears next to it.
|
|
177
|
-
explicit_file = !log_file_env.nil? || !explicit_target_file.nil?
|
|
178
|
-
default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"])
|
|
179
|
-
"both"
|
|
180
|
-
else
|
|
181
|
-
"stdout"
|
|
182
|
-
end
|
|
183
|
-
output_env = ENV["TINA4_LOG_OUTPUT"]
|
|
184
|
-
@output = if output_env && !output_env.empty?
|
|
185
|
-
output_env.downcase
|
|
186
|
-
else
|
|
187
|
-
default_output
|
|
188
|
-
end
|
|
189
|
-
@output = default_output unless %w[stdout file both].include?(@output)
|
|
190
|
-
|
|
191
|
-
# TINA4_LOG_STRICT — when true, raise on log write failures instead of swallowing.
|
|
192
|
-
@strict = truthy?(ENV["TINA4_LOG_STRICT"])
|
|
193
|
-
|
|
194
|
-
@console_level = resolve_level
|
|
195
|
-
@request_id = nil
|
|
196
|
-
@current_context = {}
|
|
197
|
-
@mutex = Mutex.new
|
|
198
228
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
output_selector = if stdout_enabled && file_enabled
|
|
230
|
+
"both"
|
|
231
|
+
else
|
|
232
|
+
file_enabled ? "file" : "stdout"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
snap = {
|
|
236
|
+
level: resolved_level, file_level: resolved_file_level, format: resolved_format,
|
|
237
|
+
output: output_selector, log_dir: resolved_log_dir, log_file: resolved_log_file,
|
|
238
|
+
layout: layout, rotate_size: resolved_rotate_size, rotate_keep: resolved_rotate_keep,
|
|
239
|
+
strict: resolved_strict, caller_capture: resolved_caller,
|
|
240
|
+
stdout_enabled: stdout_enabled, file_enabled: file_enabled,
|
|
241
|
+
main_sink: nil, error_sink: nil
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if file_enabled
|
|
245
|
+
if layout == "single"
|
|
246
|
+
sink = LogFileSink.new(resolved_log_file, resolved_rotate_size, resolved_rotate_keep)
|
|
247
|
+
sink.open
|
|
248
|
+
snap[:main_sink] = sink
|
|
249
|
+
else
|
|
250
|
+
main_sink = LogFileSink.new(File.join(resolved_log_dir, "tina4.log"), resolved_rotate_size, resolved_rotate_keep)
|
|
251
|
+
main_sink.open
|
|
252
|
+
error_sink = LogFileSink.new(File.join(resolved_log_dir, "error.log"), resolved_rotate_size, resolved_rotate_keep)
|
|
253
|
+
error_sink.open
|
|
254
|
+
snap[:main_sink] = main_sink
|
|
255
|
+
snap[:error_sink] = error_sink
|
|
226
256
|
end
|
|
227
|
-
@file_logger = build_file_logger(@log_file_path)
|
|
228
|
-
|
|
229
|
-
# Mirror WARNING and above into a dedicated error.log so
|
|
230
|
-
# `tail -f logs/error.log` gives just the stuff worth looking at.
|
|
231
|
-
# Ruby wrote ONE file where Python and PHP wrote two, so anyone whose
|
|
232
|
-
# alerting tails error.log got silence here (feature 2 of the audit,
|
|
233
|
-
# D3). Skipped when the operator named an explicit TINA4_LOG_FILE:
|
|
234
|
-
# they asked for one file at one path, so a sibling error.log
|
|
235
|
-
# appearing next to it would be a surprise.
|
|
236
|
-
@error_logger = if explicit_file
|
|
237
|
-
nil
|
|
238
|
-
else
|
|
239
|
-
build_file_logger(File.join(@log_dir, "error.log"))
|
|
240
|
-
end
|
|
241
257
|
end
|
|
242
258
|
|
|
243
|
-
|
|
259
|
+
# v3.13.14: unbuffer stdout so logs reach `docker logs` / k8s
|
|
260
|
+
# immediately -- a non-TTY $stdout (every container) is
|
|
261
|
+
# block-buffered by default, so lines sat in the buffer until it
|
|
262
|
+
# filled or the process exited.
|
|
263
|
+
$stdout.sync = true if stdout_enabled
|
|
264
|
+
|
|
265
|
+
@snapshot = snap
|
|
266
|
+
@pid = Process.pid
|
|
267
|
+
nil
|
|
244
268
|
end
|
|
245
269
|
|
|
246
|
-
|
|
247
|
-
|
|
270
|
+
# Flush/close owned sinks, clear the snapshot and the current thread's
|
|
271
|
+
# request id. Idempotent; the next use resolves a fresh snapshot.
|
|
272
|
+
def reset
|
|
273
|
+
@snapshot = nil
|
|
274
|
+
Thread.current[:tina4_request_id] = nil
|
|
275
|
+
nil
|
|
248
276
|
end
|
|
249
277
|
|
|
250
|
-
|
|
251
|
-
|
|
278
|
+
# A defensive native-map copy of the effective, stable configuration.
|
|
279
|
+
def configuration
|
|
280
|
+
snap = ensure_snapshot
|
|
281
|
+
{
|
|
282
|
+
"level" => snap[:level], "file_level" => snap[:file_level], "format" => snap[:format],
|
|
283
|
+
"output" => snap[:output], "log_dir" => snap[:log_dir], "log_file" => snap[:log_file],
|
|
284
|
+
"layout" => snap[:layout], "rotate_size" => snap[:rotate_size], "rotate_keep" => snap[:rotate_keep],
|
|
285
|
+
"strict" => snap[:strict], "caller" => snap[:caller_capture],
|
|
286
|
+
"stdout_enabled" => snap[:stdout_enabled], "file_enabled" => snap[:file_enabled]
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# ── request id (thread-local; Decision 12) ───────────────────────
|
|
291
|
+
|
|
292
|
+
def set_request_id(request_id)
|
|
293
|
+
discard_state_if_forked
|
|
294
|
+
Thread.current[:tina4_request_id] = request_id
|
|
252
295
|
end
|
|
253
296
|
|
|
254
297
|
def get_request_id
|
|
255
|
-
|
|
298
|
+
discard_state_if_forked
|
|
299
|
+
Thread.current[:tina4_request_id]
|
|
256
300
|
end
|
|
257
301
|
|
|
258
|
-
def
|
|
259
|
-
|
|
302
|
+
def clear_request_id
|
|
303
|
+
Thread.current[:tina4_request_id] = nil
|
|
260
304
|
end
|
|
261
305
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
# ("INFO", :info, "Warning", :warning all work). Mirrors Python's
|
|
269
|
-
# Log.is_enabled. It REUSES the exact severity >= @console_level
|
|
270
|
-
# comparison the console branch in `log` uses (line ~167) via
|
|
271
|
-
# SEVERITY_MAP / resolve_level — it never re-implements level
|
|
272
|
-
# comparison, so it can never disagree with what the logger prints.
|
|
273
|
-
#
|
|
274
|
-
# "critical" is a FIRST-CLASS top-level severity (4 — above error 3),
|
|
275
|
-
# not a parity alias for error. It is evaluated with ordinary threshold
|
|
276
|
-
# logic (critical 4 >= @console_level), so it passes at every level
|
|
277
|
-
# except none (5) — matching the Python master.
|
|
278
|
-
def enabled?(level)
|
|
279
|
-
sym = normalize_level(level)
|
|
280
|
-
severity = SEVERITY_MAP[sym] || 0
|
|
281
|
-
severity >= console_level
|
|
306
|
+
def sanitize_request_id(value)
|
|
307
|
+
return nil if value.nil? || value.empty?
|
|
308
|
+
return nil if value.length > 128
|
|
309
|
+
return nil if value =~ /[^A-Za-z0-9._-]/
|
|
310
|
+
|
|
311
|
+
value
|
|
282
312
|
end
|
|
283
313
|
|
|
284
|
-
|
|
285
|
-
|
|
314
|
+
# ── threshold ─────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
# True when `level` passes the queried sink's threshold and that sink
|
|
317
|
+
# is active. `sink:` is nil (console, the historical meaning),
|
|
318
|
+
# :console/"console"/:stdout/"stdout", or :file/"file".
|
|
319
|
+
def enabled?(level, sink: nil)
|
|
320
|
+
raise LogArgumentError.new("enabled? requires a level", argument: "level") if level.nil?
|
|
321
|
+
|
|
322
|
+
key = level.to_s.strip.upcase
|
|
323
|
+
raise LogArgumentError.new("#{level.inspect} is not a valid level", argument: "level", accepted: LEVELS.keys) unless LEVELS.key?(key)
|
|
324
|
+
|
|
325
|
+
snap = ensure_snapshot
|
|
326
|
+
sink_key = sink.nil? ? nil : sink.to_s
|
|
327
|
+
case sink_key
|
|
328
|
+
when nil, "console", "stdout"
|
|
329
|
+
snap[:stdout_enabled] && LEVELS[key] >= LEVELS[snap[:level]]
|
|
330
|
+
when "file"
|
|
331
|
+
snap[:file_enabled] && LEVELS[key] >= LEVELS[snap[:file_level]]
|
|
332
|
+
else
|
|
333
|
+
raise LogArgumentError.new("#{sink.inspect} is not a valid sink", argument: "sink", accepted: %w[console file])
|
|
334
|
+
end
|
|
286
335
|
end
|
|
287
336
|
|
|
337
|
+
# ── event methods (Decision 23, section 5) ───────────────────────
|
|
338
|
+
|
|
288
339
|
def debug(message, context = {})
|
|
289
|
-
|
|
340
|
+
emit("DEBUG", message, context)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def info(message, context = {})
|
|
344
|
+
emit("INFO", message, context)
|
|
290
345
|
end
|
|
291
346
|
|
|
292
347
|
def warning(message, context = {})
|
|
293
|
-
|
|
348
|
+
emit("WARNING", message, context)
|
|
294
349
|
end
|
|
295
350
|
|
|
296
351
|
def error(message, context = {})
|
|
297
|
-
|
|
352
|
+
emit("ERROR", message, context)
|
|
298
353
|
end
|
|
299
354
|
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
# (which critical passes at every level except none). A critical log is
|
|
303
|
-
# never a silent no-op. Mirrors the Python master.
|
|
355
|
+
# Critical -- the highest severity. Always emitted, subject only to
|
|
356
|
+
# the configured threshold.
|
|
304
357
|
def critical(message, context = {})
|
|
305
|
-
|
|
358
|
+
emit("CRITICAL", message, context)
|
|
306
359
|
end
|
|
307
360
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
361
|
+
private
|
|
362
|
+
|
|
363
|
+
def emit(level, message, context)
|
|
364
|
+
snap = ensure_snapshot
|
|
365
|
+
console_ok = snap[:stdout_enabled] && LEVELS[level] >= LEVELS[snap[:level]]
|
|
366
|
+
file_ok = snap[:file_enabled] && LEVELS[level] >= LEVELS[snap[:file_level]]
|
|
367
|
+
return unless console_ok || file_ok
|
|
368
|
+
|
|
369
|
+
request_id = Thread.current[:tina4_request_id]
|
|
370
|
+
caller_name = snap[:caller_capture] ? resolve_caller_name : nil
|
|
371
|
+
event = build_event(level, message, request_id, caller_name, context)
|
|
372
|
+
|
|
373
|
+
if console_ok
|
|
374
|
+
stdout_line = bounded_for_sink(event, snap[:format], STDOUT_MAX_BYTES).chomp("\n")
|
|
375
|
+
plain = snap[:format] == "json" || !stdout_tty?
|
|
376
|
+
color = plain ? "" : (COLORS[level] || "")
|
|
377
|
+
reset_code = plain ? "" : RESET
|
|
378
|
+
write_stdout("#{color}#{stdout_line}#{reset_code}\n")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
if file_ok && snap[:main_sink]
|
|
382
|
+
main_line = bounded_for_sink(event, snap[:format], snap[:rotate_size])
|
|
383
|
+
write_sink(snap[:main_sink], main_line, snap[:strict])
|
|
384
|
+
if snap[:layout] == "directory" && snap[:error_sink] && LEVELS[level] >= LEVELS["WARNING"]
|
|
385
|
+
write_sink(snap[:error_sink], main_line, snap[:strict])
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
nil
|
|
315
389
|
end
|
|
316
390
|
|
|
317
|
-
|
|
391
|
+
def write_stdout(line)
|
|
392
|
+
$stdout.write(line)
|
|
393
|
+
$stdout.flush
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def write_sink(sink, line, strict)
|
|
397
|
+
sink.write(line)
|
|
398
|
+
rescue LogWriteError => e
|
|
399
|
+
raise e if strict
|
|
318
400
|
|
|
319
|
-
|
|
320
|
-
Tina4::Env.is_truthy(val)
|
|
401
|
+
write_stdout("tina4: log sink #{sink.path} failed: #{e.message}\n")
|
|
321
402
|
end
|
|
322
403
|
|
|
323
|
-
def
|
|
324
|
-
|
|
325
|
-
|
|
404
|
+
def stdout_tty?
|
|
405
|
+
$stdout.respond_to?(:tty?) && $stdout.tty?
|
|
406
|
+
end
|
|
326
407
|
|
|
327
|
-
|
|
328
|
-
# a binary payload off a socket, a 10MB string. See coerce_message.
|
|
329
|
-
message = coerce_message(message)
|
|
330
|
-
formatted = format_line(level, message)
|
|
408
|
+
# ── caller capture (Decision 16) ─────────────────────────────────
|
|
331
409
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
end
|
|
342
|
-
end
|
|
410
|
+
OWN_FRAMES = %w[resolve_caller_name build_event emit debug info warning error critical].freeze
|
|
411
|
+
NOISE_FRAME_RE = /\A(?:block(?: \(\d+ levels\))? in |<top \(required\)>|<main>)/.freeze
|
|
412
|
+
|
|
413
|
+
def resolve_caller_name
|
|
414
|
+
locs = caller_locations(2, 16) || []
|
|
415
|
+
locs.each do |loc|
|
|
416
|
+
label = loc.base_label.to_s
|
|
417
|
+
next if OWN_FRAMES.include?(label)
|
|
418
|
+
next if label.empty? || NOISE_FRAME_RE.match?(label)
|
|
343
419
|
|
|
344
|
-
|
|
345
|
-
if @output != "stdout" && @file_logger
|
|
346
|
-
payload = @json_mode ? json_line(level, message) : strip_ansi(formatted)
|
|
347
|
-
write_to_file(payload, level)
|
|
420
|
+
return label
|
|
348
421
|
end
|
|
422
|
+
nil
|
|
423
|
+
rescue StandardError
|
|
424
|
+
nil
|
|
425
|
+
end
|
|
349
426
|
|
|
350
|
-
|
|
427
|
+
# ── native normalization (Decision 14, section 6) ────────────────
|
|
428
|
+
|
|
429
|
+
def normalize(value, ancestors = [])
|
|
430
|
+
return value if value.nil? || value == true || value == false
|
|
431
|
+
|
|
432
|
+
case value
|
|
433
|
+
when String
|
|
434
|
+
decode_maybe_binary(value)
|
|
435
|
+
when Integer
|
|
436
|
+
value
|
|
437
|
+
when Float
|
|
438
|
+
value.finite? ? value : "[Unsupported]"
|
|
439
|
+
when Array
|
|
440
|
+
return "[Circular]" if ancestors.any? { |a| a.equal?(value) }
|
|
441
|
+
|
|
442
|
+
nxt = ancestors + [value]
|
|
443
|
+
value.map { |v| normalize(v, nxt) }
|
|
444
|
+
when Hash
|
|
445
|
+
return "[Circular]" if ancestors.any? { |a| a.equal?(value) }
|
|
446
|
+
|
|
447
|
+
nxt = ancestors + [value]
|
|
448
|
+
out = {}
|
|
449
|
+
value.each { |k, v| out[k.to_s] = normalize(v, nxt) }
|
|
450
|
+
out
|
|
451
|
+
else
|
|
452
|
+
"[Unsupported]"
|
|
453
|
+
end
|
|
351
454
|
end
|
|
352
455
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
456
|
+
def decode_maybe_binary(str)
|
|
457
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
458
|
+
|
|
459
|
+
utf8 = str.dup.force_encoding(Encoding::UTF_8)
|
|
460
|
+
return utf8 if utf8.valid_encoding?
|
|
461
|
+
|
|
462
|
+
"<binary #{str.bytesize} bytes sha256=#{Digest::SHA256.hexdigest(str)}>"
|
|
359
463
|
end
|
|
360
464
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
case sym
|
|
370
|
-
when :warning then :warn
|
|
371
|
-
else sym
|
|
465
|
+
def sort_keys_recursive(value)
|
|
466
|
+
case value
|
|
467
|
+
when Hash
|
|
468
|
+
value.keys.sort.each_with_object({}) { |k, out| out[k] = sort_keys_recursive(value[k]) }
|
|
469
|
+
when Array
|
|
470
|
+
value.map { |v| sort_keys_recursive(v) }
|
|
471
|
+
else
|
|
472
|
+
value
|
|
372
473
|
end
|
|
373
474
|
end
|
|
374
475
|
|
|
375
|
-
def
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
476
|
+
def compact_json(value)
|
|
477
|
+
JSON.generate(value)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# The top-level `message` field: a string passes through; anything
|
|
481
|
+
# else uses compact, key-sorted JSON spelling (Decision 14).
|
|
482
|
+
def message_to_string(raw)
|
|
483
|
+
return decode_maybe_binary(raw) if raw.is_a?(String)
|
|
484
|
+
|
|
485
|
+
normalized = normalize(raw)
|
|
486
|
+
return "null" if normalized.nil?
|
|
487
|
+
return "true" if normalized == true
|
|
488
|
+
return "false" if normalized == false
|
|
489
|
+
return JSON.generate(normalized) if normalized.is_a?(Numeric)
|
|
490
|
+
return compact_json(sort_keys_recursive(normalized)) if normalized.is_a?(Array) || normalized.is_a?(Hash)
|
|
491
|
+
|
|
492
|
+
normalized.to_s # already a marker string
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Escape backslash, CR, LF (in that order) and strip other C0/DEL so a
|
|
496
|
+
# text-format record is exactly one physical LF-terminated line
|
|
497
|
+
# (Decision 11).
|
|
498
|
+
ESCAPE_CHARS = /[\\\r\n]/.freeze
|
|
499
|
+
|
|
500
|
+
# Escape backslash, CR, LF and strip other C0/DEL so a text-format
|
|
501
|
+
# record is exactly one physical LF-terminated line (Decision 11). A
|
|
502
|
+
# single combined regex + BLOCK replacement (not a string replacement
|
|
503
|
+
# -- String#gsub treats "\\"/"\1" etc. in a string replacement
|
|
504
|
+
# specially, which is exactly the kind of double-escaping bug this
|
|
505
|
+
# needs to avoid) walks the original string once, so a
|
|
506
|
+
# newly-introduced backslash from escaping \r or \n is never
|
|
507
|
+
# re-scanned or double-escaped.
|
|
508
|
+
def escape_text(str)
|
|
509
|
+
out = str.gsub(ESCAPE_CHARS) do |c|
|
|
510
|
+
case c
|
|
511
|
+
when "\\" then '\\\\'
|
|
512
|
+
when "\r" then '\r'
|
|
513
|
+
when "\n" then '\n'
|
|
514
|
+
end
|
|
395
515
|
end
|
|
516
|
+
out.gsub(CONTROL_CHARS, "")
|
|
396
517
|
end
|
|
397
518
|
|
|
398
|
-
|
|
519
|
+
# ── canonical event + encoding (Decision 15) ─────────────────────
|
|
520
|
+
|
|
521
|
+
def timestamp_now
|
|
399
522
|
now = Time.now.utc
|
|
400
523
|
now.strftime("%Y-%m-%dT%H:%M:%S.") + format("%03d", now.usec / 1000) + "Z"
|
|
401
524
|
end
|
|
402
525
|
|
|
403
|
-
def
|
|
404
|
-
|
|
526
|
+
def build_event(level, message, request_id, caller_name, context)
|
|
527
|
+
event = { "timestamp" => timestamp_now, "level" => level, "message" => message_to_string(message) }
|
|
528
|
+
event["request_id"] = request_id if request_id && !request_id.empty?
|
|
529
|
+
event["function"] = caller_name if caller_name && !caller_name.empty?
|
|
530
|
+
if context && !context.empty?
|
|
531
|
+
normalized_ctx = sort_keys_recursive(normalize(context))
|
|
532
|
+
event["context"] = normalized_ctx if normalized_ctx && !normalized_ctx.empty?
|
|
533
|
+
end
|
|
534
|
+
event
|
|
405
535
|
end
|
|
406
536
|
|
|
407
|
-
def
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
rid_str = rid ? " [#{rid}]" : ""
|
|
412
|
-
fn = caller_name
|
|
413
|
-
fn_str = fn ? " [#{fn}]" : ""
|
|
414
|
-
ctx = @current_context && !@current_context.empty? ? " #{JSON.generate(@current_context)}" : ""
|
|
415
|
-
# Pad to 8, not 7: CRITICAL is eight characters, so a 7-wide column was
|
|
416
|
-
# broken by our own highest level. 8 is the only width that fits every
|
|
417
|
-
# level name. Cross-framework format table (feature 2 of the audit).
|
|
418
|
-
"#{ts} [#{level_str.ljust(8)}]#{rid_str}#{fn_str} #{message}#{ctx}"
|
|
537
|
+
def encode_json(event)
|
|
538
|
+
ordered = {}
|
|
539
|
+
JSON_KEY_ORDER.each { |k| ordered[k] = event[k] if event.key?(k) }
|
|
540
|
+
"#{compact_json(ordered)}\n"
|
|
419
541
|
end
|
|
420
542
|
|
|
421
|
-
def
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
543
|
+
def encode_text(event)
|
|
544
|
+
parts = [event["timestamp"], "[#{event['level'].ljust(8)}]"]
|
|
545
|
+
parts << "[#{event['request_id']}]" if event.key?("request_id")
|
|
546
|
+
parts << "[#{event['function']}]" if event.key?("function")
|
|
547
|
+
parts << escape_text(event["message"])
|
|
548
|
+
parts << compact_json(event["context"]) if event.key?("context")
|
|
549
|
+
"#{parts.join(' ')}\n"
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def encode(event, fmt)
|
|
553
|
+
fmt == "json" ? encode_json(event) : encode_text(event)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def overflow_record(original_event, original_encoded, fmt)
|
|
557
|
+
original_bytes = original_encoded.b
|
|
558
|
+
replacement = {
|
|
559
|
+
"timestamp" => original_event["timestamp"], "level" => original_event["level"],
|
|
560
|
+
"message" => OVERFLOW_MESSAGE,
|
|
561
|
+
"context" => { "truncated" => true, "original_bytes" => original_bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(original_bytes) }
|
|
427
562
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
#
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
#
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
563
|
+
encode(replacement, fmt)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def bounded_for_sink(event, fmt, max_bytes)
|
|
567
|
+
encoded = encode(event, fmt)
|
|
568
|
+
return encoded if encoded.b.bytesize <= max_bytes
|
|
569
|
+
|
|
570
|
+
overflow_record(event, encoded, fmt)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# ── resolution helpers ────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
# Delegates to the ONE shared env-truthiness table (Tina4::Env.is_truthy)
|
|
576
|
+
# rather than a private list of its own. Log used to keep its own
|
|
577
|
+
# y/t-inclusive list here, which is the EXACT historical bug
|
|
578
|
+
# spec/dotenv_corpus_spec.rb exists to catch: TINA4_LOG_FUNC=y switched
|
|
579
|
+
# function logging ON while TINA4_DEBUG=y left debug OFF, in the same
|
|
580
|
+
# process, because two subsystems answered "is this truthy" two
|
|
581
|
+
# different ways from the same .env.
|
|
582
|
+
def truthy?(value)
|
|
583
|
+
Tina4::Env.is_truthy(value)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def truthy_debug?
|
|
587
|
+
truthy?(ENV["TINA4_DEBUG"])
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def parse_bool_setting(name, default)
|
|
591
|
+
return default unless ENV.key?(name)
|
|
592
|
+
|
|
593
|
+
token = ENV[name].strip.downcase
|
|
594
|
+
return true if token == "true"
|
|
595
|
+
return false if token == "false"
|
|
596
|
+
|
|
597
|
+
raise LogConfigurationError.new(
|
|
598
|
+
"#{name}=#{ENV[name].inspect} is not a valid boolean; accepted: true, false",
|
|
599
|
+
setting: name, value: ENV[name], accepted: %w[true false]
|
|
600
|
+
)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def resolve_bool(explicit, env_name, default)
|
|
604
|
+
return explicit unless explicit.nil?
|
|
605
|
+
|
|
606
|
+
parse_bool_setting(env_name, default)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def resolve_level(explicit, env_name, default)
|
|
610
|
+
if !explicit.nil?
|
|
611
|
+
candidate = explicit.to_s
|
|
612
|
+
source = "argument"
|
|
613
|
+
elsif ENV.key?(env_name)
|
|
614
|
+
candidate = ENV[env_name]
|
|
615
|
+
source = env_name
|
|
616
|
+
else
|
|
617
|
+
return default
|
|
464
618
|
end
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
619
|
+
key = candidate.strip.upcase
|
|
620
|
+
unless LEVELS.key?(key)
|
|
621
|
+
raise LogConfigurationError.new(
|
|
622
|
+
"#{source}=#{candidate.inspect} is not a valid level; accepted: #{LEVELS.keys}",
|
|
623
|
+
setting: env_name, value: candidate, accepted: LEVELS.keys
|
|
624
|
+
)
|
|
625
|
+
end
|
|
626
|
+
key
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def resolve_format(explicit)
|
|
630
|
+
if !explicit.nil?
|
|
631
|
+
candidate = explicit.to_s.strip.downcase
|
|
632
|
+
unless %w[text json].include?(candidate)
|
|
633
|
+
raise LogConfigurationError.new("format=#{explicit.inspect} is not valid; accepted: text, json", setting: "TINA4_LOG_FORMAT", value: explicit, accepted: %w[text json])
|
|
634
|
+
end
|
|
635
|
+
return candidate
|
|
636
|
+
end
|
|
637
|
+
if ENV.key?("TINA4_LOG_FORMAT")
|
|
638
|
+
candidate = ENV["TINA4_LOG_FORMAT"].strip.downcase
|
|
639
|
+
unless %w[text json].include?(candidate)
|
|
640
|
+
raise LogConfigurationError.new("TINA4_LOG_FORMAT=#{ENV['TINA4_LOG_FORMAT'].inspect} is not valid; accepted: text, json", setting: "TINA4_LOG_FORMAT", value: ENV["TINA4_LOG_FORMAT"], accepted: %w[text json])
|
|
641
|
+
end
|
|
642
|
+
return candidate
|
|
643
|
+
end
|
|
644
|
+
truthy_debug? ? "text" : "json"
|
|
468
645
|
end
|
|
469
646
|
|
|
470
|
-
def
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
647
|
+
def resolve_output(explicit)
|
|
648
|
+
if !explicit.nil?
|
|
649
|
+
candidate = explicit.to_s.strip.downcase
|
|
650
|
+
source = "argument"
|
|
651
|
+
elsif ENV.key?("TINA4_LOG_OUTPUT")
|
|
652
|
+
candidate = ENV["TINA4_LOG_OUTPUT"].strip.downcase
|
|
653
|
+
source = "TINA4_LOG_OUTPUT"
|
|
654
|
+
else
|
|
655
|
+
return [true, truthy_debug?]
|
|
656
|
+
end
|
|
657
|
+
unless %w[stdout file both].include?(candidate)
|
|
658
|
+
raise LogConfigurationError.new("#{source}=#{candidate.inspect} is not valid; accepted: stdout, file, both", setting: "TINA4_LOG_OUTPUT", value: candidate, accepted: %w[stdout file both])
|
|
659
|
+
end
|
|
660
|
+
case candidate
|
|
661
|
+
when "stdout" then [true, false]
|
|
662
|
+
when "file" then [false, true]
|
|
663
|
+
when "both" then [true, true]
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def resolve_int(explicit, env_name, default, minimum)
|
|
668
|
+
if !explicit.nil?
|
|
669
|
+
raise LogConfigurationError.new("#{env_name}=#{explicit.inspect} is not a valid integer", setting: env_name, value: explicit) unless explicit.is_a?(Integer)
|
|
670
|
+
|
|
671
|
+
value = explicit
|
|
672
|
+
source = "argument"
|
|
673
|
+
elsif ENV.key?(env_name)
|
|
674
|
+
raw = ENV[env_name]
|
|
675
|
+
unless raw.strip =~ /\A-?\d+\z/
|
|
676
|
+
raise LogConfigurationError.new("#{env_name}=#{raw.inspect} is not a valid integer", setting: env_name, value: raw)
|
|
677
|
+
end
|
|
678
|
+
value = raw.to_i
|
|
679
|
+
source = env_name
|
|
680
|
+
else
|
|
681
|
+
return default
|
|
682
|
+
end
|
|
683
|
+
if !minimum.nil? && value < minimum
|
|
684
|
+
raise LogConfigurationError.new("#{source}=#{value} must be >= #{minimum}", setting: env_name, value: value, accepted: ">= #{minimum}")
|
|
685
|
+
end
|
|
686
|
+
value
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def resolve_str(explicit, env_name, default, allow_empty:)
|
|
690
|
+
if !explicit.nil?
|
|
691
|
+
raise LogConfigurationError.new("#{env_name} may not be an empty string", setting: env_name, value: explicit) if explicit.empty?
|
|
692
|
+
raise LogConfigurationError.new("#{env_name} may not contain a NUL byte", setting: env_name, value: explicit) if explicit.include?("\0")
|
|
693
|
+
|
|
694
|
+
return explicit
|
|
695
|
+
end
|
|
696
|
+
return default unless ENV.key?(env_name)
|
|
697
|
+
|
|
698
|
+
raw = ENV[env_name]
|
|
699
|
+
if raw.empty?
|
|
700
|
+
raise LogConfigurationError.new("#{env_name} may not be an empty string", setting: env_name, value: raw) unless allow_empty
|
|
701
|
+
|
|
702
|
+
return default
|
|
703
|
+
end
|
|
704
|
+
raise LogConfigurationError.new("#{env_name} may not contain a NUL byte", setting: env_name, value: raw) if raw.include?("\0")
|
|
705
|
+
|
|
706
|
+
raw
|
|
480
707
|
end
|
|
481
708
|
|
|
482
|
-
# Build one rotating file logger. stdlib Logger handles rotation natively:
|
|
483
|
-
# Logger.new(path, shift_age, shift_size) — files to keep, bytes before roll.
|
|
484
|
-
# With @rotate_size 0 the rotation args are omitted. tina4.log and error.log
|
|
485
|
-
# each get their own logger so they rotate independently.
|
|
486
|
-
# Turn anything into a single safe line of text.
|
|
487
|
-
#
|
|
488
|
-
# A String passes through when it is valid UTF-8. Binary is described
|
|
489
|
-
# rather than dumped: raw bytes at a terminal garble it and can emit
|
|
490
|
-
# escape sequences. Anything else becomes JSON, because a Hash rendered as
|
|
491
|
-
# text is the whole reason the caller logged it, falling back to inspect
|
|
492
|
-
# for a value JSON cannot represent. The logger must never be the reason a
|
|
493
|
-
# request dies.
|
|
494
|
-
def coerce_message(message)
|
|
495
|
-
text = case message
|
|
496
|
-
when String
|
|
497
|
-
if message.encoding == Encoding::BINARY || !message.valid_encoding?
|
|
498
|
-
utf8 = message.dup.force_encoding(Encoding::UTF_8)
|
|
499
|
-
return "<binary #{message.bytesize} bytes>" unless utf8.valid_encoding?
|
|
500
|
-
utf8
|
|
501
|
-
else
|
|
502
|
-
message
|
|
503
|
-
end
|
|
504
|
-
when Hash, Array
|
|
505
|
-
begin
|
|
506
|
-
JSON.generate(message)
|
|
507
|
-
rescue StandardError
|
|
508
|
-
message.inspect
|
|
509
|
-
end
|
|
510
|
-
when nil
|
|
511
|
-
""
|
|
512
|
-
else
|
|
513
|
-
message.respond_to?(:to_s) ? message.to_s : message.inspect
|
|
514
|
-
end
|
|
515
|
-
text.gsub(CONTROL_CHARS, "")
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
def truncate_for_stdout(line)
|
|
519
|
-
return line if line.length <= STDOUT_MAX_CHARS
|
|
520
|
-
"#{line[0, STDOUT_MAX_CHARS]}... (truncated, #{line.length} chars)"
|
|
521
|
-
end
|
|
522
|
-
|
|
523
|
-
# Is this target a FILE PATH or a DIRECTORY?
|
|
524
|
-
#
|
|
525
|
-
# An existing directory is always a directory, extension or not. Otherwise
|
|
526
|
-
# a basename with an extension (app.log, app.txt) is a file and anything
|
|
527
|
-
# else is a directory to create. That keeps `configure("/var/log/myapp")`
|
|
528
|
-
# a directory and `configure("/var/log/myapp/app.log")` a file without
|
|
529
|
-
# needing the path to exist yet.
|
|
530
709
|
def target_is_file?(path)
|
|
531
710
|
return false if File.directory?(path)
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
logger.formatter = proc { |_sev, _t, _p, msg| msg.to_s.end_with?("\n") ? msg : "#{msg}\n" }
|
|
557
|
-
logger
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
def write_to_file(line, level = nil)
|
|
561
|
-
return unless @file_logger
|
|
562
|
-
# Use << to bypass Logger's severity filtering — we already filtered above.
|
|
563
|
-
@file_logger << "#{line}\n"
|
|
564
|
-
# WARNING and above only. Matches the Python master and PHP, which both
|
|
565
|
-
# mirror warning+ rather than error+ -- error.log is "the stuff worth
|
|
566
|
-
# looking at", and a warning qualifies.
|
|
567
|
-
if @error_logger && level && (SEVERITY_MAP[level] || 0) >= SEVERITY_MAP[:warn]
|
|
568
|
-
@error_logger << "#{line}\n"
|
|
711
|
+
|
|
712
|
+
base = File.basename(path)
|
|
713
|
+
base.include?(".") && !base.start_with?(".")
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def ensure_snapshot
|
|
717
|
+
discard_state_if_forked
|
|
718
|
+
configure if @snapshot.nil?
|
|
719
|
+
@snapshot
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Ruby has no built-in post-fork hook (Process.fork is a raw syscall
|
|
723
|
+
# wrapper), so a forked child inherits every class-level ivar's value
|
|
724
|
+
# at the instant of fork -- including an owned sink and any thread's
|
|
725
|
+
# request id snapshot at fork time (Decision 24 / LOG-Q05 requires a
|
|
726
|
+
# forked child to discard inherited state and resolve fresh).
|
|
727
|
+
# Detecting "my PID changed since I last touched this state" lazily,
|
|
728
|
+
# on every access, achieves the same observable effect as an eager
|
|
729
|
+
# fork hook.
|
|
730
|
+
def discard_state_if_forked
|
|
731
|
+
current = Process.pid
|
|
732
|
+
if !@pid.nil? && @pid != current
|
|
733
|
+
@snapshot = nil
|
|
734
|
+
Thread.current[:tina4_request_id] = nil
|
|
569
735
|
end
|
|
570
|
-
|
|
571
|
-
raise if @strict
|
|
572
|
-
# Don't crash on log write failure
|
|
736
|
+
@pid = current
|
|
573
737
|
end
|
|
574
738
|
end
|
|
575
739
|
end
|
|
740
|
+
|
|
741
|
+
# Tina4::Debug (backward-compat alias) is defined once in lib/tina4/debug.rb,
|
|
742
|
+
# not here, to avoid the "already initialized constant" redefinition warning.
|
|
576
743
|
end
|