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 bare warn on 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

Class Method Summary collapse

Class Attribute Details

.log_dirObject (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_pathObject (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_idObject



250
251
252
# File 'lib/tina4/log.rb', line 250

def clear_request_id
  @mutex.synchronize { @request_id = nil }
end

.close_file_loggerObject

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(message, context = {})
  log(:critical, message, context)
end

.debug(message, context = {}) ⇒ Object



288
289
290
# File 'lib/tina4/log.rb', line 288

def debug(message, context = {})
  log(:debug, message, 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.

Returns:

  • (Boolean)


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(message, context = {})
  log(:error, message, context)
end

.get_request_idObject



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(message, context = {})
  log(:info, message, context)
end

.json_mode?Boolean

Returns:

  • (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(message, context = {})
  log(:warn, message, context)
end