Module: Tina4::Log
- Defined in:
- lib/tina4/log.rb
Defined Under Namespace
Classes: HeaderlessLogDevice
Constant Summary collapse
- LEVELS =
{ "[TINA4_LOG_ALL]" => 0, "[TINA4_LOG_DEBUG]" => 0, "[TINA4_LOG_INFO]" => 1, "[TINA4_LOG_WARNING]" => 2, "[TINA4_LOG_ERROR]" => 3, "[TINA4_LOG_CRITICAL]" => 4, "[TINA4_LOG_NONE]" => 5 }.freeze
- SEVERITY_MAP =
{ debug: 0, info: 1, warn: 2, error: 3, critical: 4 }.freeze
- COLORS =
{ reset: "\e[0m", red: "\e[31m", green: "\e[32m", yellow: "\e[33m", blue: "\e[34m", magenta: "\e[35m", cyan: "\e[36m", gray: "\e[90m" }.freeze
- ANSI_RE =
ANSI escape code regex for stripping from file output
/\033\[[0-9;]*m/- STDOUT_MAX_CHARS =
The logger must never be surprised by what it is handed. Console lines are capped; control characters never reach a terminal. Same numbers in all four frameworks (feature 2 of the feature audit).
2000- CONTROL_CHARS =
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/- DEFAULT_ROTATE_SIZE =
Defaults used when env vars are unset.
10 * 1024 * 1024
- DEFAULT_ROTATE_KEEP =
10MB
5- STRICT_WRITE_ERRORS =
The error classes TINA4_LOG_STRICT must let ESCAPE stdlib ::Logger.
TINA4_LOG_STRICT is documented as "raise on a log write failure instead of swallowing", and #write_to_file dutifully rescues IOError/SystemCallError and re-raises when @strict. It was a NO-OP anyway: ::Logger::LogDevice wraps every device write in its own
handle_write_errors, which rescues and turns the failure into a barewarnon stderr. The real error was swallowed one layer BELOW Tina4 and never reached Tina4's rescue at all.MEASURED 2026-08-01 on a genuinely full 1MB HFS+ ram disk (0 KB free), Ruby 4.0.2: with TINA4_LOG_STRICT=true, Tina4::Log.info(...) printed "log writing failed. No space left on device @ rb_sys_fail_on_write" to stderr and returned normally. The operator got a stderr warning and strict mode did nothing.
::Logger has a first-class seam for exactly this —
reraise_write_errors:(logger >= 1.5.0), which handle_write_errors re-raises through instead of warning. Listing the two classes #write_to_file already rescues keeps the two ends of the strict path in agreement. Note Errno::ENOSPC (and every other errno) is a SystemCallError, so the real disk-full case is covered. [IOError, SystemCallError].freeze
Class Attribute Summary collapse
-
.log_dir ⇒ Object
readonly
Returns the value of attribute log_dir.
-
.log_file_path ⇒ Object
readonly
Returns the value of attribute log_file_path.
Class Method Summary collapse
- .clear_request_id ⇒ Object
-
.close_file_logger ⇒ Object
Test/teardown helper — closes the underlying Logger so the file handle is released (Windows / tmpdir cleanup).
-
.configure(target = nil) ⇒ Object
configure(target = nil).
-
.critical(message, context = {}) ⇒ Object
critical is the HIGHEST severity (4, above error).
- .debug(message, context = {}) ⇒ Object
-
.enabled?(level) ⇒ Boolean
Would a message at
levelpass the configured MINIMUM CONSOLE LEVEL (TINA4_LOG_LEVEL)? Returns true ifflogwould print it to stdout — it reflects CONSOLE visibility only. - .error(message, context = {}) ⇒ Object
- .get_request_id ⇒ Object
- .info(message, context = {}) ⇒ Object
- .json_mode? ⇒ Boolean
- .set_request_id(id) ⇒ Object
- .warning(message, context = {}) ⇒ Object
Class Attribute Details
.log_dir ⇒ Object (readonly)
Returns the value of attribute log_dir.
85 86 87 |
# File 'lib/tina4/log.rb', line 85 def log_dir @log_dir end |
.log_file_path ⇒ Object (readonly)
Returns the value of attribute log_file_path.
85 86 87 |
# File 'lib/tina4/log.rb', line 85 def log_file_path @log_file_path end |
Class Method Details
.clear_request_id ⇒ Object
250 251 252 |
# File 'lib/tina4/log.rb', line 250 def clear_request_id @mutex.synchronize { @request_id = nil } end |
.close_file_logger ⇒ Object
Test/teardown helper — closes the underlying Logger so the file handle is released (Windows / tmpdir cleanup).
310 311 312 313 314 315 |
# File 'lib/tina4/log.rb', line 310 def close_file_logger @file_logger&.close rescue nil @file_logger = nil @error_logger&.close rescue nil @error_logger = nil end |
.configure(target = nil) ⇒ Object
configure(target = nil)
Logs land in a logs/ folder by default. The argument OVERRIDES that,
and it accepts a DIRECTORY or a FILE PATH:
configure -> ./logs/tina4.log + ./logs/error.log
configure("/var/log/myapp") -> /var/log/myapp/tina4.log + error.log
configure("/var/log/myapp/app.log") -> that exact file (no error.log sibling)
TINA4_LOG_DIR=log -> ./log/
A target with a file extension is a file path; anything else is a directory (an existing directory is always treated as one, extension or not). Naming a file means "one file at this path", so no error.log appears beside it -- same rule TINA4_LOG_FILE already followed.
BREAKING for Ruby callers: the argument used to be a project ROOT with
logs/ appended, so configure("/app") wrote to /app/logs/ while the
identical call on Python and PHP wrote to /app/. That is the same
file-versus-directory confusion feature 1 found in loadEnv, and it made
"put the logs exactly here" impossible to express. If you relied on the
old behaviour, pass the parent explicitly: configure(File.join(root, "logs")).
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 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 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 |
# File 'lib/tina4/log.rb', line 108 def configure(target = nil) # Explicit argument wins, then TINA4_LOG_DIR, then ./logs. log_dir_env = ENV["TINA4_LOG_DIR"] log_dir_env = nil if log_dir_env && log_dir_env.empty? chosen = target || log_dir_env || "logs" chosen = File.join(Dir.pwd, chosen) unless File.absolute_path?(chosen) if target_is_file?(chosen) @log_dir = File.dirname(chosen) explicit_target_file = chosen else @log_dir = chosen explicit_target_file = nil end FileUtils.mkdir_p(@log_dir) # A file path passed to configure() wins, then TINA4_LOG_FILE (absolute # or relative to log_dir). Default: <log_dir>/tina4.log. log_file_env = ENV["TINA4_LOG_FILE"] log_file_env = nil if log_file_env && log_file_env.empty? @log_file_path = if explicit_target_file explicit_target_file elsif log_file_env File.absolute_path?(log_file_env) ? log_file_env : File.join(@log_dir, log_file_env) else File.join(@log_dir, "tina4.log") end # TINA4_LOG_ROTATE_SIZE — bytes per file before rotation. 0 = no rotation. @rotate_size = (ENV["TINA4_LOG_ROTATE_SIZE"] || DEFAULT_ROTATE_SIZE).to_i # TINA4_LOG_ROTATE_KEEP — number of rotated backups to keep. @rotate_keep = (ENV["TINA4_LOG_ROTATE_KEEP"] || DEFAULT_ROTATE_KEEP).to_i # TINA4_LOG_FORMAT — "text" or "json". TEXT IS THE DEFAULT, always. # # Owner decision 2026-08-01: nothing but an explicit TINA4_LOG_FORMAT=json # selects JSON. The implicit "production means JSON" switch is DELETED in # all four frameworks, because MEASURED, "production" meant four different # things and it silently picked your log format: # # node !isTruthy(TINA4_DEBUG) -> JSON with TINA4_DEBUG unset # ruby TINA4_ENV|RACK_ENV|RUBY_ENV == "production" # python only via configure(production=True) # php no switch at all — JSON was the shipped default # # Same machine, same .env, four formats. An OBJECT (Hash/Array) passed as # the message is still JSON-encoded INLINE inside the text line — that is # coerce_message's job and it is unchanged. format_env = ENV["TINA4_LOG_FORMAT"] @format = format_env && !format_env.empty? ? format_env.downcase : "text" @json_mode = @format == "json" # TINA4_LOG_OUTPUT — "stdout", "file", or "both". # # Default (UNSET): stdout is ALWAYS on. The log FILE (tina4.log + any # error log) is written ONLY in development — i.e. when TINA4_DEBUG is # truthy. In production / containers (TINA4_DEBUG falsy) the logger is # stdout-only: writing a log file inside a container just bloats the # writable layer + disk, and 12-factor wants logs on stdout for the # platform to capture. An explicit TINA4_LOG_OUTPUT=file/both (or an # explicit TINA4_LOG_FILE path) overrides this and STILL writes a file. # Mirrors the Python master (debug/__init__.py configure()). # An explicit TINA4_LOG_FILE always wins: a path the operator named must # be written even in production (parity with the Python master, where an # explicit log_file builds a writer unconditionally), so the dev-gated # default below resolves to "both" (stdout + file) rather than "stdout". # "The operator named ONE file" — via TINA4_LOG_FILE or by passing a file # path to configure(). Either way a file must be written even in # production, and no error.log sibling appears next to it. explicit_file = !log_file_env.nil? || !explicit_target_file.nil? default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"]) "both" else "stdout" end output_env = ENV["TINA4_LOG_OUTPUT"] @output = if output_env && !output_env.empty? output_env.downcase else default_output end @output = default_output unless %w[stdout file both].include?(@output) # TINA4_LOG_STRICT — when true, raise on log write failures instead of swallowing. @strict = truthy?(ENV["TINA4_LOG_STRICT"]) @console_level = resolve_level @request_id = nil @current_context = {} @mutex = Mutex.new # v3.13.14: unbuffer stdout so logs reach `docker logs` / k8s # immediately. A non-TTY $stdout (every container) is block-buffered # by default — logs sat in the buffer until it filled or the process # exited, so operators "weren't getting logs". No-op when output is # file-only. $stdout.sync = true if @output != "file" # Build the file logger via stdlib Logger which handles rotation natively. # Logger.new(path, shift_age, shift_size): # shift_age = number of files to keep # shift_size = bytes before rotation # When @rotate_size is 0, omit rotation args. close_file_logger # TINA4_LOG_APPEND — append (default) or overwrite on startup. # # APPEND IS THE DEFAULT: a log you can lose by restarting the process is # not a log. Set it false when you want one file per run (a short CLI, a # test fixture, a container that ships logs elsewhere) and the file is # truncated once here at configure time, never per line. @append = ENV["TINA4_LOG_APPEND"].nil? || truthy?(ENV["TINA4_LOG_APPEND"]) if @output != "stdout" unless @append [@log_file_path, File.join(@log_dir, "error.log")].each do |path| File.write(path, "") if File.exist?(path) end end @file_logger = build_file_logger(@log_file_path) # Mirror WARNING and above into a dedicated error.log so # `tail -f logs/error.log` gives just the stuff worth looking at. # Ruby wrote ONE file where Python and PHP wrote two, so anyone whose # alerting tails error.log got silence here (feature 2 of the audit, # D3). Skipped when the operator named an explicit TINA4_LOG_FILE: # they asked for one file at one path, so a sibling error.log # appearing next to it would be a surprise. @error_logger = if explicit_file nil else build_file_logger(File.join(@log_dir, "error.log")) end end @initialized = true end |
.critical(message, context = {}) ⇒ Object
critical is the HIGHEST severity (4, above error). Like every other level it ALWAYS emits, subject only to the TINA4_LOG_LEVEL threshold (which critical passes at every level except none). A critical log is never a silent no-op. Mirrors the Python master.
304 305 306 |
# File 'lib/tina4/log.rb', line 304 def critical(, context = {}) log(:critical, , context) end |
.debug(message, context = {}) ⇒ Object
288 289 290 |
# File 'lib/tina4/log.rb', line 288 def debug(, context = {}) log(:debug, , context) end |
.enabled?(level) ⇒ Boolean
Would a message at level pass the configured MINIMUM CONSOLE LEVEL
(TINA4_LOG_LEVEL)? Returns true iff log would print it to stdout —
it reflects CONSOLE visibility only. The log FILE records every level
regardless of this threshold, so this never gates file output.
level accepts a String or Symbol and is case-insensitive
("INFO", :info, "Warning", :warning all work). Mirrors Python's
Log.is_enabled. It REUSES the exact severity >= @console_level
comparison the console branch in log uses (line ~167) via
SEVERITY_MAP / resolve_level — it never re-implements level
comparison, so it can never disagree with what the logger prints.
"critical" is a FIRST-CLASS top-level severity (4 — above error 3), not a parity alias for error. It is evaluated with ordinary threshold logic (critical 4 >= @console_level), so it passes at every level except none (5) — matching the Python master.
278 279 280 281 282 |
# File 'lib/tina4/log.rb', line 278 def enabled?(level) sym = normalize_level(level) severity = SEVERITY_MAP[sym] || 0 severity >= console_level end |
.error(message, context = {}) ⇒ Object
296 297 298 |
# File 'lib/tina4/log.rb', line 296 def error(, context = {}) log(:error, , context) end |
.get_request_id ⇒ Object
254 255 256 |
# File 'lib/tina4/log.rb', line 254 def get_request_id @mutex.synchronize { @request_id } end |
.info(message, context = {}) ⇒ Object
284 285 286 |
# File 'lib/tina4/log.rb', line 284 def info(, context = {}) log(:info, , context) end |
.json_mode? ⇒ Boolean
258 259 260 |
# File 'lib/tina4/log.rb', line 258 def json_mode? @json_mode end |
.set_request_id(id) ⇒ Object
246 247 248 |
# File 'lib/tina4/log.rb', line 246 def set_request_id(id) @mutex.synchronize { @request_id = id } end |
.warning(message, context = {}) ⇒ Object
292 293 294 |
# File 'lib/tina4/log.rb', line 292 def warning(, context = {}) log(:warn, , context) end |