| File: | blib/lib/Log/Abstraction.pm |
| Coverage: | 57.2% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Log::Abstraction; | |||||
| 2 | ||||||
| 3 | # TODO: OpenTelemetry (OTel) Logs backend â not yet implemented. | |||||
| 4 | # | |||||
| 5 | # The goal is to route log messages to an OTel collector via | |||||
| 6 | # OpenTelemetry::Logs::Logger->emit_record(), allowing Log::Abstraction | |||||
| 7 | # to participate in a unified traces+logs+metrics pipeline. | |||||
| 8 | # | |||||
| 9 | # Why it is blocked (last assessed 2026-07-10, OTel Perl v0.033): | |||||
| 10 | # | |||||
| 11 | # 1. emit_record() is a no-op stub. OpenTelemetry::Logs::Logger | |||||
| 12 | # contains "method emit_record ( %args ) { }" â every call is | |||||
| 13 | # silently discarded. This has been the case since logs were added | |||||
| 14 | # as "experimental" in v0.023 (June 2024). | |||||
| 15 | # | |||||
| 16 | # 2. The SDK has no Logs implementation at all. SDK::Trace::* is | |||||
| 17 | # complete (providers, processors, samplers, OTLP exporter), but | |||||
| 18 | # there is no SDK::Logs::LogRecord, no Batch/Simple processor, and | |||||
| 19 | # no SDK::Logs::LoggerProvider. OpenTelemetry::Exporter::OTLP::Logs | |||||
| 20 | # exists as a module but has no processor pipeline to feed it. | |||||
| 21 | # | |||||
| 22 | # 3. The official Log::Any::Adapter::OpenTelemetry has a documented | |||||
| 23 | # FIXME: it cannot safely cache the Logger at construction time, | |||||
| 24 | # because acquiring a Logger before a real LoggerProvider is | |||||
| 25 | # registered returns a no-op that can never be upgraded. This is | |||||
| 26 | # an unresolved architectural issue upstream. | |||||
| 27 | # | |||||
| 28 | # 4. is_debug() / is_* detection in the OTel adapter reads | |||||
| 29 | # otel_config('LOG_LEVEL'), which is the SDK's own internal | |||||
| 30 | # diagnostic level, not the application log level â a semantic bug | |||||
| 31 | # that would propagate into any adapter we write on top. | |||||
| 32 | # | |||||
| 33 | # 5. The Logs stack depends on Object::Pad (Corinna), adding a | |||||
| 34 | # non-trivial dependency and Perl >= 5.26 requirement in practice. | |||||
| 35 | # | |||||
| 36 | # When to revisit: watch for OpenTelemetry::SDK::Logs::LogRecord::Processor | |||||
| 37 | # appearing on CPAN. That signals the end-to-end SDK pipe is functional. | |||||
| 38 | # Estimated: late 2026, based on the Trace SDK timeline (~6-9 months after | |||||
| 39 | # the Trace API stabilised). | |||||
| 40 | # | |||||
| 41 | # Implementation sketch (for when the above blockers are resolved): | |||||
| 42 | # - Add an 'opentelemetry' sub-key to the HASH logger backend. | |||||
| 43 | # - In _log: call otel_logger_provider()->logger()->emit_record( | |||||
| 44 | # timestamp => Time::HiRes::time(), | |||||
| 45 | # severity_text => $level, | |||||
| 46 | # severity_number => $OTEL_SEVERITY{$level}, | |||||
| 47 | # body => $str, | |||||
| 48 | # attributes => $self->{ctx} ? { ctx => $self->{ctx} } : {}, | |||||
| 49 | # ); | |||||
| 50 | # - Map internal levels: trace=1, debug=5, info=9, notice=10, | |||||
| 51 | # warn=13, error=17 (OTel SeverityNumber spec, table 5). | |||||
| 52 | # - Store the provider reference, not a cached Logger, to survive | |||||
| 53 | # provider swaps (workaround for blocker 3 above). | |||||
| 54 | ||||||
| 55 | # Enforce strict variable declarations and enable common warnings | |||||
| 56 | 17 17 17 | 1059410 14 184 | use strict; | |||
| 57 | 17 17 17 | 22 15 327 | use warnings; | |||
| 58 | ||||||
| 59 | # Automatically throw exceptions on failed built-in I/O (open, close, print...) | |||||
| 60 | 17 17 17 | 2995 101579 31 | use autodie qw(:all); | |||
| 61 | ||||||
| 62 | # Core and CPAN dependencies | |||||
| 63 | 17 17 17 | 124702 24 435 | use Carp; | |||
| 64 | 17 17 17 | 4034 539641 248 | use Config::Abstraction 0.36; | |||
| 65 | 17 17 17 | 1459 14318 460 | use Data::Dumper; | |||
| 66 | 17 17 17 | 29 101 227 | use Params::Get 0.13; | |||
| 67 | 17 17 17 | 34 19 75 | use POSIX qw(strftime); | |||
| 68 | 17 17 17 | 480 11 256 | use Readonly; | |||
| 69 | 17 17 17 | 3713 22526 1032 | use Readonly::Values::Syslog 0.04; | |||
| 70 | 17 17 17 | 2875 4002 293 | use Return::Set; | |||
| 71 | 17 17 17 | 32 13 381 | use Scalar::Util 'blessed'; | |||
| 72 | ||||||
| 73 | # Sub::Private in enforce mode: _-prefixed subs decorated :Private croak when | |||||
| 74 | # called from outside this package. HARNESS_ACTIVE bypasses checks during | |||||
| 75 | # make test so white-box tests can still reach private methods. | |||||
| 76 | 17 | 107 | BEGIN { $Sub::Private::config{mode} = 'enforce' } | |||
| 77 | 17 17 17 | 3694 204902 54 | use Sub::Private; | |||
| 78 | ||||||
| 79 | # Sys::Syslog imported with bare-function names used in _log | |||||
| 80 | 17 17 17 | 6071 75296 9745 | use Sys::Syslog 0.28; | |||
| 81 | ||||||
| 82 | # --------------------------------------------------------------------------- | |||||
| 83 | # Module-level constants -- no magic strings or numbers anywhere below | |||||
| 84 | # --------------------------------------------------------------------------- | |||||
| 85 | ||||||
| 86 | # Default minimum log level when none is specified in new() | |||||
| 87 | Readonly::Scalar my $DEFAULT_LEVEL => 'warning'; | |||||
| 88 | ||||||
| 89 | # Default SMTP delivery parameters for the sendmail backend | |||||
| 90 | Readonly::Scalar my $DEFAULT_SMTP_HOST => 'localhost'; | |||||
| 91 | Readonly::Scalar my $DEFAULT_SMTP_PORT => 25; | |||||
| 92 | Readonly::Scalar my $DEFAULT_FROM_ADDR => 'noreply@localhost'; | |||||
| 93 | Readonly::Scalar my $MIN_PORT => 1; | |||||
| 94 | Readonly::Scalar my $MAX_PORT => 65535; | |||||
| 95 | ||||||
| 96 | # Default syslog connection parameters | |||||
| 97 | Readonly::Scalar my $DEFAULT_SYSLOG_FACILITY => 'local0'; | |||||
| 98 | Readonly::Scalar my $DEFAULT_SYSLOG_OPTIONS => 'cons,pid'; | |||||
| 99 | Readonly::Scalar my $DEFAULT_SYSLOG_IDENTITY => 'user'; | |||||
| 100 | ||||||
| 101 | # Default log-line format tokens for file/fd/scalar-path backends | |||||
| 102 | Readonly::Scalar my $DEFAULT_FORMAT => '%level%> [%timestamp%] %class% %callstack% %message%'; | |||||
| 103 | Readonly::Scalar my $DEFAULT_FORMAT_NOCLASS => '%level%> [%timestamp%] %callstack% %message%'; | |||||
| 104 | ||||||
| 105 | # Map internal level names to POSIX syslog priority strings | |||||
| 106 | Readonly::Hash my %LEVEL_TO_SYSLOG_PRIORITY => ( | |||||
| 107 | trace => 'debug', | |||||
| 108 | debug => 'debug', | |||||
| 109 | info => 'info', | |||||
| 110 | notice => 'notice', | |||||
| 111 | warn => 'warning', | |||||
| 112 | warning => 'warning', | |||||
| 113 | error => 'err', | |||||
| 114 | ); | |||||
| 115 | ||||||
| 116 | # Regex: characters forbidden in a log-file path (prevents command injection) | |||||
| 117 | Readonly::Scalar my $RE_SAFE_PATH => qr/^([^<>|*?;!`\$"\x00-\x1F]+)$/; | |||||
| 118 | ||||||
| 119 | # Regex: path component that would escape the intended directory | |||||
| 120 | Readonly::Scalar my $RE_DOTDOT => qr/\.\./; | |||||
| 121 | ||||||
| 122 | # Regex: characters forbidden in an SMTP hostname (allows a-z, A-Z, 0-9, dot, hyphen) | |||||
| 123 | Readonly::Scalar my $RE_SAFE_HOST => qr/[^a-zA-Z0-9.\-]/; | |||||
| 124 | ||||||
| 125 | # Regex: a valid TCP port number string (decimal digits only; range checked separately) | |||||
| 126 | Readonly::Scalar my $RE_PORT => qr/^\d+$/; | |||||
| 127 | ||||||
| 128 | # Default path to the journald native-protocol socket on systemd systems | |||||
| 129 | Readonly::Scalar my $DEFAULT_JOURNALD_SOCKET => '/run/systemd/journal/socket'; | |||||
| 130 | ||||||
| 131 - 139 | =head1 NAME Log::Abstraction - Logging Abstraction Layer =head1 VERSION 0.32 =cut | |||||
| 140 | ||||||
| 141 | our $VERSION = 0.32; | |||||
| 142 | ||||||
| 143 - 378 | =head1 SYNOPSIS
use Log::Abstraction;
my $logger = Log::Abstraction->new(logger => 'logfile.log');
$logger->debug('This is a debug message');
$logger->info('This is an info message');
$logger->notice('This is a notice message');
$logger->trace('This is a trace message');
$logger->warn({ warning => 'This is a warning message' });
=head1 DESCRIPTION
The C<Log::Abstraction> class provides a flexible logging layer on top of
different types of loggers, including code references, arrays, file paths,
and objects. It also supports logging to syslog if configured.
=head1 METHODS
=head2 new
my $logger = Log::Abstraction->new(%args);
my $logger = Log::Abstraction->new(\%args);
my $logger = Log::Abstraction->new($file_path);
# Clone with optional overrides
my $clone = $logger->new(level => 'debug');
Creates a new C<Log::Abstraction> instance, or clones an existing one when
called on an object.
=head3 Arguments
=over 4
=item * C<carp_on_warn>
If set to 1, and no C<logger> is given, call C<Carp::carp> on C<warn()>.
Also causes C<error()> to C<carp> if C<croak_on_error> is not set.
=item * C<croak_on_error>
If set to 1, and no C<logger> is given, call C<Carp::croak> on C<error()>.
=item * C<config_file>
Path to a configuration file (YAML, XML, INI, etc.) whose contents are
merged with the constructor arguments. On non-Windows systems the class
can also be configured via environment variables prefixed with
C<"Log::Abstraction::">. For example:
export Log::Abstraction::script_name=foo
=item * C<ctx>
Arbitrary context value passed through to CODE-ref logger callbacks as
C<$args-E<gt>{ctx}>.
=item * C<format>
Format string for file/fd backends. Tokens expanded at log time:
%callstack% caller file and line number
%class% blessed class of the logger object
%level% upper-cased level name
%message% the joined log message
%timestamp% YYYY-MM-DD HH:MM:SS (local time)
%env_FOO% value of $ENV{FOO}, or empty string if unset
The special value C<"json"> (not a format string but a magic keyword) switches
all file and fd backends to emit one compact JSON object per log line:
{"timestamp":"...","level":"info","message":"...","file":"...","line":42}
This format is compatible with log aggregators such as journald, Loki,
Elasticsearch, and Splunk. C<class> is included when the logger is a subclass
of C<Log::Abstraction>.
B<Security note:> because C<format> may contain C<%env_*%> tokens, avoid
granting untrusted sources write access to config files that set this key.
=item * C<level>
Minimum level at which to emit log entries. Defaults to C<"warning">.
Valid values (case-insensitive): C<trace>, C<debug>, C<info>, C<notice>,
C<warn>/C<warning>, C<error>.
=item * C<logger>
One of:
=over 4
=item * A code reference -- called with a hashref C<{ class, file, line, level, message, ctx }>
=item * An object -- method matching the level name is called on it
=item * A hash reference -- may contain C<file>, C<array>, C<fd>, C<syslog>, C<journald>, and/or C<sendmail> keys
=item * An array reference -- C<{ level, message }> hashrefs are pushed onto it
=item * A scalar string -- treated as a file path to append to
=back
When not supplied, L<Log::Log4perl> is initialised as the default backend.
The C<sendmail> sub-hash supports:
C<host>, C<port>, C<to>, C<from>, C<subject>, C<level>, C<min_interval>.
At most one email is sent per C<min_interval> seconds per instance.
The C<journald> sub-hash sends each message as a single datagram to the
systemd journal using the journald native protocol. Supported keys:
=over 4
=item * C<socket> -- path to the journald socket (default: F</run/systemd/journal/socket>)
=item * C<identifier> -- value for the C<SYSLOG_IDENTIFIER> field (default: basename of C<$0>)
=item * any other key -- included verbatim as an uppercase journald field name
=back
The C<PRIORITY> field is set automatically from the log level (0=emerg...7=debug).
Delivery failures are silent (C<Carp::carp> only); the application is never crashed by a journald error.
=item * C<script_name>
Script name reported to syslog. Auto-detected from C<$0> if not supplied.
=item * C<verbose>
When using the default Log::Log4perl backend, raises the logging level to
DEBUG when set to a true value.
=back
=head3 Returns
A blessed C<Log::Abstraction> object.
=head3 Side Effects
Loads C<File::Basename> if C<syslog> is configured and C<script_name> is
not supplied. Loads C<Log::Log4perl> if no logger backend is specified.
=head3 Example
my $logger = Log::Abstraction->new(
level => 'debug',
logger => \@messages,
);
my $clone = $logger->new(level => 'info');
=head3 API Specification
=head4 Input
{
carp_on_warn => { type => 'boolean', optional => 1 },
config_file => { type => 'string', optional => 1 },
croak_on_error => { type => 'boolean', optional => 1 },
ctx => { optional => 1 },
format => { type => 'string', optional => 1 },
level => { type => 'string', regex => qr/^(trace|debug|info|notice|warn(?:ing)?|error)$/i, optional => 1 },
logger => { optional => 1 },
script_name => { type => 'string', optional => 1 },
verbose => { type => 'boolean', optional => 1 },
}
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=head3 MESSAGES
Error Meaning / Action
---------------------------------------- -----------------------------------------
"<class>: <path>: File not readable" config_file path exists but is unreadable.
Check file permissions.
"<class>: Can't load configuration Config::Abstraction could not parse the
from <path>" file. Check syntax and format.
"<class>: syslog needs to know the syslog backend requested but script_name
script name" could not be determined. Pass it explicitly.
"<class>: attempt to encapsulate logger => Log::Abstraction would create
Log::Abstraction as a logging class, a needless forwarding loop. Use a
that would add a needless indirection" different backend.
"<class>: invalid syslog level '<l>'" level value is not a recognised syslog
level name. Use trace/debug/info/notice/
warn/warning/error.
=head3 PSEUDOCODE
FUNCTION new(class_or_obj, args...)
Parse args:
IF single non-hash scalar
THEN store as logger shorthand
ELSE extract named params via Params::Get
IF config_file present:
CROAK if file is not readable
Load via Config::Abstraction, merge into args (constructor args win)
Restore caller-supplied array ref that config merge would have dropped
IF called on a blessed instance (clone form):
shallow-clone self merged with override args
validate and store new level integer if level given in args
deep-copy message history list
RETURN clone
IF syslog requested and script_name not supplied:
auto-detect script name via File::Basename
CROAK if still undefined
IF logger arg is a Log::Abstraction object:
CROAK (would create a needless forwarding loop)
IF no logger AND no file AND no array:
load Log::Log4perl, easy_init at DEBUG or ERROR per verbose flag
store Log4perl logger as the backend
Normalise and validate level:
IF level is an arrayref, take first element
lc() the level string
CROAK if not in syslog_values lookup
default to $DEFAULT_LEVEL if not supplied
RETURN bless { messages => [], merged args, level => numeric } as class
END FUNCTION
=cut | |||||
| 379 | ||||||
| 380 | sub new { | |||||
| 381 | 1391 | 1809845 | my $class = shift; | |||
| 382 | ||||||
| 383 | # Accept a plain hash, a hashref, or a single scalar (file-path shorthand) | |||||
| 384 | 1391 | 780 | my %args; | |||
| 385 | 1391 | 1918 | if((scalar(@_) == 1) && (ref($_[0]) ne 'HASH')) { | |||
| 386 | 5 | 6 | $args{'logger'} = shift; | |||
| 387 | } elsif(my $params = Params::Get::get_params(undef, \@_)) { | |||||
| 388 | 370 370 | 4691 449 | %args = %{$params}; | |||
| 389 | } | |||||
| 390 | ||||||
| 391 | # Load configuration from a file when config_file is present | |||||
| 392 | 1391 | 7554 | if(exists($args{'config_file'})) { | |||
| 393 | 10 | 69 | if(!-r $args{'config_file'}) { | |||
| 394 | 2 | 60 | croak("$class: ", $args{'config_file'}, ': File not readable'); | |||
| 395 | } | |||||
| 396 | 8 | 39 | if(my $config = Config::Abstraction->new( | |||
| 397 | config_dirs => [''], | |||||
| 398 | config_file => $args{'config_file'}, | |||||
| 399 | env_prefix => "${class}::", | |||||
| 400 | )) { | |||||
| 401 | # Merge file config with constructor args; constructor wins | |||||
| 402 | 8 | 13431 | $config = $config->all(); | |||
| 403 | 8 | 41 | if($config->{$class}) { | |||
| 404 | 2 | 2 | $config = $config->{$class}; | |||
| 405 | } | |||||
| 406 | 8 | 7 | my $array = $args{'array'}; | |||
| 407 | 8 8 | 18 17 | %args = (%{$config}, %args); | |||
| 408 | # Restore caller-supplied array ref after merge (config can't supply refs) | |||||
| 409 | 8 | 14 | if($array) { | |||
| 410 | 4 | 6 | $args{'array'} = $array; | |||
| 411 | } | |||||
| 412 | } else { | |||||
| 413 | 0 | 0 | croak("$class: Can't load configuration from ", $args{'config_file'}); | |||
| 414 | } | |||||
| 415 | } | |||||
| 416 | ||||||
| 417 | # Handle function-call form: Log::Abstraction::new() with no class | |||||
| 418 | 1389 | 1547 | if(!defined($class)) { | |||
| 419 | 1 | 1 | $class = __PACKAGE__; | |||
| 420 | } elsif(Scalar::Util::blessed($class)) { | |||||
| 421 | # Called on an existing instance -- return a shallow clone | |||||
| 422 | 1019 1019 | 497 1062 | my $clone = bless { %{$class}, %args }, ref($class); | |||
| 423 | 1019 | 817 | if(my $level = $args{'level'}) { | |||
| 424 | 6 | 7 | $level = lc($level); | |||
| 425 | 6 | 9 | if(!defined($syslog_values{$level})) { | |||
| 426 | 1 | 8 | Carp::croak("$class: invalid syslog level '$level'"); | |||
| 427 | } | |||||
| 428 | 5 | 5 | $clone->{level} = $syslog_values{$level}; | |||
| 429 | } | |||||
| 430 | # Deep-copy the message history so parent and clone diverge independently | |||||
| 431 | 1018 1018 | 531 641 | $clone->{messages} = [ @{$class->{messages}} ]; | |||
| 432 | 1018 | 978 | return $clone; | |||
| 433 | } | |||||
| 434 | ||||||
| 435 | # Auto-detect script name when syslog backend is requested | |||||
| 436 | 370 | 511 | if($args{'syslog'} && !$args{'script_name'}) { | |||
| 437 | 3 | 9 | require File::Basename; | |||
| 438 | 3 | 77 | $args{'script_name'} = File::Basename::basename($ENV{'SCRIPT_NAME'} || $0); | |||
| 439 | croak("$class: syslog needs to know the script name") | |||||
| 440 | 3 | 5 | if(!defined($args{'script_name'})); | |||
| 441 | } | |||||
| 442 | ||||||
| 443 | # Reject attempts to use this module as its own logger backend | |||||
| 444 | 370 | 684 | if(defined(my $logger = $args{logger})) { | |||
| 445 | 116 | 173 | if(Scalar::Util::blessed($logger) && (ref($logger) eq __PACKAGE__)) { | |||
| 446 | 2 | 10 | croak( | |||
| 447 | "$class: attempt to encapsulate ", | |||||
| 448 | __PACKAGE__, | |||||
| 449 | ' as a logging class, that would add a needless indirection', | |||||
| 450 | ); | |||||
| 451 | } | |||||
| 452 | } elsif((!$args{'file'}) && (!$args{'array'})) { | |||||
| 453 | # Fall back to Log::Log4perl when no other backend is configured | |||||
| 454 | 17 | 3462 | require Log::Log4perl; | |||
| 455 | 17 | 145250 | Log::Log4perl->import(); | |||
| 456 | Log::Log4perl->easy_init( | |||||
| 457 | 17 | 301 | $args{verbose} ? $Log::Log4perl::DEBUG : $Log::Log4perl::ERROR | |||
| 458 | ); | |||||
| 459 | 17 | 14860 | $args{'logger'} = Log::Log4perl->get_logger(); | |||
| 460 | } | |||||
| 461 | ||||||
| 462 | # Resolve and store the numeric threshold for the requested level | |||||
| 463 | 368 | 1976 | my $level = $args{'level'}; | |||
| 464 | 368 | 285 | if($level) { | |||
| 465 | 348 | 316 | if(ref($level) eq 'ARRAY') { | |||
| 466 | 1 | 2 | $level = $level->[0]; | |||
| 467 | } | |||||
| 468 | 348 | 284 | $level = lc($level); | |||
| 469 | 348 | 372 | if(!defined($syslog_values{$level})) { | |||
| 470 | 3 | 23 | Carp::croak("$class: invalid syslog level '$level'"); | |||
| 471 | } | |||||
| 472 | 345 | 279 | $args{'level'} = $level; | |||
| 473 | } else { | |||||
| 474 | 20 | 20 | $args{'level'} = $DEFAULT_LEVEL; | |||
| 475 | } | |||||
| 476 | ||||||
| 477 | # Construct and return the blessed object | |||||
| 478 | return bless { | |||||
| 479 | messages => [], | |||||
| 480 | %args, | |||||
| 481 | 365 | 1157 | level => $syslog_values{ $args{'level'} }, | |||
| 482 | }, $class; | |||||
| 483 | } | |||||
| 484 | ||||||
| 485 | # --------------------------------------------------------------------------- | |||||
| 486 | # _sanitize_email_header -- remove CR/LF to prevent SMTP header injection | |||||
| 487 | # | |||||
| 488 | # Purpose: Strip carriage-return and line-feed characters from any string | |||||
| 489 | # that will appear in a MIME header field (To, From, Subject). | |||||
| 490 | # Entry: $value -- a scalar, possibly containing \r, \n, or \r\n. | |||||
| 491 | # Exit: Returns the sanitised scalar, or undef if input was undef. | |||||
| 492 | # Notes: Called from _log before every header_set() call. | |||||
| 493 | # --------------------------------------------------------------------------- | |||||
| 494 | sub _sanitize_email_header :Private { | |||||
| 495 | 0 | 0 | my ($value) = @_; | |||
| 496 | ||||||
| 497 | 0 | 0 | return unless defined $value; | |||
| 498 | ||||||
| 499 | # Strip all CR, LF and CRLF sequences | |||||
| 500 | 0 | 0 | $value =~ s/\r\n?|\n//g; | |||
| 501 | ||||||
| 502 | 0 | 0 | return Return::Set::set_return( | |||
| 503 | $value, | |||||
| 504 | { type => 'string', 'matches' => qr/^[^\r\n]*$/ }, | |||||
| 505 | ); | |||||
| 506 | 17 17 17 | 69 20 264 | } | |||
| 507 | ||||||
| 508 | # --------------------------------------------------------------------------- | |||||
| 509 | # _validate_file_path -- validate and untaint a filesystem path | |||||
| 510 | # | |||||
| 511 | # Purpose: Ensure a caller-supplied path does not contain dangerous | |||||
| 512 | # characters or directory-traversal sequences before it is | |||||
| 513 | # passed to open(). | |||||
| 514 | # Entry: $self -- the logger object (for error context in croak). | |||||
| 515 | # $path -- the raw path string to validate. | |||||
| 516 | # Exit: Returns the untainted capture (Perl taint-safe string). | |||||
| 517 | # Croaks with a descriptive message if validation fails. | |||||
| 518 | # Notes: Blocks the character set <, >, |, *, ?, ;, !, `, $, " | |||||
| 519 | # and all C0 control characters, as well as ".." sequences. | |||||
| 520 | # --------------------------------------------------------------------------- | |||||
| 521 | sub _validate_file_path :Private { | |||||
| 522 | 2 | 2 | my ($self, $path) = @_; | |||
| 523 | ||||||
| 524 | # Block ".." path-traversal and all dangerous shell metacharacters | |||||
| 525 | 2 | 13 | if($path =~ $RE_SAFE_PATH && $path !~ $RE_DOTDOT) { | |||
| 526 | 2 | 3 | return $1; # $1 is the untainted capture from RE_SAFE_PATH | |||
| 527 | } | |||||
| 528 | 0 | 0 | Carp::croak(ref($self), ": Invalid file name: $path"); | |||
| 529 | 17 17 17 | 2236 13 112 | } | |||
| 530 | ||||||
| 531 | # --------------------------------------------------------------------------- | |||||
| 532 | # _journald_send -- encode fields and send one datagram to the journald socket | |||||
| 533 | # | |||||
| 534 | # Purpose: Format key=value fields in the journald native protocol and | |||||
| 535 | # deliver them as a single Unix-domain SOCK_DGRAM packet. | |||||
| 536 | # Entry: $self -- the logger object (unused but required for | |||||
| 537 | # consistent OOP dispatch; enforces Sub::Private). | |||||
| 538 | # $socket_path -- filesystem path of the journald socket. | |||||
| 539 | # %fields -- FIELD_NAME => value pairs; names must be | |||||
| 540 | # uppercase ASCII + digits + underscore. | |||||
| 541 | # Exit: Returns nothing. Croaks on socket or send failure (the caller | |||||
| 542 | # wraps every call in eval{} so failures are silent to the app). | |||||
| 543 | # Side effects: Opens a transient Unix datagram socket, sends, closes. | |||||
| 544 | # Notes: Values containing newline or NUL use the binary framing | |||||
| 545 | # (field-name NL uint64-LE-length value NL) as specified by | |||||
| 546 | # https://systemd.io/JOURNAL_NATIVE_PROTOCOL/. | |||||
| 547 | # Values without newlines or NULs use the simpler FIELD=VALUE NL | |||||
| 548 | # text format. | |||||
| 549 | # --------------------------------------------------------------------------- | |||||
| 550 | sub _journald_send :Private { | |||||
| 551 | 0 | 0 | my ($self, $socket_path, %fields) = @_; | |||
| 552 | ||||||
| 553 | # Build the datagram payload from all supplied fields | |||||
| 554 | 0 | 0 | my $payload = ''; | |||
| 555 | 0 | 0 | for my $key (sort keys %fields) { | |||
| 556 | 0 | 0 | my $value = defined($fields{$key}) ? "$fields{$key}" : ''; | |||
| 557 | 0 | 0 | if($value =~ /[\n\0]/) { | |||
| 558 | # Binary framing: field-name LF uint64LE-length value LF | |||||
| 559 | 0 | 0 | $payload .= $key . "\n" . pack('Q<', length($value)) . $value . "\n"; | |||
| 560 | } else { | |||||
| 561 | 0 | 0 | $payload .= "$key=$value\n"; | |||
| 562 | } | |||||
| 563 | } | |||||
| 564 | ||||||
| 565 | # Open a Unix-domain datagram socket, send, and close | |||||
| 566 | 0 | 0 | require Socket; | |||
| 567 | 0 | 0 | socket(my $sock, Socket::AF_UNIX(), Socket::SOCK_DGRAM(), 0); | |||
| 568 | 0 | 0 | my $dest = Socket::sockaddr_un($socket_path); | |||
| 569 | 0 | 0 | send($sock, $payload, 0, $dest); | |||
| 570 | 0 | 0 | close $sock; | |||
| 571 | 17 17 17 | 3001 14 122 | } | |||
| 572 | ||||||
| 573 | # --------------------------------------------------------------------------- | |||||
| 574 | # _format_message -- expand a log-format string into a final log line | |||||
| 575 | # | |||||
| 576 | # Purpose: Centralise the repeated format-token substitution so that | |||||
| 577 | # file, fd, and scalar-path backends all share one code path. | |||||
| 578 | # Entry: $self -- the logger object (source of 'format' setting). | |||||
| 579 | # $level -- log level string (e.g. 'debug'). | |||||
| 580 | # $str -- the already-joined message string. | |||||
| 581 | # $use_class -- 1 to include %class% in the default format, | |||||
| 582 | # 0 to use the no-class format. | |||||
| 583 | # $caller_file -- pre-resolved source file of the logging call. | |||||
| 584 | # $caller_line -- pre-resolved source line of the logging call. | |||||
| 585 | # Exit: Returns the formatted log line (without trailing newline). | |||||
| 586 | # Notes: %env_FOO% tokens are expanded with a // '' fallback so that | |||||
| 587 | # missing environment variables expand silently to empty string. | |||||
| 588 | # caller_file/caller_line are computed by _log at the correct | |||||
| 589 | # stack depth (adjusted for the extra _high_priority frame on | |||||
| 590 | # warn/error calls) so the reported location is always the | |||||
| 591 | # caller's code, not an internal dispatch frame. | |||||
| 592 | # | |||||
| 593 | # Pseudocode: | |||||
| 594 | # FUNCTION _format_message(self, level, str, use_class, caller_file, caller_line) | |||||
| 595 | # IF self->{'format'} eq 'json': | |||||
| 596 | # Build hash: timestamp, level, message, file=caller_file, line=caller_line | |||||
| 597 | # (+ class if subclass) | |||||
| 598 | # RETURN JSON::PP::encode_json(\%hash) [single compact line] | |||||
| 599 | # | |||||
| 600 | # Choose default format template: | |||||
| 601 | # use_class=1 â DEFAULT_FORMAT (includes %class%) | |||||
| 602 | # use_class=0 â DEFAULT_FORMAT_NOCLASS | |||||
| 603 | # Override with self->{'format'} if the caller supplied a custom format | |||||
| 604 | # | |||||
| 605 | # Compute token values: | |||||
| 606 | # ulevel = uc(level) | |||||
| 607 | # class = blessed class if it is a subclass, else '' (base package) | |||||
| 608 | # callstack = caller_file and caller_line | |||||
| 609 | # timestamp = strftime 'YYYY-MM-DD HH:MM:SS' | |||||
| 610 | # | |||||
| 611 | # Expand tokens in format string: | |||||
| 612 | # %level% â ulevel | |||||
| 613 | # %class% â class (may be empty) | |||||
| 614 | # %message% â str | |||||
| 615 | # %callstack% â callstack | |||||
| 616 | # %timestamp% â timestamp | |||||
| 617 | # %env_FOO% â $ENV{FOO} // '' (silent if env var unset) | |||||
| 618 | # | |||||
| 619 | # RETURN formatted line string | |||||
| 620 | # END FUNCTION | |||||
| 621 | # --------------------------------------------------------------------------- | |||||
| 622 | sub _format_message :Private { | |||||
| 623 | 2 | 3 | my ($self, $level, $str, $use_class, $caller_file, $caller_line) = @_; | |||
| 624 | ||||||
| 625 | 2 | 2 | my $format = $self->{'format'}; | |||
| 626 | ||||||
| 627 | # 'json' is a magic format value: emit a compact JSON object per line | |||||
| 628 | 2 | 5 | if(defined($format) && ($format eq 'json')) { | |||
| 629 | 0 | 0 | require JSON::PP; | |||
| 630 | 0 | 0 | my $bclass = blessed($self); | |||
| 631 | 0 | 0 | my $class = ($bclass && $bclass ne __PACKAGE__) ? $bclass : undef; | |||
| 632 | 0 | 0 | my %obj = ( | |||
| 633 | timestamp => strftime('%Y-%m-%d %H:%M:%S', localtime), | |||||
| 634 | level => $level, | |||||
| 635 | message => $str, | |||||
| 636 | file => $caller_file, | |||||
| 637 | line => $caller_line + 0, | |||||
| 638 | ); | |||||
| 639 | 0 | 0 | $obj{class} = $class if defined($class); | |||
| 640 | 0 | 0 | return JSON::PP::encode_json(\%obj); | |||
| 641 | } | |||||
| 642 | ||||||
| 643 | # Select the appropriate default when no custom format is configured ('' is falsy) | |||||
| 644 | 2 | 1 | my $default = $use_class ? $DEFAULT_FORMAT : $DEFAULT_FORMAT_NOCLASS; | |||
| 645 | 2 | 3 | $format = $format || $default; | |||
| 646 | ||||||
| 647 | 2 | 3 | my $ulevel = uc($level); | |||
| 648 | ||||||
| 649 | # Suppress the class name for the base package (only show for subclasses) | |||||
| 650 | 2 | 2 | my $bclass = blessed($self); | |||
| 651 | 2 | 4 | my $class = ($bclass && $bclass ne __PACKAGE__) ? $bclass : ''; | |||
| 652 | ||||||
| 653 | 2 | 3 | my $callstack = "$caller_file $caller_line"; | |||
| 654 | 2 | 64 | my $timestamp = strftime '%Y-%m-%d %H:%M:%S', localtime; | |||
| 655 | ||||||
| 656 | # Expand all recognised tokens in a single pass per token type | |||||
| 657 | 2 | 4 | $format =~ s/%level%/$ulevel/g; | |||
| 658 | 2 | 2 | $format =~ s/%class%/$class/g; | |||
| 659 | 2 | 3 | $format =~ s/%message%/$str/g; | |||
| 660 | 2 | 3 | $format =~ s/%callstack%/$callstack/g; | |||
| 661 | 2 | 2 | $format =~ s/%timestamp%/$timestamp/g; | |||
| 662 | 2 1 | 4 2 | $format =~ s/%env_(\w+)%/$ENV{$1} \/\/ ''/ge; | |||
| 663 | ||||||
| 664 | 2 | 3 | return $format; | |||
| 665 | 17 17 17 | 4725 13 132 | } | |||
| 666 | ||||||
| 667 | # --------------------------------------------------------------------------- | |||||
| 668 | # _log -- central dispatcher that routes a message to all active backends | |||||
| 669 | # | |||||
| 670 | # Purpose: Every public logging method ultimately calls _log. It checks | |||||
| 671 | # the current level threshold, records the message in the | |||||
| 672 | # internal history, then dispatches to the configured backend(s). | |||||
| 673 | # Entry: $self -- the logger object. | |||||
| 674 | # $level -- one of trace/debug/info/notice/warn/error. | |||||
| 675 | # @messages -- one or more message strings (or a single arrayref). | |||||
| 676 | # Exit: Returns nothing (void). Croaks on configuration errors. | |||||
| 677 | # Side effects: Appends to $self->{messages}. May write to a file, fd, | |||||
| 678 | # array, syslog, or email backend. May load Email::* modules. | |||||
| 679 | # Notes: Enforced private: croaks if called from outside this package. | |||||
| 680 | # The caller depth used for file/line in CODE-ref callbacks is | |||||
| 681 | # caller(1), which resolves correctly for trace/debug/info/notice | |||||
| 682 | # but points one frame inward for warn/error (via _high_priority). | |||||
| 683 | # | |||||
| 684 | # Pseudocode: | |||||
| 685 | # FUNCTION _log(self, level, messages...) | |||||
| 686 | # CROAK if caller package is not this package (private method guard) | |||||
| 687 | # CROAK if level is not a recognised syslog level name | |||||
| 688 | # RETURN early if syslog_values{level} > self->{'level'} (below threshold) | |||||
| 689 | # | |||||
| 690 | # Flatten single-arrayref argument to a list; filter out undefs; join to $str | |||||
| 691 | # Push { level, message } onto self->{messages} (always recorded) | |||||
| 692 | # Set $class = '' for base package, else the blessed class name | |||||
| 693 | # | |||||
| 694 | # IF self->{'logger'} is a CODE ref: | |||||
| 695 | # Build args hashref { class, file, line, level, message, ctx? } | |||||
| 696 | # Call logger->( args ) | |||||
| 697 | # | |||||
| 698 | # ELSIF self->{'logger'} is an ARRAY ref: | |||||
| 699 | # Push { level, message } | |||||
| 700 | # | |||||
| 701 | # ELSIF self->{'logger'} is a HASH ref: | |||||
| 702 | # IF 'file' key present: | |||||
| 703 | # validate path; format line; (eval) open>>file, print, close | |||||
| 704 | # IF 'array' key present: | |||||
| 705 | # push { level, message } | |||||
| 706 | # IF 'sendmail' key present with a 'to' address: | |||||
| 707 | # IF level passes threshold AND not throttled: | |||||
| 708 | # CROAK if host contains unsafe characters | |||||
| 709 | # CROAK if port is out of 1-65535 range | |||||
| 710 | # (eval) load Email::* modules; build email with sanitised headers; | |||||
| 711 | # send via SMTP transport; carp on delivery failure | |||||
| 712 | # Record timestamp for throttle | |||||
| 713 | # IF 'syslog' key present: | |||||
| 714 | # IF level passes threshold: | |||||
| 715 | # Open syslog connection on first use (setlogsock, openlog) | |||||
| 716 | # (eval) map level to syslog priority; call Sys::Syslog::syslog; | |||||
| 717 | # carp with Data::Dumper output on failure | |||||
| 718 | # IF 'journald' key present: | |||||
| 719 | # Map level to syslog PRIORITY integer | |||||
| 720 | # Build fields: MESSAGE, PRIORITY, SYSLOG_IDENTIFIER, plus any extra | |||||
| 721 | # (eval) _journald_send(socket_path, %fields); carp on failure | |||||
| 722 | # IF 'fd' key present: | |||||
| 723 | # Format line; print to filehandle | |||||
| 724 | # ELSIF no actionable key (no file/array/syslog/sendmail/journald/fd): | |||||
| 725 | # CROAK (configuration error) | |||||
| 726 | # | |||||
| 727 | # ELSIF self->{'logger'} is an unblessed scalar (file path): | |||||
| 728 | # Validate path; format line; (eval) open>>file, print, close | |||||
| 729 | # | |||||
| 730 | # ELSIF self->{'logger'} is a blessed object: | |||||
| 731 | # Map 'notice' to 'info' for backends without notice() (e.g. Log::Log4perl) | |||||
| 732 | # CROAK if object cannot handle the level | |||||
| 733 | # Call $logger->$level(@messages) | |||||
| 734 | # | |||||
| 735 | # ELSIF self->{'array'} top-level key: | |||||
| 736 | # Push { level, message } | |||||
| 737 | # | |||||
| 738 | # IF self->{'file'} top-level key: | |||||
| 739 | # Validate path; format line; (eval) open>>file, print, close | |||||
| 740 | # IF self->{'fd'} top-level key: | |||||
| 741 | # Format line; print to filehandle | |||||
| 742 | # END FUNCTION | |||||
| 743 | # --------------------------------------------------------------------------- | |||||
| 744 | sub _log :Private { | |||||
| 745 | 68 | 55 | my ($self, $level, @messages) = @_; | |||
| 746 | ||||||
| 747 | # Reject direct calls from outside this package (also enforced by :Private) | |||||
| 748 | 68 | 81 | if(!(caller)[0]->isa(__PACKAGE__)) { | |||
| 749 | 0 | 0 | Carp::croak('Illegal Operation: _log is a private method'); | |||
| 750 | } | |||||
| 751 | ||||||
| 752 | # Sanity-check the level (should not be reachable in normal use) | |||||
| 753 | 68 | 555 | if(!defined($syslog_values{$level})) { | |||
| 754 | 0 | 0 | Carp::croak(ref($self), ": Invalid level '$level'"); | |||
| 755 | } | |||||
| 756 | ||||||
| 757 | # Drop messages that fall below the configured threshold | |||||
| 758 | 68 | 65 | if($syslog_values{$level} > $self->{'level'}) { | |||
| 759 | 16 | 13 | return; | |||
| 760 | } | |||||
| 761 | ||||||
| 762 | # Flatten a single arrayref argument to a plain list | |||||
| 763 | 52 | 74 | if((scalar(@messages) == 1) && (ref($messages[0]) eq 'ARRAY')) { | |||
| 764 | 1 1 | 1 1 | @messages = @{$messages[0]}; | |||
| 765 | } | |||||
| 766 | ||||||
| 767 | # Remove any undef elements before joining | |||||
| 768 | 52 54 | 42 62 | @messages = grep { defined } @messages; | |||
| 769 | 52 | 48 | my $str = join('', @messages); | |||
| 770 | 52 | 32 | chomp($str); | |||
| 771 | ||||||
| 772 | # Record in the internal message history regardless of backend | |||||
| 773 | 52 52 | 25 79 | push @{$self->{messages}}, { level => $level, message => $str }; | |||
| 774 | ||||||
| 775 | # Compute class once; suppress the package name for base-class instances | |||||
| 776 | 52 | 70 | my $class = blessed($self) || $self; | |||
| 777 | 52 | 43 | if($class eq __PACKAGE__) { | |||
| 778 | 52 | 25 | $class = ''; | |||
| 779 | } | |||||
| 780 | ||||||
| 781 | # Resolve caller file/line at the correct stack depth. | |||||
| 782 | # For trace/debug/info/notice: _log â public_method â user â depth=1 | |||||
| 783 | # For warn/error: _log â _high_priority â public_method â user â depth=2 | |||||
| 784 | 52 | 44 | my $depth = ((caller(1))[3] // '') =~ /::_high_priority$/ ? 2 : 1; | |||
| 785 | 52 | 375 | my $caller_file = (caller($depth))[1]; | |||
| 786 | 52 | 303 | my $caller_line = (caller($depth))[2]; | |||
| 787 | ||||||
| 788 | # ----------------------------------------------------------------------- | |||||
| 789 | # Dispatch to the configured backend(s) | |||||
| 790 | # ----------------------------------------------------------------------- | |||||
| 791 | 52 | 308 | if(my $logger = $self->{'logger'}) { | |||
| 792 | 4 | 5 | if(ref($logger) eq 'CODE') { | |||
| 793 | # CODE-ref backend: build the args hashref and invoke the callback | |||||
| 794 | 2 | 5 | my $args = { | |||
| 795 | class => blessed($self) || __PACKAGE__, | |||||
| 796 | file => $caller_file, | |||||
| 797 | line => $caller_line, | |||||
| 798 | level => $level, | |||||
| 799 | message => \@messages, | |||||
| 800 | }; | |||||
| 801 | 2 | 3 | if(my $ctx = $self->{ctx}) { | |||
| 802 | 1 | 1 | $args->{ctx} = $ctx; | |||
| 803 | } | |||||
| 804 | 2 | 2 | $logger->($args); | |||
| 805 | } elsif(ref($logger) eq 'ARRAY') { | |||||
| 806 | # ARRAY-ref backend: push a simple hashref | |||||
| 807 | 2 2 | 2 2 | push @{$logger}, { level => $level, message => $str }; | |||
| 808 | } elsif(ref($logger) eq 'HASH') { | |||||
| 809 | # HASH backend: route to whichever sub-keys are present | |||||
| 810 | ||||||
| 811 | # -- file sub-backend ------------------------------------------- | |||||
| 812 | 0 | 0 | if(my $raw_file = $logger->{'file'}) { | |||
| 813 | 0 | 0 | my $file = $self->_validate_file_path($raw_file); | |||
| 814 | 0 | 0 | my $use_class = ($class ne '') ? 1 : 0; | |||
| 815 | 0 | 0 | my $line = $self->_format_message($level, $str, $use_class, $caller_file, $caller_line); | |||
| 816 | # Log failures are silent by design; the app must not crash on I/O errors | |||||
| 817 | 0 | 0 | eval { | |||
| 818 | 0 | 0 | open(my $fout, '>>', $file); | |||
| 819 | 0 | 0 | print $fout "$line\n"; | |||
| 820 | 0 | 0 | close $fout; | |||
| 821 | }; | |||||
| 822 | } | |||||
| 823 | ||||||
| 824 | # -- array sub-backend ------------------------------------------ | |||||
| 825 | 0 | 0 | if(my $array = $logger->{'array'}) { | |||
| 826 | 0 0 | 0 0 | push @{$array}, { level => $level, message => $str }; | |||
| 827 | } | |||||
| 828 | ||||||
| 829 | # -- sendmail sub-backend --------------------------------------- | |||||
| 830 | 0 | 0 | if(exists($logger->{'sendmail'}) && exists($logger->{'sendmail'}->{'to'})) { | |||
| 831 | 0 | 0 | my $sm = $logger->{'sendmail'}; | |||
| 832 | ||||||
| 833 | # Check the level threshold for email (undef means send always) | |||||
| 834 | 0 | 0 | if((!defined($sm->{'level'})) || | |||
| 835 | ($syslog_values{$level} <= $syslog_values{ $sm->{'level'} })) { | |||||
| 836 | ||||||
| 837 | # Honour the minimum-interval throttle | |||||
| 838 | 0 | 0 | my $throttled = 0; | |||
| 839 | 0 | 0 | if(my $interval = $sm->{'min_interval'}) { | |||
| 840 | 0 | 0 | my $now = time(); | |||
| 841 | $throttled = defined($self->{_last_email_sent}) | |||||
| 842 | 0 | 0 | && ($now - $self->{_last_email_sent}) < $interval; | |||
| 843 | } | |||||
| 844 | ||||||
| 845 | 0 | 0 | if(!$throttled) { | |||
| 846 | # Validate host and port before any eval so bad config croaks immediately | |||||
| 847 | 0 | 0 | my $host = $sm->{'host'} || $DEFAULT_SMTP_HOST; | |||
| 848 | 0 | 0 | Carp::croak(ref($self), ": Invalid SMTP host: $host") | |||
| 849 | if $host =~ $RE_SAFE_HOST; | |||||
| 850 | 0 | 0 | my $port = $sm->{'port'} || $DEFAULT_SMTP_PORT; | |||
| 851 | 0 | 0 | Carp::croak(ref($self), ": Invalid SMTP port: $port") | |||
| 852 | unless $port =~ $RE_PORT | |||||
| 853 | && $port >= $MIN_PORT | |||||
| 854 | && $port <= $MAX_PORT; | |||||
| 855 | ||||||
| 856 | # Load mail modules lazily; wrap only I/O in eval to handle delivery failures | |||||
| 857 | 0 | 0 | eval { | |||
| 858 | 0 | 0 | require Email::Simple; | |||
| 859 | 0 | 0 | require Email::Sender::Simple; | |||
| 860 | 0 | 0 | require Email::Sender::Transport::SMTP; | |||
| 861 | ||||||
| 862 | 0 | 0 | Email::Simple->import(); | |||
| 863 | 0 | 0 | Email::Sender::Simple->import('sendmail'); | |||
| 864 | 0 | 0 | Email::Sender::Transport::SMTP->import(); | |||
| 865 | ||||||
| 866 | # Build the email object with sanitised headers | |||||
| 867 | 0 | 0 | my $email = Email::Simple->new(''); | |||
| 868 | $email->header_set( | |||||
| 869 | 'to', | |||||
| 870 | 0 | 0 | _sanitize_email_header($sm->{'to'}), | |||
| 871 | ); | |||||
| 872 | 0 | 0 | my $from = $sm->{'from'} || $DEFAULT_FROM_ADDR; | |||
| 873 | 0 | 0 | $email->header_set( | |||
| 874 | 'from', | |||||
| 875 | _sanitize_email_header($from), | |||||
| 876 | ); | |||||
| 877 | 0 | 0 | if(my $subject = $sm->{'subject'}) { | |||
| 878 | 0 | 0 | $email->header_set( | |||
| 879 | 'subject', | |||||
| 880 | _sanitize_email_header($subject), | |||||
| 881 | ); | |||||
| 882 | } | |||||
| 883 | 0 | 0 | $email->body_set(join(' ', @messages)); | |||
| 884 | ||||||
| 885 | 0 | 0 | my $transport = Email::Sender::Transport::SMTP->new({ | |||
| 886 | host => $host, | |||||
| 887 | port => $port, | |||||
| 888 | }); | |||||
| 889 | 0 | 0 | sendmail($email, { transport => $transport }); | |||
| 890 | }; | |||||
| 891 | ||||||
| 892 | 0 | 0 | if($@) { | |||
| 893 | 0 | 0 | Carp::carp("Failed to send email: $@"); | |||
| 894 | 0 | 0 | return; | |||
| 895 | } | |||||
| 896 | ||||||
| 897 | # Record send time for the throttle on success | |||||
| 898 | 0 | 0 | $self->{_last_email_sent} = time(); | |||
| 899 | } | |||||
| 900 | } | |||||
| 901 | } | |||||
| 902 | ||||||
| 903 | # -- syslog sub-backend ----------------------------------------- | |||||
| 904 | 0 | 0 | if(my $syslog = $logger->{'syslog'}) { | |||
| 905 | 0 | 0 | if((!defined($syslog->{'level'})) || | |||
| 906 | ($syslog_values{$level} <= $syslog->{'level'})) { | |||||
| 907 | ||||||
| 908 | # Open the persistent syslog connection on first use | |||||
| 909 | 0 | 0 | if(!$self->{_syslog_opened}) { | |||
| 910 | 0 | 0 | my $facility = delete $syslog->{'facility'} || $DEFAULT_SYSLOG_FACILITY; | |||
| 911 | 0 | 0 | my $min_level = delete $syslog->{'level'}; | |||
| 912 | ||||||
| 913 | # Accept 'server' as an alias for 'host' (CHI convention) | |||||
| 914 | 0 | 0 | if($syslog->{'server'}) { | |||
| 915 | 0 | 0 | $syslog->{'host'} = delete $syslog->{'server'}; | |||
| 916 | } | |||||
| 917 | 0 0 | 0 0 | Sys::Syslog::setlogsock($syslog) if(scalar keys %{$syslog}); | |||
| 918 | 0 | 0 | $syslog->{'facility'} = $facility; | |||
| 919 | 0 | 0 | $syslog->{'level'} = $min_level; | |||
| 920 | ||||||
| 921 | 0 | 0 | openlog($self->{script_name}, $DEFAULT_SYSLOG_OPTIONS, $DEFAULT_SYSLOG_IDENTITY); | |||
| 922 | 0 | 0 | $self->{_syslog_opened} = 1; | |||
| 923 | } | |||||
| 924 | ||||||
| 925 | # Map internal level names to syslog priority strings | |||||
| 926 | 0 | 0 | eval { | |||
| 927 | 0 | 0 | my $priority = $LEVEL_TO_SYSLOG_PRIORITY{$level} // 'warning'; | |||
| 928 | 0 | 0 | my $facility = $syslog->{'facility'}; | |||
| 929 | 0 | 0 | Sys::Syslog::syslog("$priority|$facility", join(' ', @messages)); | |||
| 930 | }; | |||||
| 931 | 0 | 0 | if($@) { | |||
| 932 | 0 | 0 | my $err = $@; | |||
| 933 | 0 | 0 | $err .= ":\n" . Data::Dumper->new([$syslog])->Dump(); | |||
| 934 | 0 | 0 | Carp::carp($err); | |||
| 935 | } | |||||
| 936 | } | |||||
| 937 | } | |||||
| 938 | ||||||
| 939 | # -- journald sub-backend -------------------------------------- | |||||
| 940 | 0 | 0 | if(my $jd = $logger->{'journald'}) { | |||
| 941 | # Map internal level name to journald/syslog PRIORITY integer (0=emerg, 7=debug) | |||||
| 942 | 0 | 0 | my $priority = $syslog_values{$level}; | |||
| 943 | 0 | 0 | my $sock_path = $jd->{'socket'} || $DEFAULT_JOURNALD_SOCKET; | |||
| 944 | ||||||
| 945 | # Determine the syslog identifier (script name or basename of $0) | |||||
| 946 | 0 | 0 | my $ident = $jd->{'identifier'} || $self->{'script_name'} || do { | |||
| 947 | require File::Basename; | |||||
| 948 | File::Basename::basename($0); | |||||
| 949 | }; | |||||
| 950 | ||||||
| 951 | # Mandatory journald fields | |||||
| 952 | 0 | 0 | my %fields = ( | |||
| 953 | MESSAGE => $str, | |||||
| 954 | PRIORITY => $priority, | |||||
| 955 | SYSLOG_IDENTIFIER => $ident, | |||||
| 956 | ); | |||||
| 957 | ||||||
| 958 | # Include any extra fields from the journald config hash | |||||
| 959 | 0 0 | 0 0 | for my $key (keys %{$jd}) { | |||
| 960 | 0 | 0 | next if lc($key) =~ /^(?:socket|identifier)$/; | |||
| 961 | 0 | 0 | $fields{uc($key)} = $jd->{$key}; | |||
| 962 | } | |||||
| 963 | ||||||
| 964 | # Delivery failures are silent; the app must not crash on log errors | |||||
| 965 | 0 0 | 0 0 | eval { $self->_journald_send($sock_path, %fields) }; | |||
| 966 | 0 | 0 | Carp::carp(ref($self), ": journald send failed: $@") if $@; | |||
| 967 | } | |||||
| 968 | ||||||
| 969 | # -- fd sub-backend --------------------------------------------- | |||||
| 970 | 0 | 0 | if(my $fout = $logger->{'fd'}) { | |||
| 971 | 0 | 0 | my $use_class = ($class ne '') ? 1 : 0; | |||
| 972 | 0 | 0 | my $line = $self->_format_message($level, $str, $use_class, $caller_file, $caller_line); | |||
| 973 | 0 | 0 | print $fout "$line\n"; | |||
| 974 | ||||||
| 975 | } elsif(!$logger->{'file'} && !$logger->{'array'} | |||||
| 976 | && !$logger->{'syslog'} && !exists($logger->{'sendmail'}) | |||||
| 977 | && !$logger->{'fd'} && !$logger->{'journald'}) { | |||||
| 978 | # Hash logger with no recognised sub-key -- configuration error | |||||
| 979 | 0 | 0 | croak(ref($self), ": Don't know how to deal with the $level message"); | |||
| 980 | } | |||||
| 981 | ||||||
| 982 | } elsif(!ref($logger)) { | |||||
| 983 | # Scalar-path backend: validate path then append to the file | |||||
| 984 | 0 | 0 | my $safe_path = $self->_validate_file_path($logger); | |||
| 985 | 0 | 0 | my $use_class = ($class ne '') ? 1 : 0; | |||
| 986 | 0 | 0 | my $line = $self->_format_message($level, $str, $use_class, $caller_file, $caller_line); | |||
| 987 | # Log failures are silent by design; the app must not crash on I/O errors | |||||
| 988 | 0 | 0 | eval { | |||
| 989 | 0 | 0 | open(my $fout, '>>', $safe_path); | |||
| 990 | 0 | 0 | print $fout "$line\n"; | |||
| 991 | 0 | 0 | close $fout; | |||
| 992 | }; | |||||
| 993 | ||||||
| 994 | } elsif(Scalar::Util::blessed($logger)) { | |||||
| 995 | # Object backend: delegate to the method matching the level name | |||||
| 996 | 0 | 0 | if(!$logger->can($level)) { | |||
| 997 | 0 | 0 | if(($level eq 'notice') && $logger->can('info')) { | |||
| 998 | # Log::Log4perl has no notice() method; map to info() | |||||
| 999 | 0 | 0 | $level = 'info'; | |||
| 1000 | } else { | |||||
| 1001 | 0 | 0 | croak( | |||
| 1002 | ref($self), ': ', ref($logger), | |||||
| 1003 | " doesn't know how to deal with the $level message", | |||||
| 1004 | ); | |||||
| 1005 | } | |||||
| 1006 | } | |||||
| 1007 | 0 | 0 | $logger->$level(@messages); | |||
| 1008 | ||||||
| 1009 | } else { | |||||
| 1010 | 0 | 0 | croak(ref($self), | |||
| 1011 | ": configuration error, no handler written for the $level message"); | |||||
| 1012 | } | |||||
| 1013 | ||||||
| 1014 | } elsif($self->{'array'}) { | |||||
| 1015 | # Top-level 'array' key (not nested inside logger hash) | |||||
| 1016 | 46 46 | 18 43 | push @{$self->{'array'}}, { level => $level, message => $str }; | |||
| 1017 | } | |||||
| 1018 | ||||||
| 1019 | # ----------------------------------------------------------------------- | |||||
| 1020 | # Top-level 'file' and 'fd' keys (parallel to 'logger') | |||||
| 1021 | # ----------------------------------------------------------------------- | |||||
| 1022 | 52 | 46 | if($self->{'file'}) { | |||
| 1023 | 2 | 4 | my $file = $self->_validate_file_path($self->{'file'}); | |||
| 1024 | 2 | 3 | my $use_class = ($class ne '') ? 1 : 0; | |||
| 1025 | 2 | 3 | my $line = $self->_format_message($level, $str, $use_class, $caller_file, $caller_line); | |||
| 1026 | # Log failures are silent by design; the app must not crash on I/O errors | |||||
| 1027 | 2 | 2 | eval { | |||
| 1028 | 2 | 4 | open(my $fout, '>>', $file); | |||
| 1029 | 2 | 856 | print $fout "$line\n"; | |||
| 1030 | 2 | 2 | close $fout; | |||
| 1031 | }; | |||||
| 1032 | } | |||||
| 1033 | ||||||
| 1034 | 52 | 431 | if(my $fout = $self->{'fd'}) { | |||
| 1035 | 0 | 0 | my $use_class = ($class ne '') ? 1 : 0; | |||
| 1036 | 0 | 0 | my $line = $self->_format_message($level, $str, $use_class, $caller_file, $caller_line); | |||
| 1037 | 0 | 0 | print $fout "$line\n"; | |||
| 1038 | } | |||||
| 1039 | 17 17 17 | 15418 12 163 | } | |||
| 1040 | ||||||
| 1041 | # --------------------------------------------------------------------------- | |||||
| 1042 | # _high_priority -- common handler for warn() and error() calls | |||||
| 1043 | # | |||||
| 1044 | # Purpose: Extracts the warning/error text from a variety of argument | |||||
| 1045 | # forms (plain list, named 'warning' key, or arrayref value), | |||||
| 1046 | # then dispatches to _log and optionally to Carp. | |||||
| 1047 | # Entry: $self -- the logger object. | |||||
| 1048 | # $level -- 'warn' or 'error'. | |||||
| 1049 | # @_ -- remaining arguments in any of the accepted forms. | |||||
| 1050 | # Exit: Returns nothing (void). | |||||
| 1051 | # Side effects: Calls _log, which appends to $self->{messages} and writes to | |||||
| 1052 | # configured backends. May call Carp::carp or Carp::croak. | |||||
| 1053 | # Notes: The duplicated extraction logic that appeared in earlier | |||||
| 1054 | # versions has been collapsed into a single if/else block. | |||||
| 1055 | # | |||||
| 1056 | # Pseudocode: | |||||
| 1057 | # FUNCTION _high_priority(self, level, args...) | |||||
| 1058 | # RETURN early if no args supplied | |||||
| 1059 | # RETURN early if level is below WARNING threshold (defensive guard) | |||||
| 1060 | # | |||||
| 1061 | # Attempt to parse args as named-parameter form via Params::Get (in eval) | |||||
| 1062 | # | |||||
| 1063 | # IF named 'warning' key found in result: | |||||
| 1064 | # Extract warning value; RETURN if value is undef | |||||
| 1065 | # IF value is an arrayref: join defined elements into a string | |||||
| 1066 | # ELSE (plain list form): | |||||
| 1067 | # Join defined elements of @_ into a string | |||||
| 1068 | # RETURN if resulting string is empty | |||||
| 1069 | # | |||||
| 1070 | # IF called as a class method (self is the package name, not an object): | |||||
| 1071 | # IF error level: CROAK with warning text; RETURN | |||||
| 1072 | # CARP with warning text; RETURN | |||||
| 1073 | # | |||||
| 1074 | # Call self->_log(level, warning) | |||||
| 1075 | # | |||||
| 1076 | # IF error level: | |||||
| 1077 | # IF croak_on_error flag set OR no logger/array backend configured: | |||||
| 1078 | # CROAK with warning text | |||||
| 1079 | # | |||||
| 1080 | # IF carp_on_warn flag set OR no logger/array backend configured: | |||||
| 1081 | # CARP with warning text | |||||
| 1082 | # END FUNCTION | |||||
| 1083 | # --------------------------------------------------------------------------- | |||||
| 1084 | sub _high_priority :Private { | |||||
| 1085 | 25 | 17 | my $self = shift; | |||
| 1086 | 25 | 15 | my $level = shift; # 'warn' or 'error' | |||
| 1087 | ||||||
| 1088 | # Nothing to log if no arguments supplied | |||||
| 1089 | 25 | 23 | return if(scalar(@_) == 0); | |||
| 1090 | ||||||
| 1091 | # Silently drop levels lower than WARNING (should not happen in practice) | |||||
| 1092 | 25 | 71 | return if($syslog_values{$level} > $WARNING); | |||
| 1093 | ||||||
| 1094 | # Try to interpret arguments as warn(warning => VALUE) named form | |||||
| 1095 | 25 | 56 | my $params; | |||
| 1096 | 25 25 | 17 18 | eval { $params = Params::Get::get_params('warning', @_) }; | |||
| 1097 | ||||||
| 1098 | # Determine the final warning string from whichever form was passed | |||||
| 1099 | 25 | 238 | my $warning; | |||
| 1100 | 25 | 46 | if($params && ref($params) eq 'HASH' && exists($params->{warning})) { | |||
| 1101 | # Named form: warn({ warning => ... }) or warn(warning => ...) | |||||
| 1102 | 25 | 17 | $warning = $params->{warning}; | |||
| 1103 | 25 | 23 | return unless defined($warning); | |||
| 1104 | 25 | 22 | if(ref($warning) eq 'ARRAY') { | |||
| 1105 | # Arrayref value: join defined elements | |||||
| 1106 | 1 2 1 | 1 3 1 | $warning = join('', grep { defined } @{$warning}); | |||
| 1107 | } | |||||
| 1108 | } else { | |||||
| 1109 | # Plain list form: warn('text', 'more text', ...) | |||||
| 1110 | 0 0 | 0 0 | $warning = join('', grep { defined } @_); | |||
| 1111 | 0 | 0 | return unless length($warning); | |||
| 1112 | } | |||||
| 1113 | ||||||
| 1114 | # If called as a class method rather than on an instance, use Carp directly | |||||
| 1115 | 25 | 25 | if($self eq __PACKAGE__) { | |||
| 1116 | 0 | 0 | if($syslog_values{$level} <= $ERROR) { | |||
| 1117 | 0 | 0 | Carp::croak($warning); | |||
| 1118 | } | |||||
| 1119 | 0 | 0 | Carp::carp($warning); | |||
| 1120 | 0 | 0 | return; | |||
| 1121 | } | |||||
| 1122 | ||||||
| 1123 | # Log the message through the normal dispatch path | |||||
| 1124 | 25 | 25 | $self->_log($level, $warning); | |||
| 1125 | ||||||
| 1126 | # Optionally escalate to Carp for error-level messages | |||||
| 1127 | 25 | 25 | if($syslog_values{$level} <= $ERROR) { | |||
| 1128 | 13 | 55 | if($self->{'croak_on_error'} | |||
| 1129 | || (!defined($self->{logger}) && !defined($self->{array}))) { | |||||
| 1130 | 3 | 17 | Carp::croak($warning); | |||
| 1131 | } | |||||
| 1132 | } | |||||
| 1133 | ||||||
| 1134 | # Optionally also emit a Carp::carp for warn-level messages | |||||
| 1135 | 22 | 64 | if($self->{'carp_on_warn'} | |||
| 1136 | || (!defined($self->{logger}) && !defined($self->{array}))) { | |||||
| 1137 | 2 | 2 | Carp::carp($warning); | |||
| 1138 | } | |||||
| 1139 | 17 17 17 | 4160 15 132 | } | |||
| 1140 | ||||||
| 1141 - 1213 | =head2 level
my $current = $logger->level();
$logger->level('debug');
Get or set the minimum logging level. When setting, returns C<$self> to
allow method chaining. When getting, returns the current level as an
integer (per the syslog numeric scale; lower numbers are higher priority).
=head3 Arguments
=over 4
=item * C<$level> (optional)
A level name string: C<trace>, C<debug>, C<info>, C<notice>, C<warn>/C<warning>,
or C<error>. Case-insensitive. Omit to perform a pure get.
=back
=head3 Returns
In getter mode: an integer in the range 0 (emergency) to 7 (debug/trace).
In setter mode: C<$self> (to allow chaining).
=head3 Side Effects
When setting, updates C<$self-E<gt>{level}>.
=head3 Example
$logger->level('debug');
my $n = $logger->level(); # e.g. 7
# Method chaining
$logger->level('info')->info('Now at info level');
=head3 API Specification
=head4 Input
{
level => { type => 'string', regex => qr/^(trace|debug|info|notice|warn(?:ing)?|error)$/i, optional => 1 },
}
=head4 Output
Getter: { type => 'integer', min => 0, max => 7 }
Setter: { type => 'object', class => 'Log::Abstraction' }
=head3 MESSAGES
Warning Meaning / Action
---------------------------------------- ------------------------------------------
"<class>: invalid syslog level '<l>'" The supplied level name is not recognised.
Use trace/debug/info/notice/warn/error.
=head3 PSEUDOCODE
FUNCTION level(self, level?)
IF level argument supplied:
CARP and RETURN undef if level is not a recognised syslog name
Store syslog_values{level} in self->{'level'}
RETURN self (allows method chaining)
ELSE (getter mode):
RETURN self->{'level'} (current numeric threshold)
END FUNCTION
=cut | |||||
| 1214 | ||||||
| 1215 | sub level { | |||||
| 1216 | 252 | 6200 | my ($self, $level) = @_; | |||
| 1217 | ||||||
| 1218 | 252 | 185 | if($level) { | |||
| 1219 | # Setter path: validate, store and return $self for chaining | |||||
| 1220 | 233 | 186 | if(!defined($syslog_values{$level})) { | |||
| 1221 | 2 | 4 | Carp::carp(ref($self), ": invalid syslog level '$level'"); | |||
| 1222 | 2 | 2 | return; # undef signals the caller that validation failed | |||
| 1223 | } | |||||
| 1224 | 231 | 139 | $self->{'level'} = $syslog_values{$level}; | |||
| 1225 | 231 | 158 | return $self; | |||
| 1226 | } | |||||
| 1227 | ||||||
| 1228 | # Getter path: return the numeric threshold | |||||
| 1229 | return Return::Set::set_return( | |||||
| 1230 | 19 | 43 | $self->{'level'}, | |||
| 1231 | { 'type' => 'integer', 'min' => 0, 'max' => 7 }, | |||||
| 1232 | ); | |||||
| 1233 | } | |||||
| 1234 | ||||||
| 1235 - 1268 | =head2 is_debug
if($logger->is_debug()) { ... }
Returns a true value when the logger is configured at C<debug> level or
below (i.e. debug messages will actually be emitted). Provided for
compatibility with L<Log::Any>.
=head3 Arguments
None.
=head3 Returns
C<1> if the current level threshold includes debug (or trace) messages;
C<0> otherwise.
=head3 Example
if($logger->is_debug()) {
$logger->debug('Expensive diagnostic: ' . Dumper(\%state));
}
=head3 API Specification
=head4 Input
{} (no arguments)
=head4 Output
{ type => 'boolean' }
=cut | |||||
| 1269 | ||||||
| 1270 | sub is_debug { | |||||
| 1271 | 25 | 55 | my $self = $_[0]; | |||
| 1272 | ||||||
| 1273 | # $DEBUG is exported by Readonly::Values::Syslog | |||||
| 1274 | 25 | 81 | return ($self->{'level'} && ($self->{'level'} >= $DEBUG)) ? 1 : 0; | |||
| 1275 | } | |||||
| 1276 | ||||||
| 1277 - 1314 | =head2 messages
my $aref = $logger->messages();
Returns a reference to a shallow copy of all messages emitted through this
logger since it was created (or since the last clone).
=head3 Arguments
None.
=head3 Returns
An array reference of hashrefs, each with keys C<level> (string) and
C<message> (string).
=head3 Side Effects
None. The returned array is a copy; modifying it does not affect the
internal history.
=head3 Example
$logger->info('hello');
my $msgs = $logger->messages();
# $msgs->[0] = { level => 'info', message => 'hello' }
=head3 API Specification
=head4 Input
{} (no arguments)
=head4 Output
{ type => 'arrayref', element_type => { level => 'string', message => 'string' } }
=cut | |||||
| 1315 | ||||||
| 1316 | sub messages { | |||||
| 1317 | 71 | 2291 | my $self = $_[0]; | |||
| 1318 | ||||||
| 1319 | 71 71 | 53 137 | return [ @{$self->{messages}} ]; | |||
| 1320 | } | |||||
| 1321 | ||||||
| 1322 - 1367 | =head2 trace
$logger->trace(@messages);
$logger->trace(\@messages);
Logs a message at C<trace> level (the most verbose level, below C<debug>).
The message is dropped silently when the configured level threshold is above
C<trace>.
=head3 Arguments
=over 4
=item * C<@messages>
One or more strings, or a single array reference. All elements are joined
without a separator before storage.
=back
=head3 Returns
C<$self>, to allow method chaining.
=head3 Side Effects
Appends to the internal message history and dispatches to configured backends.
=head3 Example
$logger->trace('entering sub foo, args=', join(',', @args));
# Chaining
$logger->trace('start')->debug('details')->info('summary');
=head3 API Specification
=head4 Input
{ messages => { type => [ 'arrayref', 'scalar' ] } }
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=cut | |||||
| 1368 | ||||||
| 1369 | sub trace { | |||||
| 1370 | 14 | 29 | my $self = shift; | |||
| 1371 | 14 | 38 | $self->_log('trace', @_); | |||
| 1372 | 14 | 11 | return $self; | |||
| 1373 | } | |||||
| 1374 | ||||||
| 1375 - 1414 | =head2 debug
$logger->debug(@messages);
$logger->debug(\@messages);
Logs a message at C<debug> level.
=head3 Arguments
=over 4
=item * C<@messages>
One or more strings, or a single array reference.
=back
=head3 Returns
C<$self>, to allow method chaining.
=head3 Side Effects
Appends to the internal message history and dispatches to configured backends.
=head3 Example
$logger->debug('Query took ', $elapsed, 'ms');
=head3 API Specification
=head4 Input
{ messages => { type => [ 'arrayref', 'scalar' ] } }
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=cut | |||||
| 1415 | ||||||
| 1416 | sub debug { | |||||
| 1417 | 10363 | 9853 | my $self = shift; | |||
| 1418 | 10363 | 9231 | $self->_log('debug', @_); | |||
| 1419 | 10346 | 5986 | return $self; | |||
| 1420 | } | |||||
| 1421 | ||||||
| 1422 - 1461 | =head2 info
$logger->info(@messages);
$logger->info(\@messages);
Logs a message at C<info> level.
=head3 Arguments
=over 4
=item * C<@messages>
One or more strings, or a single array reference.
=back
=head3 Returns
C<$self>, to allow method chaining.
=head3 Side Effects
Appends to the internal message history and dispatches to configured backends.
=head3 Example
$logger->info('Server started on port ', $port);
=head3 API Specification
=head4 Input
{ messages => { type => [ 'arrayref', 'scalar' ] } }
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=cut | |||||
| 1462 | ||||||
| 1463 | sub info { | |||||
| 1464 | 70 | 486 | my $self = shift; | |||
| 1465 | 70 | 107 | $self->_log('info', @_); | |||
| 1466 | 70 | 65 | return $self; | |||
| 1467 | } | |||||
| 1468 | ||||||
| 1469 - 1509 | =head2 notice
$logger->notice(@messages);
$logger->notice(\@messages);
Logs a message at C<notice> level (higher priority than C<info>, lower than
C<warn>).
=head3 Arguments
=over 4
=item * C<@messages>
One or more strings, or a single array reference.
=back
=head3 Returns
C<$self>, to allow method chaining.
=head3 Side Effects
Appends to the internal message history and dispatches to configured backends.
=head3 Example
$logger->notice('Configuration reloaded');
=head3 API Specification
=head4 Input
{ messages => { type => [ 'arrayref', 'scalar' ] } }
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=cut | |||||
| 1510 | ||||||
| 1511 | sub notice { | |||||
| 1512 | 27 | 335 | my $self = shift; | |||
| 1513 | 27 | 42 | $self->_log('notice', @_); | |||
| 1514 | 26 | 49 | return $self; | |||
| 1515 | } | |||||
| 1516 | ||||||
| 1517 - 1574 | =head2 warn
$logger->warn(@messages);
$logger->warn(\@messages);
$logger->warn(warning => $text);
$logger->warn({ warning => $text });
$logger->warn(warning => \@parts);
Logs a warning message. Also dispatches to syslog and/or email backends
when those are configured. Falls back to C<Carp::carp> when no logger
backend is set.
A C<warn()> call with an empty or all-undef argument list is a silent no-op.
=head3 Arguments
=over 4
=item * C<@messages>
A plain list of strings joined without separator, B<or> a named C<warning>
parameter whose value may be a string or an array reference of strings.
=back
=head3 Returns
C<$self>, to allow method chaining.
=head3 Side Effects
Appends to internal message history. Writes to all configured backends.
May call C<Carp::carp> if C<carp_on_warn> is set or no backend is active.
=head3 Example
$logger->warn('Disk usage is high');
$logger->warn(warning => 'Connection reset', ' retrying');
$logger->warn({ warning => ['Part A', 'Part B'] });
=head3 API Specification
=head4 Input
# Named form
{ warning => { type => [ 'scalar', 'arrayref' ] } }
# Plain-list form
{ messages => { type => 'arrayref' } }
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=head3 MESSAGES
(no croak/carp messages from this method itself; see _high_priority)
=cut | |||||
| 1575 | ||||||
| 1576 | sub warn { | |||||
| 1577 | 91 | 986 | my $self = shift; | |||
| 1578 | ||||||
| 1579 | # Empty argument list is a documented no-op | |||||
| 1580 | 91 | 125 | if(scalar(@_) > 0) { | |||
| 1581 | 88 | 111 | $self->_high_priority('warn', @_); | |||
| 1582 | } | |||||
| 1583 | 91 | 96 | return $self; | |||
| 1584 | } | |||||
| 1585 | ||||||
| 1586 - 1629 | =head2 error
$logger->error(@messages);
$logger->error(warning => $text);
Logs an error-level message. Behaves identically to C<warn()> but at the
C<error> level, which triggers C<Carp::croak> if C<croak_on_error> is set
or no logger backend is active.
=head3 Arguments
Same argument forms as C<warn()>.
=head3 Returns
C<$self>, to allow method chaining. Note: if C<croak_on_error> is set, the
method never returns -- execution unwinds via C<Carp::croak>.
=head3 Side Effects
Same as C<warn()> plus optional C<Carp::croak> escalation.
=head3 Example
$logger->error('Fatal: database unavailable');
=head3 API Specification
=head4 Input
{ warning => { type => [ 'scalar', 'arrayref' ], optional => 1 } }
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=head3 MESSAGES
Croak Meaning / Action
---------------------------------------- ------------------------------------------
(the error message text itself) croak_on_error is set, or no backend is
active. The call stack is unwound.
=cut | |||||
| 1630 | ||||||
| 1631 | sub error { | |||||
| 1632 | 34 | 1718 | my $self = shift; | |||
| 1633 | 34 | 46 | $self->_high_priority('error', @_); | |||
| 1634 | 27 | 23 | return $self; | |||
| 1635 | } | |||||
| 1636 | ||||||
| 1637 - 1674 | =head2 fatal
$logger->fatal(@messages);
Synonym for C<error()>. Provided for compatibility with logging frameworks
that use C<fatal> as the highest-severity level name.
=head3 Arguments
Same as C<error()>.
=head3 Returns
C<$self>.
=head3 Side Effects
Same as C<error()>.
=head3 Example
$logger->fatal('Unrecoverable state; aborting');
=head3 API Specification
=head4 Input
{ warning => { type => [ 'scalar', 'arrayref' ], optional => 1 } }
=head4 Output
{ type => 'object', class => 'Log::Abstraction' }
=head3 MESSAGES
Same as C<error()>.
=cut | |||||
| 1675 | ||||||
| 1676 | sub fatal { | |||||
| 1677 | 10 | 75 | my $self = shift; | |||
| 1678 | 10 | 18 | $self->_high_priority('error', @_); | |||
| 1679 | 7 | 7 | return $self; | |||
| 1680 | } | |||||
| 1681 | ||||||
| 1682 | # --------------------------------------------------------------------------- | |||||
| 1683 | # DESTROY -- close the persistent syslog connection when the object is freed | |||||
| 1684 | # | |||||
| 1685 | # Purpose: Ensure the syslog socket is closed cleanly on object | |||||
| 1686 | # destruction, avoiding resource leaks under persistent | |||||
| 1687 | # interpreters such as mod_perl. | |||||
| 1688 | # Entry: $self -- the logger object being destroyed. | |||||
| 1689 | # Exit: void | |||||
| 1690 | # Side effects: Calls Sys::Syslog::closelog() and removes _syslog_opened flag. | |||||
| 1691 | # Notes: Uses fully-qualified Sys::Syslog::closelog() so that | |||||
| 1692 | # Test::Mockingbird can intercept the call in tests. | |||||
| 1693 | # --------------------------------------------------------------------------- | |||||
| 1694 | sub DESTROY { | |||||
| 1695 | 1382 | 135535 | my $self = $_[0]; | |||
| 1696 | ||||||
| 1697 | 1382 | 4516 | if($self->{_syslog_opened}) { | |||
| 1698 | 12 | 21 | Sys::Syslog::closelog(); | |||
| 1699 | 12 | 80 | delete $self->{_syslog_opened}; | |||
| 1700 | } | |||||
| 1701 | } | |||||
| 1702 | ||||||
| 1703 | =encoding utf-8 | |||||
| 1704 | ||||||
| 1705 - 2041 | =head1 EXAMPLES
=head2 CSV file logging for BI import
The code-reference backend gives you full control over the output format.
The example below writes every message at C<trace> level and above as a
CSV row to a file, producing output that can be loaded directly into a
spreadsheet or BI tool (Tableau, Power BI, Metabase, etc.).
Each row contains: C<timestamp>, C<level>, C<class>, C<file>, C<line>, C<message>.
use Log::Abstraction;
my $csv_file = 'app_events.csv';
# Write the header row once (skip if the file already exists and has data).
unless (-s $csv_file) {
open my $fh, '>', $csv_file or die "Cannot open $csv_file: $!";
print $fh qq{timestamp,level,class,file,line,message\n};
close $fh;
}
# Helper: quote a single CSV field (escapes embedded double-quotes).
my $csv_field = sub {
my $v = defined $_[0] ? $_[0] : '';
$v =~ s/"/""/g;
return qq{"$v"};
};
my $logger = Log::Abstraction->new(
level => 'trace', # capture everything from trace upwards
logger => sub {
my $args = $_[0];
my $timestamp = POSIX::strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
my $message = join(' ', @{ $args->{message} // [] });
open my $fh, '>>', $csv_file or return;
print $fh join(',',
$csv_field->($timestamp),
$csv_field->($args->{level}),
$csv_field->($args->{class}),
$csv_field->($args->{file}),
$csv_field->($args->{line}),
$csv_field->($message),
), "\n";
close $fh;
},
);
$logger->trace('application started');
$logger->info('user logged in', { user => 'alice' });
$logger->warn({ warning => 'disk usage above 80%' });
The resulting C<app_events.csv> looks like:
timestamp,level,class,file,line,message
"2026-05-27T14:00:00Z","trace","Log::Abstraction","app.pl","42","application started"
"2026-05-27T14:00:01Z","info","Log::Abstraction","app.pl","43","user logged in"
"2026-05-27T14:00:02Z","warn","Log::Abstraction","Log/Abstraction.pm","820","disk usage above 80%"
Note: C<class> is always C<Log::Abstraction> (or the subclass name if you subclass the
module). For C<trace>, C<debug>, C<info>, and C<notice> calls, C<file> and C<line>
resolve to the caller's source location. For C<warn> and C<error> calls the
extra C<_high_priority> stack frame shifts the resolution one level inward, so
C<file> and C<line> point into the module rather than the calling script.
For production use, consider replacing the manual C<$csv_field> quoting with
L<Text::CSV> for correct handling of embedded newlines and other edge cases.
If you also want real-time alerting on critical events, add the email logic
directly inside the code-ref callback -- test C<$args-E<gt>{level}> and call
your mailer for C<warn> / C<error> messages while still writing the CSV row
for every message.
Alternatively, use the C<sendmail> hash-ref backend on its own (without the
code-ref) and add a C<level> key to restrict emails to warn-and-above:
my $logger = Log::Abstraction->new(
level => 'warn',
logger => {
sendmail => {
host => 'smtp.example.com',
to => 'ops@example.com',
from => 'logger@example.com',
subject => 'Application alert',
level => 'warn', # only email at warn level and above
min_interval => 300, # at most one alert email per 5 minutes
},
},
);
Note: the C<sendmail> backend writes the module's standard text format, not
CSV. To produce CSV rows I<and> send email alerts from the same logger,
embed both the CSV-write and the mail-send logic inside a single code-ref
callback as described above.
=head1 LIMITATIONS
=over 4
=item B<Syslog hash mutation>
The C<syslog> sub-hash passed to C<new()> is mutated in-place on the first
log call: C<facility> and C<level> are temporarily removed before
C<setlogsock()> is called, then restored; C<server> is permanently renamed
to C<host>. Sharing a syslog hashref between two C<Log::Abstraction>
instances is not supported and produces undefined behaviour on the second
instance.
=item B<No structured log fields>
All backends except the CODE-ref backend reduce the message to a flat string.
To log structured key/value pairs, use a CODE-ref backend that formats the
data itself.
=item B<Single-threaded email throttle>
The C<min_interval> throttle for the C<sendmail> backend and the
C<_syslog_opened> first-open flag are stored on the object without mutex
protection. Under Perl ithreads or other concurrency models, objects shared
between threads are not safe.
=item B<OpenTelemetry not yet supported>
The OTel Logs SDK for Perl is incomplete; see the TODO block at the top of
F<lib/Log/Abstraction.pm> for a full status report and the list of blockers.
Monitor L<https://metacpan.org/pod/OpenTelemetry::SDK> for progress.
=item B<Log::Log4perl is a de-facto required dependency>
When no C<logger>, C<file>, or C<array> backend is configured, C<new()>
loads L<Log::Log4perl> and uses it as the default backend. Although listed
as an optional runtime dependency, it is required in that default-backend
path.
=back
=head1 AUTHOR
Nigel Horne C<njh@nigelhorne.com>
=head1 SEE ALSO
=over 4
=item * L<Log::Any> and L<Log::Any::Adapter::Abstraction>
Route messages from any C<Log::Any>-using CPAN module through
C<Log::Abstraction> with a single C<Log::Any::Adapter-E<gt>set()> call.
=item * L<Test Dashboard|https://nigelhorne.github.io/Log-Abstraction/coverage/>
=back
=head1 SUPPORT
This module is provided as-is without any warranty.
Please report any bugs or feature requests to C<bug-log-abstraction at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Log-Abstraction>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.
You can find documentation for this module with the perldoc command.
perldoc Log::Abstraction
You can also look for information at:
=over 4
=item * MetaCPAN
L<https://metacpan.org/dist/Log-Abstraction>
=item * RT: CPAN's request tracker
L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Log-Abstraction>
=item * CPAN Testers' Matrix
L<http://matrix.cpantesters.org/?dist=Log-Abstraction>
=item * CPAN Testers Dependencies
L<http://deps.cpantesters.org/?module=Log::Abstraction>
=back
=head1 FORMAL SPECIFICATION
=head2 new
ââ LogState ââââââââââââââââââââââââââââââââââââââââââââââââââ
â level : â¤
â messages : seq { level : STRING; message : STRING }
â logger : LOGGER
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
ââ New âââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â args? : Args
â result! : LogState
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result!.level = syslog_values(args?.level ⨠'warning')
â result!.messages = â¨â©
â result!.logger = args?.logger
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Clone operation (called on an existing object):
ââ Clone âââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â overrides? : Args
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result!.level = syslog_values(overrides?.level ⨠level)
â result!.messages = messages {deep copy}
â result!.logger = overrides?.logger ⨠logger
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 level
ââ LevelGet âââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â result! : â¤
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = level
â 0 ⤠result! â§ result! ⤠7
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
ââ LevelSet âââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â new_level? : STRING
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â new_level? â dom(syslog_values)
â level' = syslog_values(new_level?)
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 is_debug
ââ IsDebug ââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â result! : BOOLEAN
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = (level ⥠syslog_values('debug'))
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 messages
ââ Messages âââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â result! : seq { level : STRING; message : STRING }
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = messages
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 trace
ââ Trace ââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â msg? : seq STRING
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â msg? â â¨â©
â syslog_values('trace') ⤠level
â messages' = messages ⢠â¨{level ⦠'trace', message ⦠â(msg?)}â©
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 debug
ââ Debug ââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â msg? : seq STRING
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â msg? â â¨â©
â syslog_values('debug') ⤠level
â messages' = messages ⢠â¨{level ⦠'debug', message ⦠â(msg?)}â©
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 info
ââ Info âââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â msg? : seq STRING
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â msg? â â¨â©
â syslog_values('info') ⤠level
â messages' = messages ⢠â¨{level ⦠'info', message ⦠â(msg?)}â©
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 notice
ââ Notice âââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â msg? : seq STRING
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â msg? â â¨â©
â syslog_values('notice') ⤠level
â messages' = messages ⢠â¨{level ⦠'notice', message ⦠â(msg?)}â©
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 warn
ââ Warn âââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â msg? : seq STRING | { warning : STRING | seq STRING }
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â msg? â â
â§ join(msg?) â ''
â syslog_values('warn') ⤠level
â messages' = messages ⢠â¨{level ⦠'warn', message ⦠join(msg?)}â©
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 error
ââ Error ââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLogState
â msg? : seq STRING | { warning : STRING | seq STRING }
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â msg? â â
â§ join(msg?) â ''
â syslog_values('error') ⤠level
â messages' = messages ⢠â¨{level ⦠'error', message ⦠join(msg?)}â©
â croak_on_error = 1 â¹ execution_continues = false
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 fatal
fatal â¡ error (identical operation schema)
=head1 COPYRIGHT AND LICENSE
Copyright (C) 2025-2026 Nigel Horne
Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.
=cut | |||||
| 2042 | ||||||
| 2043 | 1; | |||||