File Coverage

File:blib/lib/Log/Abstraction.pm
Coverage:57.2%

linestmtbrancondsubtimecode
1package 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()
87Readonly::Scalar my $DEFAULT_LEVEL => 'warning';
88
89# Default SMTP delivery parameters for the sendmail backend
90Readonly::Scalar my $DEFAULT_SMTP_HOST => 'localhost';
91Readonly::Scalar my $DEFAULT_SMTP_PORT => 25;
92Readonly::Scalar my $DEFAULT_FROM_ADDR => 'noreply@localhost';
93Readonly::Scalar my $MIN_PORT          => 1;
94Readonly::Scalar my $MAX_PORT          => 65535;
95
96# Default syslog connection parameters
97Readonly::Scalar my $DEFAULT_SYSLOG_FACILITY => 'local0';
98Readonly::Scalar my $DEFAULT_SYSLOG_OPTIONS  => 'cons,pid';
99Readonly::Scalar my $DEFAULT_SYSLOG_IDENTITY => 'user';
100
101# Default log-line format tokens for file/fd/scalar-path backends
102Readonly::Scalar my $DEFAULT_FORMAT         => '%level%> [%timestamp%] %class% %callstack% %message%';
103Readonly::Scalar my $DEFAULT_FORMAT_NOCLASS => '%level%> [%timestamp%] %callstack% %message%';
104
105# Map internal level names to POSIX syslog priority strings
106Readonly::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)
117Readonly::Scalar my $RE_SAFE_PATH => qr/^([^<>|*?;!`\$"\x00-\x1F]+)$/;
118
119# Regex: path component that would escape the intended directory
120Readonly::Scalar my $RE_DOTDOT => qr/\.\./;
121
122# Regex: characters forbidden in an SMTP hostname (allows a-z, A-Z, 0-9, dot, hyphen)
123Readonly::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)
126Readonly::Scalar my $RE_PORT => qr/^\d+$/;
127
128# Default path to the journald native-protocol socket on systemd systems
129Readonly::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
141our $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
380sub 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# ---------------------------------------------------------------------------
494sub _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# ---------------------------------------------------------------------------
521sub _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# ---------------------------------------------------------------------------
550sub _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# ---------------------------------------------------------------------------
622sub _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# ---------------------------------------------------------------------------
744sub _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# ---------------------------------------------------------------------------
1084sub _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
1215sub 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
1270sub 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
1316sub 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
1369sub 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
1416sub 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
1463sub 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
1511sub 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
1576sub 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
1631sub 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
1676sub 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# ---------------------------------------------------------------------------
1694sub 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
20431;