File Coverage

blib/lib/Log/Any/Adapter/DERIV.pm
Criterion Covered Total %
statement 151 165 91.5
branch 35 44 79.5
condition 22 32 68.7
subroutine 34 36 94.4
pod 4 5 80.0
total 246 282 87.2


line stmt bran cond sub pod time code
1             package Log::Any::Adapter::DERIV;
2             # ABSTRACT: one company's example of a standardised logging setup
3              
4 4     4   240872 use strict;
  4         39  
  4         116  
5 4     4   21 use warnings;
  4         7  
  4         204  
6              
7             our $AUTHORITY = 'cpan:DERIV'; # AUTHORITY
8             our $VERSION = '0.005';
9              
10 4     4   21 use feature qw(state);
  4         11  
  4         460  
11 4     4   1890 use parent qw(Log::Any::Adapter::Coderef);
  4         1221  
  4         23  
12              
13 4     4   14761 use utf8;
  4         61  
  4         20  
14              
15             =encoding utf8
16              
17             =head1 NAME
18              
19             Log::Any::Adapter::DERIV - standardised logging to STDERR and JSON file
20              
21             =begin markdown
22              
23             [![Test status](https://circleci.com/gh/binary-com/perl-Log-Any-Adapter-DERIV.svg?style=shield&circle-token=bed2af8f8e388746eafbbf905cf6990f84dbd69e)](https://app.circleci.com/pipelines/github/binary-com/perl-Log-Any-Adapter-DERIV)
24              
25             =end markdown
26              
27             =head1 SYNOPSIS
28              
29             use Log::Any;
30              
31             # print text log to STDERR, json format when inside docker container,
32             # colored text format when STDERR is a tty, non-colored text format when
33             # STDERR is redirected.
34             use Log::Any::Adapter ('DERIV');
35              
36             #specify STDERR directly
37             use Log::Any::Adapter ('DERIV', stderr => 1)
38              
39             #specify STDERR's format
40             use Log::Any::Adapter ('DERIV', stderr => 'json')
41              
42             #specify the json log name
43             use Log::Any::Adapter ('DERIV', json_log_file => '/var/log/program.json.log');
44              
45             =head1 DESCRIPTION
46              
47             Applies some opinionated log handling rules for L.
48              
49             B. It does the following, affecting global state
50             in various ways:
51              
52             =over 4
53              
54             =item * applies UTF-8 encoding to STDERR
55              
56             =item * writes to a C<.json.log> file.
57              
58             =item * overrides the default L formatter to provide data as JSON
59              
60             =item * when stringifying, may replace some problematic objects with simplified versions
61              
62             =back
63              
64             An example of the string-replacement approach would be the event loop in asynchronous code:
65             it's likely to have many components attached to it, and dumping that would effectively end up
66             dumping the entire tree of useful objects in the process. This is a planned future extension,
67             not currently implemented.
68              
69             =head2 Why
70              
71             This is provided as a CPAN module as an example for dealing with multiple outputs and formatting.
72             The existing L modules tend to cover one thing, and it's
73             not immediately obvious how to extend formatting, or send data to multiple logging mechanisms at once.
74              
75             Although the module may not be directly useful, it is hoped that other teams may find
76             parts of the code useful for their own logging requirements.
77              
78             There is a public repository on Github, anyone is welcome to fork that and implement
79             their own version or make feature/bug fix suggestions if they seem generally useful:
80              
81             L
82              
83             =head2 PARAMETERS
84              
85             =over 4
86              
87             =item * json_log_file
88              
89             Specify a file name to which you want the json formatted logs printed into.
90             If not given, then it prints the logs to STDERR.
91              
92             =item * STDERR
93              
94             If it is true, then print logs to STDERR
95              
96             If the value is json or text, then print logs with that format
97              
98             If the value is just a true value other than `json` or `text`,
99             then if it is running in a container, then it prints the logs in `json` format.
100             Else if STDERR is a tty, then it prints `colored text` format.
101             Else it prints non-color text format.
102              
103             =back
104              
105             If no parameters provided, then default `stderr => 1`;
106              
107             =cut
108              
109             =head1 METHODS
110              
111             =cut
112              
113 4     4   1844 use Time::Moment;
  4         5806  
  4         141  
114 4     4   2797 use Path::Tiny;
  4         39105  
  4         251  
115 4     4   2648 use curry;
  4         1377  
  4         168  
116 4     4   1383 use JSON::MaybeUTF8 qw(:v1);
  4         17842  
  4         564  
117 4     4   30 use PerlIO;
  4         7  
  4         41  
118 4     4   103 use Config;
  4         41  
  4         139  
119 4     4   2035 use Term::ANSIColor;
  4         28731  
  4         284  
120 4     4   35 use Log::Any qw($log);
  4         43  
  4         33  
121 4     4   1007 use Fcntl qw(:DEFAULT :seek :flock);
  4         15  
  4         1514  
122 4     4   32 use Log::Any::Adapter::Util qw(numeric_level logging_methods);
  4         7  
  4         237  
123 4     4   1796 use Clone qw(clone);
  4         9752  
  4         743  
124              
125             # Used for stringifying data more neatly than Data::Dumper might offer
126             our $JSON = JSON::MaybeXS->new(
127             # Multi-line for terminal output, single line if redirecting somewhere
128             pretty => _fh_is_tty(\*STDERR),
129             # Be consistent
130             canonical => 1,
131             # Try a bit harder to give useful output
132             convert_blessed => 1,
133             );
134              
135             # Simple mapping from severity levels to Term::ANSIColor definitions.
136             our %SEVERITY_COLOUR = (
137             trace => [qw(grey12)],
138             debug => [qw(grey18)],
139             info => [qw(green)],
140             warning => [qw(bright_yellow)],
141             error => [qw(red bold)],
142             fatal => [qw(red bold)],
143             critical => [qw(red bold)],
144             );
145              
146             my @methods = reverse logging_methods();
147             my %num_to_name = map { $_ => $methods[$_] } 0 .. $#methods;
148              
149             # The obvious way to handle this might be to provide our own proxy class:
150             # $Log::Any::OverrideDefaultProxyClass = 'Log::Any::Proxy::DERIV';
151             # but the handling for proxy classes is somewhat opaque - and there's an ordering problem
152             # where `use Log::Any` before the adapter is loaded means we end up with some classes having
153             # the default anyway.
154             # Rather than trying to deal with that, we just provide our own default:
155             {
156 4     4   32 no warnings 'redefine'; ## no critic (ProhibitNoWarnings)
  4         8  
  4         783  
157              
158             # We expect this to be loaded, but be explicit just in case - we'll be overriding
159             # one of the methods, so let's at least make sure it exists first
160             require Log::Any::Proxy;
161              
162             # Mostly copied from Log::Any::Proxy
163             *Log::Any::Proxy::_default_formatter = sub {
164 0     0   0 my ($cat, $lvl, $format, @params) = @_;
165 0 0       0 return $format->() if ref($format) eq 'CODE';
166              
167             chomp(
168             my @new_params = map {
169 0   0     0 eval { $JSON->encode($_) }
  0         0  
  0         0  
170             // Log::Any::Proxy::_stringify_params($_)
171             } @params
172             );
173 0         0 s{\n}{\n }g for @new_params;
174              
175             # Perl 5.22 adds a 'redundant' warning if the number parameters exceeds
176             # the number of sprintf placeholders. If a user does this, the warning
177             # is issued from here, which isn't very helpful. Doing something
178             # clever would be expensive, so instead we just disable warnings for
179             # the final line of this subroutine.
180 4     4   43 no warnings; ## no critic (ProhibitNoWarnings)
  4         10  
  4         8303  
181 0         0 return sprintf($format, @new_params);
182             };
183             }
184              
185             # Upgrade any `warn ...` lines to send through Log::Any.
186             $SIG{__WARN__} = sub { ## no critic (RequireLocalizedPunctuationVars)
187             # We don't expect anything called from here to raise further warnings, but
188             # let's be safe and try to avoid any risk of recursion
189             local $SIG{__WARN__} = undef;
190             chomp(my $msg = shift);
191             $log->warn($msg);
192             };
193              
194             sub new {
195 180     180 0 357942 my ($class, %args) = @_;
196 180     0   1055 my $self = $class->SUPER::new(sub { }, %args);
197              
198             # if there is json_log_file, then print json to that file
199 180 100       6425 if ($self->{json_log_file}) {
200 106 50       294 $self->{json_fh} = path($self->{json_log_file})->opena_utf8 or die 'unable to open log file - ' . $!;
201 106         60255 $self->{json_fh}->autoflush(1);
202             }
203              
204             # if there is stderr, then print log to stderr also
205             # if stderr is json or text, then use that format
206             # else, if it is in_container, then json, else text
207 180 100 100     4819 if (!$self->{json_log_file} && !$self->{stderr}) {
208 50         105 $self->{stderr} = 1;
209             }
210              
211 180         608 for my $stdfile (['stderr', \*STDERR], ['stdout', \*STDOUT]) {
212 360         1141 my ($name, $fh) = $stdfile->@*;
213 360 100       926 if ($self->{$name}) {
214 102 50       359 $self->{$name} = {format => $self->{$name}} if ref($self->{$name}) ne 'HASH';
215             # docker tends to prefer JSON
216             $self->{$name}{format} = _in_container() ? 'json' : 'text'
217 102 100 100     612 if (!$self->{$name}{format} || $self->{$name}{format} ne 'json' && $self->{$name}{format} ne 'text');
    100 66        
218 102         445 $self->apply_filehandle_utf8($fh);
219 102         5618 $self->{$name}{fh} = $fh;
220 102   66     400 $self->{$name}{color} //= _fh_is_tty($fh);
221             }
222             }
223              
224             # Keep a strong reference to this, since we expect to stick around until exit anyway
225 180         1340 $self->{code} = $self->curry::log_entry;
226 180         3283 return $self;
227             }
228              
229             =head2 apply_filehandle_utf8
230              
231             Applies UTF-8 to filehandle if it is not utf-flavoured already
232              
233             $object->apply_filehandle_utf8($fh);
234              
235             =over 4
236              
237             =item * C<$fh> file handle
238              
239             =back
240              
241             =cut
242              
243             sub apply_filehandle_utf8 {
244 102     102 1 185 my ($class, $fh) = @_;
245             # We'd expect `encoding(utf-8-strict)` and `utf8` if someone's already applied binmode
246             # for us, but implementation details in Perl may change those names slightly, and on
247             # some platforms (Windows?) there's also a chance of one of the UTF16LE/BE variants,
248             # so we make this check quite lax and skip binmode if there's anything even slightly
249             # utf-flavoured in the mix.
250             $fh->binmode(':encoding(UTF-8)')
251 102 100       534 unless grep { /utf/i } PerlIO::get_layers($fh, output => 1);
  238         1537  
252 102         24478 $fh->autoflush(1);
253             }
254              
255             =head2 format_line
256              
257             Formatting the log entry with timestamp, from which the message populated,
258             severity and message.
259              
260             If color/colour param passed it adds appropriate color code for timestamp,
261             log level, from which this log message populated and actual message.
262             For non-color mode, it just returns the formatted message.
263              
264             $object->format_line($data, {color => $color});
265              
266             =over 4
267              
268             =item * C<$data> hashref - The data with stack info like package method from
269             which the message populated, timestamp, severity and message
270              
271             =item * C<$opts> hashref - the options color
272              
273             =back
274              
275             Returns only formatted string if non-color mode. Otherwise returns formatted
276             string with embedded ANSI color code using L
277              
278             =cut
279              
280             sub format_line {
281 18     18 1 41 my ($class, $data, $opts) = @_;
282              
283             # With international development teams, no matter which spelling we choose
284             # someone's going to get this wrong sooner or later... or to put another
285             # way, we got country *and* western.
286 18   66     69 $opts->{colour} = $opts->{color} || $opts->{colour};
287              
288             # Expand formatting if necessary: it's not immediately clear how to defer
289             # handling of structured data, the ->structured method doesn't have a way
290             # to return the stringified data back to the caller for example
291             # for edge cases like `my $msg = $log->debug(...);` so we're still working
292             # on how best to handle this:
293             # https://metacpan.org/release/Log-Any/source/lib/Log/Any/Proxy.pm#L105
294             # $_ = sprintf $_->@* for grep ref, $data->{message};
295              
296             # If we have a stack entry, report the context - default to "main" if we're at top level
297 18 50       46 my $from = $data->{stack}[-1] ? join '->', @{$data->{stack}[-1]}{qw(package method)} : 'main';
  18         64  
298              
299             # Start with the plain-text details
300             my @details = (
301             Time::Moment->from_epoch($data->{epoch})->strftime('%Y-%m-%dT%H:%M:%S%3f'),
302             uc(substr $data->{severity}, 0, 1),
303 18         263 "[$from]", $data->{message});
304              
305             # This is good enough if we're in non-colour mode
306 18 100       113 return join ' ', @details unless $opts->{colour};
307              
308 7 50       37 my @colours = ($SEVERITY_COLOUR{$data->{severity}} || die 'no severity definition found for ' . $data->{severity})->@*;
309              
310             # Colour formatting codes applied at the start and end of each line, in case something else
311             # gets inbetween us and the output
312 7         18 local $Term::ANSIColor::EACHLINE = "\n";
313 7         16 my ($ts, $level) = splice @details, 0, 2;
314 7         18 $from = shift @details;
315              
316 7         30 return join ' ', colored($ts, qw(bright_blue)), colored($level, @colours), colored($from, qw(grey10)), map { colored($_, @colours) } @details;
  7         959  
317             }
318              
319             =head2 log_entry
320              
321             Add format and add color code using C and writes the log entry
322              
323             $object->log_entry($data);
324              
325             =over 4
326              
327             =item *C<$data> hashref - The log data
328              
329             =back
330              
331             =cut
332              
333             sub log_entry {
334 81     81 1 12659 my ($self, $data) = @_;
335 81         198 $data = $self->_process_data($data);
336 81         126 my $json_data;
337 81         124 my %text_data = ();
338 81   66 75   290 my $get_json = sub { $json_data //= encode_json_text($data) . "\n"; return $json_data; };
  75         349  
  75         1815  
339             my $get_text =
340 81   100 20   274 sub { my $color = shift // 0; $text_data{$color} //= $self->format_line($data, {color => $color}) . "\n"; return $text_data{$color}; };
  20   66     57  
  20         113  
  20         355  
341              
342             # remove substitution context from message
343 81 50       201 if ($data->{message}) {
344 81         213 $data->{message} =~ s/\".*//;
345             }
346             # Prepare the JSON object with the required fields
347             my %log_data = (
348             message => $data->{message},
349             severity => $data->{severity},
350 81         261 );
351              
352 81 50 33     209 if ($self->{context} && ref($self->{context}) eq 'HASH') {
353 0         0 my @keys = keys %{$self->{context}}; # Get the keys from the context hash
  0         0  
354              
355 0         0 foreach my $key (@keys) {
356 0         0 $log_data{$key} = $self->{context}->{$key};
357             }
358 0         0 my $json_string = $JSON->encode(\%log_data);
359 0         0 $data->{message} = $json_string;
360             }
361              
362 81 100       187 if ($self->{json_fh}) {
363 53         150 _lock($self->{json_fh});
364 53         152 $self->{json_fh}->print($get_json->());
365 53         3452 _unlock($self->{json_fh});
366             }
367              
368 81         194 for my $stdfile (qw(stderr stdout)) {
369 162 100       1120 next unless $self->{$stdfile};
370             my $txt =
371             $self->{$stdfile}{format} eq 'json'
372             ? $get_json->()
373 42 100       132 : $get_text->($self->{$stdfile}{color});
374 42         84 my $fh = $self->{$stdfile}{fh};
375              
376 42         130 _lock($fh);
377 42         187 $fh->print($txt);
378 42         791 _unlock($fh);
379             }
380             }
381              
382             =head2 _process_data
383              
384             Process the data before printing out. Reduce the continues L stack
385             messages and filter the messages based on log level.
386              
387             $object->_process_data($data);
388              
389             =over 4
390              
391             =item * C<$data> hashref - The log data.
392              
393             =back
394              
395             Returns a hashref - the processed data
396              
397             =cut
398              
399             sub _process_data {
400 81     81   149 my ($self, $data) = @_;
401              
402 81         2483 $data = clone($data);
403 81         325 $data = $self->_collapse_future_stack($data);
404 81         184 $data = $self->_filter_stack($data);
405              
406 81         1034 return $data;
407             }
408              
409             =head2 _filter_stack
410              
411             Filter the stack message based on log level.
412              
413             $object->_filter_stack($data);
414              
415             =over 4
416              
417             =item * C<$data> hashref - Log stack data
418              
419             =back
420              
421             Returns hashref - the filtered data
422              
423             =cut
424              
425             sub _filter_stack {
426 81     81   144 my ($self, $data) = @_;
427              
428 81 100       238 return $data if (numeric_level($data->{severity}) <= numeric_level('warn'));
429              
430             # now severity > warn
431 12 100       218 return $data if $self->{log_level} >= numeric_level('debug');
432              
433 3         23 delete $data->{stack};
434              
435 3         6 return $data;
436             }
437              
438             =head2 _collapse_future_stack
439              
440             Go through the caller stack and if continuous L messages then keep
441             only one at the first.
442              
443             $object->_collapse_future_stack($data);
444              
445             =over 4
446              
447             =item * C<$data> hashref - Log stack data
448              
449             =back
450              
451             Returns a hashref - the reduced log data
452              
453             =cut
454              
455             sub _collapse_future_stack {
456 83     83   1847 my ($self, $data) = @_;
457 83         153 my $stack = $data->{stack};
458 83         142 my @new_stack;
459             my $previous_is_future;
460              
461 83         180 for my $frame ($stack->@*) {
462 214 100 100     642 if ($frame->{package} eq 'Future' || $frame->{package} eq 'Future::PP') {
463 29 100       70 next if ($previous_is_future);
464 6         12 push @new_stack, $frame;
465 6         12 $previous_is_future = 1;
466             } else {
467 185         273 push @new_stack, $frame;
468 185         283 $previous_is_future = 0;
469             }
470             }
471 83         145 $data->{stack} = \@new_stack;
472              
473 83         224 return $data;
474             }
475              
476             =head2 _fh_is_tty
477              
478             Check the filehandle opened to tty
479              
480             =over 4
481              
482             =item * C<$fh> file handle
483              
484             =back
485              
486             Returns boolean
487              
488             =cut
489              
490             sub _fh_is_tty {
491 21     21   35 my $fh = shift;
492              
493 21         250 return -t $fh; ## no critic (ProhibitInteractiveTest)
494             }
495              
496             =head2 _in_container
497              
498             Returns true if we think we are currently running in a container.
499              
500             At the moment this only looks for a C<.dockerenv> file in the root directory;
501             future versions may expand this to provide a more accurate check covering
502             other container systems such as `runc`.
503              
504             Returns boolean
505              
506             =cut
507              
508             sub _in_container {
509 18     18   388 return -r '/.dockerenv';
510             }
511              
512             =head2 _linux_flock_data
513              
514             Based on the type of lock requested, it packs into linux binary flock structure
515             and return the string of that structure.
516              
517             Linux struct flock: "s s l l i"
518             short l_type short - Possible values: F_RDLCK(0) - read lock, F_WRLCK(1) - write lock, F_UNLCK(2) - unlock
519             short l_whence - starting offset
520             off_t l_start - relative offset
521             off_t l_len - number of consecutive bytes to lock
522             pid_t l_pid - process ID
523              
524             =over 4
525              
526             =item * C<$type> integer lock type - F_WRLCK or F_UNLCK
527              
528             =back
529              
530             Returns a string of the linux flock structure
531              
532             =cut
533              
534             sub _linux_flock_data {
535 102     102   183 my ($type) = @_;
536 102         177 my $FLOCK_STRUCT = "s s l l i";
537              
538 102         484 return pack($FLOCK_STRUCT, $type, SEEK_SET, 0, 0, 0);
539             }
540              
541             =head2 _flock
542              
543             call fcntl to lock or unlock a file handle
544              
545             =over 4
546              
547             =item * C<$fh> file handle
548              
549             =item * C<$type> lock type, either F_WRLCK or F_UNLCK
550              
551             =back
552              
553             Returns boolean or undef
554              
555             =cut
556              
557             # We don't use `flock` function directly here
558             # In some cases the program will do fork after the log file opened.
559             # In such case every subprocess can get lock of the log file at the same time.
560             # Using fcntl to lock a file can avoid this problem
561             sub _flock {
562 102     102   181 my ($fh, $type) = @_;
563 102         170 my $lock = _linux_flock_data($type);
564 102         1186 my $result = fcntl($fh, F_SETLKW, $lock);
565              
566 102 50       425 return $result if $result;
567              
568 0         0 return undef;
569             }
570              
571             =head2 _lock
572              
573             Lock a file handler with fcntl.
574              
575             =over 4
576              
577             =item * C<$fh> File handle
578              
579             =back
580              
581             Returns boolean
582              
583             =cut
584              
585             sub _lock {
586 51     51   80 my ($fh) = @_;
587              
588 51         104 return _flock($fh, F_WRLCK);
589             }
590              
591             =head2 _unlock
592              
593             Unlock a file handler locked by fcntl
594              
595             =over 4
596              
597             =item * C<$fh> File handle
598              
599             =back
600              
601             Returns boolean
602              
603             =cut
604              
605             sub _unlock {
606 51     51   118 my ($fh) = @_;
607              
608 51         106 return _flock($fh, F_UNLCK);
609             }
610              
611             =head2 level
612              
613             Return the current log level name.
614              
615             =cut
616              
617             sub level {
618 9     9 1 276 my $self = shift;
619 9         52 return $num_to_name{$self->{log_level}};
620             }
621              
622             1;
623              
624             =head1 AUTHOR
625              
626             Deriv Group Services Ltd. C<< DERIV@cpan.org >>
627              
628             =head1 LICENSE
629              
630             Copyright Deriv Group Services Ltd 2020-2021. Licensed under the same terms as Perl itself.