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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +24 -2
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -19
  51. data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
  52. data/lib/tina4/scss/tina4css/_badges.scss +0 -22
  53. data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
  54. data/lib/tina4/scss/tina4css/_cards.scss +0 -49
  55. data/lib/tina4/scss/tina4css/_forms.scss +0 -156
  56. data/lib/tina4/scss/tina4css/_grid.scss +0 -81
  57. data/lib/tina4/scss/tina4css/_modals.scss +0 -84
  58. data/lib/tina4/scss/tina4css/_nav.scss +0 -149
  59. data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
  60. data/lib/tina4/scss/tina4css/_reset.scss +0 -94
  61. data/lib/tina4/scss/tina4css/_tables.scss +0 -54
  62. data/lib/tina4/scss/tina4css/_typography.scss +0 -55
  63. data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
  64. data/lib/tina4/scss/tina4css/_variables.scss +0 -117
  65. data/lib/tina4/scss/tina4css/base.scss +0 -1
  66. data/lib/tina4/scss/tina4css/colors.scss +0 -48
  67. 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 "logger"
5
+ require "digest"
6
6
 
7
7
  module Tina4
8
- module Log
9
- LEVELS = {
10
- "[TINA4_LOG_ALL]" => 0,
11
- "[TINA4_LOG_DEBUG]" => 0,
12
- "[TINA4_LOG_INFO]" => 1,
13
- "[TINA4_LOG_WARNING]" => 2,
14
- "[TINA4_LOG_ERROR]" => 3,
15
- "[TINA4_LOG_CRITICAL]" => 4,
16
- "[TINA4_LOG_NONE]" => 5
17
- }.freeze
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
- SEVERITY_MAP = {
20
- debug: 0, info: 1, warn: 2, error: 3, critical: 4
21
- }.freeze
22
+ # Invalid argument to a public logger method.
23
+ class LogArgumentError < ArgumentError
24
+ attr_accessor :argument, :accepted
22
25
 
23
- COLORS = {
24
- reset: "\e[0m", red: "\e[31m", green: "\e[32m",
25
- yellow: "\e[33m", blue: "\e[34m", magenta: "\e[35m",
26
- cyan: "\e[36m", gray: "\e[90m"
27
- }.freeze
26
+ def initialize(message, argument: nil, accepted: nil)
27
+ super(message)
28
+ @argument = argument
29
+ @accepted = accepted
30
+ end
31
+ end
28
32
 
29
- # ANSI escape code regex for stripping from file output
30
- ANSI_RE = /\033\[[0-9;]*m/
33
+ # A selected sink failed after configuration succeeded, under strict mode.
34
+ class LogWriteError < RuntimeError
35
+ attr_accessor :sink, :operation
31
36
 
32
- # The logger must never be surprised by what it is handed. Console lines are
33
- # capped; control characters never reach a terminal. Same numbers in all four
34
- # frameworks (feature 2 of the feature audit).
35
- STDOUT_MAX_CHARS = 2000
36
- CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
37
+ def initialize(message, sink: nil, operation: nil)
38
+ super(message)
39
+ @sink = sink
40
+ @operation = operation
41
+ end
42
+ end
37
43
 
38
- # Defaults used when env vars are unset.
39
- DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024 # 10MB
40
- DEFAULT_ROTATE_KEEP = 5
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
- # The error classes TINA4_LOG_STRICT must let ESCAPE stdlib ::Logger.
43
- #
44
- # TINA4_LOG_STRICT is documented as "raise on a log write failure instead of
45
- # swallowing", and #write_to_file dutifully rescues IOError/SystemCallError
46
- # and re-raises when @strict. It was a NO-OP anyway: ::Logger::LogDevice
47
- # wraps every device write in its own `handle_write_errors`, which rescues
48
- # and turns the failure into a bare `warn` on stderr. The real error was
49
- # swallowed one layer BELOW Tina4 and never reached Tina4's rescue at all.
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
- attr_reader :log_dir, :log_file_path
184
+ # ── configuration ──────────────────────────────────────────────
86
185
 
87
- # configure(target = nil)
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
- # A target with a file extension is a file path; anything else is a
98
- # directory (an existing directory is always treated as one, extension or
99
- # not). Naming a file means "one file at this path", so no error.log
100
- # appears beside it -- same rule TINA4_LOG_FILE already followed.
101
- #
102
- # BREAKING for Ruby callers: the argument used to be a project ROOT with
103
- # `logs/` appended, so `configure("/app")` wrote to /app/logs/ while the
104
- # identical call on Python and PHP wrote to /app/. That is the same
105
- # file-versus-directory confusion feature 1 found in loadEnv, and it made
106
- # "put the logs exactly here" impossible to express. If you relied on the
107
- # old behaviour, pass the parent explicitly: `configure(File.join(root, "logs"))`.
108
- def configure(target = nil)
109
- # Explicit argument wins, then TINA4_LOG_DIR, then ./logs.
110
- log_dir_env = ENV["TINA4_LOG_DIR"]
111
- log_dir_env = nil if log_dir_env && log_dir_env.empty?
112
- chosen = target || log_dir_env || "logs"
113
- chosen = File.join(Dir.pwd, chosen) unless File.absolute_path?(chosen)
114
-
115
- if target_is_file?(chosen)
116
- @log_dir = File.dirname(chosen)
117
- explicit_target_file = chosen
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
- @log_dir = chosen
120
- explicit_target_file = nil
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
- # v3.13.14: unbuffer stdout so logs reach `docker logs` / k8s
200
- # immediately. A non-TTY $stdout (every container) is block-buffered
201
- # by default — logs sat in the buffer until it filled or the process
202
- # exited, so operators "weren't getting logs". No-op when output is
203
- # file-only.
204
- $stdout.sync = true if @output != "file"
205
-
206
- # Build the file logger via stdlib Logger which handles rotation natively.
207
- # Logger.new(path, shift_age, shift_size):
208
- # shift_age = number of files to keep
209
- # shift_size = bytes before rotation
210
- # When @rotate_size is 0, omit rotation args.
211
- close_file_logger
212
-
213
- # TINA4_LOG_APPEND — append (default) or overwrite on startup.
214
- #
215
- # APPEND IS THE DEFAULT: a log you can lose by restarting the process is
216
- # not a log. Set it false when you want one file per run (a short CLI, a
217
- # test fixture, a container that ships logs elsewhere) and the file is
218
- # truncated once here at configure time, never per line.
219
- @append = ENV["TINA4_LOG_APPEND"].nil? || truthy?(ENV["TINA4_LOG_APPEND"])
220
-
221
- if @output != "stdout"
222
- unless @append
223
- [@log_file_path, File.join(@log_dir, "error.log")].each do |path|
224
- File.write(path, "") if File.exist?(path)
225
- end
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
- @initialized = true
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
- def set_request_id(id)
247
- @mutex.synchronize { @request_id = id }
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
- def clear_request_id
251
- @mutex.synchronize { @request_id = nil }
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
- @mutex.synchronize { @request_id }
298
+ discard_state_if_forked
299
+ Thread.current[:tina4_request_id]
256
300
  end
257
301
 
258
- def json_mode?
259
- @json_mode
302
+ def clear_request_id
303
+ Thread.current[:tina4_request_id] = nil
260
304
  end
261
305
 
262
- # Would a message at `level` pass the configured MINIMUM CONSOLE LEVEL
263
- # (TINA4_LOG_LEVEL)? Returns true iff `log` would print it to stdout —
264
- # it reflects CONSOLE visibility only. The log FILE records every level
265
- # regardless of this threshold, so this never gates file output.
266
- #
267
- # `level` accepts a String or Symbol and is case-insensitive
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
- def info(message, context = {})
285
- log(:info, message, context)
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
- log(:debug, message, context)
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
- log(:warn, message, context)
348
+ emit("WARNING", message, context)
294
349
  end
295
350
 
296
351
  def error(message, context = {})
297
- log(:error, message, context)
352
+ emit("ERROR", message, context)
298
353
  end
299
354
 
300
- # critical is the HIGHEST severity (4, above error). Like every other
301
- # level it ALWAYS emits, subject only to the TINA4_LOG_LEVEL threshold
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
- log(:critical, message, context)
358
+ emit("CRITICAL", message, context)
306
359
  end
307
360
 
308
- # Test/teardown helper — closes the underlying Logger so the file
309
- # handle is released (Windows / tmpdir cleanup).
310
- def close_file_logger
311
- @file_logger&.close rescue nil
312
- @file_logger = nil
313
- @error_logger&.close rescue nil
314
- @error_logger = nil
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
- private
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
- def truthy?(val)
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 log(level, message, context = {})
324
- configure unless @initialized
325
- @current_context = context.is_a?(Hash) ? context : {}
404
+ def stdout_tty?
405
+ $stdout.respond_to?(:tty?) && $stdout.tty?
406
+ end
326
407
 
327
- # Coerce FIRST. Anything can arrive as a message: a Hash from a handler,
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
- # Console output respects TINA4_LOG_LEVEL and TINA4_LOG_OUTPUT
333
- severity = SEVERITY_MAP[level] || 0
334
- if severity >= @console_level && @output != "file"
335
- # Truncate on the CONSOLE only. The file keeps the full line so a
336
- # consumer parsing it loses nothing; a terminal does not need 10MB.
337
- if @json_mode
338
- $stdout.puts truncate_for_stdout(json_line(level, message))
339
- else
340
- $stdout.puts colorize(level, truncate_for_stdout(formatted))
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
- # File output — always full level (consumer parses themselves) — unless disabled.
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
- @current_context = {}
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
- # The current minimum console level as an integer (the same value
354
- # the console branch in `log` compares against). Ensures the logger
355
- # is configured so `enabled?` works before any log call has run.
356
- def console_level
357
- configure unless @initialized
358
- @console_level
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
- # Map a level (String or Symbol, case-insensitive) onto the symbol
362
- # space used by SEVERITY_MAP. Accepts the public method names
363
- # (debug/info/warning/error/critical) and the internal :warn symbol.
364
- # critical is a FIRST-CLASS level (severity 4), not an alias for error.
365
- # Unknown levels fall through to their own symbol and resolve to
366
- # severity 0 in `enabled?`.
367
- def normalize_level(level)
368
- sym = level.to_s.strip.downcase.to_sym
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 resolve_level
376
- # v3.13.14: default is INFO (was ALL) so a deployed app surfaces
377
- # request/startup/warn/error without debug noise, matching
378
- # Python/PHP/Node. Accept BOTH the legacy bracket form
379
- # ("[TINA4_LOG_ERROR]") AND plain names ("ERROR") so the env value
380
- # is portable across all four frameworks.
381
- raw = (ENV["TINA4_LOG_LEVEL"] || "").strip
382
- return 1 if raw.empty? # INFO
383
- key = raw.start_with?("[") ? raw.upcase : "[TINA4_LOG_#{raw.upcase}]"
384
- LEVELS[key] || 1
385
- end
386
-
387
- def severity_to_level(level)
388
- case level
389
- when :debug then "DEBUG"
390
- when :info then "INFO"
391
- when :warn then "WARNING"
392
- when :error then "ERROR"
393
- when :critical then "CRITICAL"
394
- else level.to_s.upcase
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
- def utc_timestamp
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 strip_ansi(text)
404
- text.gsub(ANSI_RE, "")
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 format_line(level, message)
408
- level_str = severity_to_level(level)
409
- ts = utc_timestamp
410
- rid = get_request_id
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 json_line(level, message)
422
- level_str = severity_to_level(level)
423
- entry = {
424
- timestamp: utc_timestamp,
425
- level: level_str,
426
- message: message
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
- rid = get_request_id
429
- entry[:request_id] = rid if rid
430
- fn = caller_name
431
- entry[:function] = fn if fn
432
- entry[:context] = @current_context if @current_context && !@current_context.empty?
433
- JSON.generate(entry)
434
- end
435
-
436
- # Names that belong to Log itself — walk past them so the reported
437
- # frame is the real caller (e.g. the route handler or service
438
- # method that called Log.info). Kept as a Set for O(1) lookup.
439
- OWN_FRAMES = %w[
440
- caller_name format_line json_line log colorize write_to_file
441
- debug info warning error critical
442
- ].freeze
443
-
444
- # Names that are noise Ruby block labels, lambdas, top-level
445
- # script frames. We skip these the same way Python skips <module>
446
- # and <lambda>.
447
- NOISE_FRAME_RE = /\A(?:block(?: \(\d+ levels\))? in |<top \(required\)>|<main>)/
448
-
449
- # Return the name of the function that called Log.{debug,info,warning,error}.
450
- # Active only when TINA4_LOG_FUNC=true (parity feature #41).
451
- # Returns nil on any error so it never crashes a log call.
452
- def caller_name
453
- return nil unless Tina4::Env.bool("TINA4_LOG_FUNC")
454
-
455
- # caller_locations(2, 16) skips this method + log() and gives us
456
- # up to 16 frames to walk. We bail out the moment we hit a frame
457
- # whose base_label isn't in OWN_FRAMES and isn't a block label.
458
- locs = caller_locations(2, 16) || []
459
- locs.each do |loc|
460
- label = loc.base_label.to_s
461
- next if OWN_FRAMES.include?(label)
462
- next if label.empty? || NOISE_FRAME_RE.match?(label)
463
- return label
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
- nil
466
- rescue StandardError
467
- nil
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 colorize(level, line)
471
- color = case level
472
- when :debug then COLORS[:cyan]
473
- when :info then COLORS[:green]
474
- when :warn then COLORS[:yellow]
475
- when :error then COLORS[:red]
476
- when :critical then COLORS[:magenta]
477
- else COLORS[:reset]
478
- end
479
- "#{color}#{line}#{COLORS[:reset]}"
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
- File.extname(path) != ""
533
- end
534
-
535
- def build_file_logger(path)
536
- # In strict mode ask ::Logger to let a real write error through instead
537
- # of warning on stderr and returning (see STRICT_WRITE_ERRORS). Empty
538
- # otherwise, which is the stdlib default, so the non-strict path behaves
539
- # byte for byte as it always has.
540
- reraise = @strict ? STRICT_WRITE_ERRORS : []
541
- # HeaderlessLogDevice, not a bare path: ::Logger would otherwise open the
542
- # file with a "# Logfile created on ..." banner that is not a log line
543
- # (and not JSON). It still does the rotation shift_age = files kept,
544
- # shift_size = bytes before a roll; with @rotate_size 0 both are omitted
545
- # so rotation is off. reraise_write_errors is passed to BOTH layers or
546
- # the outer one swallows what the inner one raises (see STRICT_WRITE_ERRORS).
547
- device = if @rotate_size > 0
548
- HeaderlessLogDevice.new(path, shift_age: @rotate_keep,
549
- shift_size: @rotate_size,
550
- reraise_write_errors: reraise)
551
- else
552
- HeaderlessLogDevice.new(path, reraise_write_errors: reraise)
553
- end
554
- logger = ::Logger.new(device, reraise_write_errors: reraise)
555
- # We do our own formatting — strip Logger's default formatter.
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
- rescue IOError, SystemCallError => e
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