Log.pm 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. package Trog::Log;
  2. use v5.36;
  3. use re '/aa';
  4. use POSIX qw{strftime};
  5. use Log::Dispatch;
  6. use Log::Dispatch::DBI;
  7. use Log::Dispatch::Screen;
  8. use Log::Dispatch::FileRotate;
  9. use File::Basename qw{dirname};
  10. use Trog::SQLite;
  11. use Trog::Log::DBI;
  12. use Exporter 'import';
  13. our @EXPORT_OK = qw{log_init is_debug INFO DEBUG WARN FATAL};
  14. our %EXPORT_TAGS = ( 'all' => \@EXPORT_OK );
  15. my ( $LEVEL, $LOGDIR );
  16. our ( $log, $user );
  17. $Trog::Log::user = 'nobody';
  18. $Trog::Log::ip = '0.0.0.0';
  19. =head1 Trog::Log
  20. Logging for tCMS.
  21. Everything goes through one Log::Dispatch object with three outputs: a rotating
  22. file for the operator, the screen for anything at error level or above (which
  23. is what ends up in the webserver's own log), and Trog::Log::DBI, which is what
  24. Trog::Log::Metrics later reads its time series out of.
  25. Import the level subroutines you need, or C<:all>:
  26. use Trog::Log qw{:all};
  27. INFO("something happened");
  28. =head1 Termination Conditions
  29. log_init() dies if it isn't told where to log and at what level. FATAL() dies
  30. by design, that being the point of it. Note that the level subroutines will
  31. themselves die on an undefined $log, so log_init() has to have run first --
  32. which the server does per-worker, and CLI tools have to do for themselves.
  33. =head1 VARIABLES
  34. =over 4
  35. =item $Trog::Log::user
  36. Who the current request belongs to. Interpolated into every log line, so that
  37. the request log can be grepped by user. Defaults to 'nobody'.
  38. =item $Trog::Log::ip
  39. Where the current request came from, same deal. Defaults to '0.0.0.0'.
  40. =back
  41. =head1 FUNCTIONS
  42. =head2 log_init(STRING logname, STRING level) = BOOL
  43. Build the logger. Must be called before anything tries to log.
  44. $logname is the path to the log file; its directory is also where the metrics
  45. database gets put. $level is a Log::Dispatch level, generally 'info' or
  46. 'debug'.
  47. Returns 1.
  48. =cut
  49. sub log_init {
  50. my ( $LOGNAME, $LEVEL ) = @_;
  51. die "Cannot initialize logs without log name and log level" unless $LOGNAME && $LEVEL;
  52. $LOGNAME //= 'logs/tcms.log';
  53. $LEVEL //= 'info';
  54. $LOGDIR = dirname($LOGNAME);
  55. # By default only log requests & warnings.
  56. # Otherwise emit debug messages.
  57. my $rotate = Log::Dispatch::FileRotate->new(
  58. name => 'tcms',
  59. filename => $LOGNAME,
  60. min_level => $LEVEL,
  61. 'mode' => 'append',
  62. size => 10 * 1024 * 1024,
  63. max => 6,
  64. );
  65. # Only send fatal events/errors to prod-web.log
  66. my $screen = Log::Dispatch::Screen->new(
  67. name => 'screen',
  68. min_level => 'error',
  69. );
  70. # Send things like requests in to the stats log
  71. my $dblog = Trog::Log::DBI->new(
  72. name => 'dbi',
  73. min_level => $LEVEL,
  74. dbh => _dbh(),
  75. );
  76. $log = Log::Dispatch->new();
  77. $log->add($rotate);
  78. $log->add($screen);
  79. $log->add($dblog);
  80. uuid("INIT");
  81. return 1;
  82. }
  83. #memoize
  84. my $rq;
  85. sub _dbh {
  86. return Trog::SQLite::dbh( 'schema/log.schema', "$LOGDIR/log.db" );
  87. }
  88. =head2 is_debug() = BOOL
  89. Whether we were initialized at debug level. Useful for skipping the
  90. construction of an expensive message which would only be thrown away.
  91. =cut
  92. sub is_debug {
  93. $LEVEL //= 'info';
  94. return $LEVEL eq 'debug';
  95. }
  96. =head2 uuid([STRING requestid]) = STRING
  97. Get, or set, the request ID stamped onto every log line.
  98. The server sets this once per request so that the lines belonging to it can be
  99. picked back out of an interleaved log, and so that Trog::Log::DBI can group a
  100. request's messages under the request itself.
  101. =cut
  102. sub uuid {
  103. my $requestid = shift;
  104. $rq = $requestid if $requestid;
  105. $requestid //= return $rq;
  106. }
  107. sub _log {
  108. my ( $msg, $level ) = @_;
  109. $msg //= "No message passed. This is almost certainly a bug. ";
  110. #XXX Log lines must start as an ISO8601 date, anything else breaks fail2ban's beautiful mind
  111. my $tstamp = strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
  112. # Undef until the server stamps one, or log_init() sets INIT. Anything
  113. # logged before that still deserves a line rather than an uninitialized
  114. # value warning glued into the middle of it.
  115. my $uuid = uuid() // 'NONE';
  116. return "$tstamp [$level]: RequestId $uuid From $Trog::Log::ip |$Trog::Log::user| $msg\n";
  117. }
  118. =head2 DEBUG(STRING msg), INFO(STRING msg), WARN(STRING msg), FATAL(STRING msg)
  119. Log a message at the named level.
  120. Lines are formatted as an ISO8601 timestamp, the level, the request ID, the
  121. originating IP and the user, followed by the message. Don't change the leading
  122. timestamp -- fail2ban parses these.
  123. FATAL logs and then dies, so it never returns.
  124. All four work before log_init() has run, falling back to stderr. Logging is
  125. the thing you reach for when something has already gone wrong, so a logger that
  126. dies because it wasn't set up first turns a diagnostic into an outage -- and
  127. plenty of callers legitimately run uninitialized, such as the scripts in bin/
  128. and the test suite.
  129. =cut
  130. # One path for every level so the uninitialized fallback only exists once.
  131. sub _emit ( $level, $method, $msg ) {
  132. my $line = _log( $msg, $level );
  133. return $log->$method($line) if $log;
  134. warn $line;
  135. return 1;
  136. }
  137. sub DEBUG { return _emit( 'DEBUG', 'debug', shift ) }
  138. sub INFO { return _emit( 'INFO', 'info', shift ) }
  139. sub WARN { return _emit( 'WARN', 'warning', shift ) }
  140. sub FATAL {
  141. my $line = _log( shift, 'FATAL' );
  142. $log->log_and_die( level => 'error', message => $line ) if $log;
  143. # Still fatal with no logger, or the caller's error handling vanishes.
  144. die $line;
  145. }
  146. 1;