File Coverage

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


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   243954 use strict;
  4         33  
  4         114  
5 4     4   24 use warnings;
  4         10  
  4         209  
6              
7             our $AUTHORITY = 'cpan:DERIV'; # AUTHORITY
8             our $VERSION = '0.004';
9              
10 4     4   22 use feature qw(state);
  4         18  
  4         479  
11 4     4   1937 use parent qw(Log::Any::Adapter::Coderef);
  4         1246  
  4         19  
12              
13 4     4   14821 use utf8;
  4         58  
  4         21  
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   1968 use Time::Moment;
  4         6078  
  4         143  
114 4     4   2815 use Path::Tiny;
  4         40475  
  4         228  
115 4     4   2852 use curry;
  4         1396  
  4         153  
116 4     4   1385 use JSON::MaybeUTF8 qw(:v1);
  4         18344  
  4         583  
117 4     4   27 use PerlIO;
  4         10  
  4         27  
118 4     4   112 use Config;
  4         9  
  4         150  
119 4     4   2104 use Term::ANSIColor;
  4         28209  
  4         300  
120 4     4   29 use Log::Any qw($log);
  4         48  
  4         27  
121 4     4   1150 use Fcntl qw(:DEFAULT :seek :flock);
  4         9  
  4         1586  
122 4     4   32 use Log::Any::Adapter::Util qw(numeric_level logging_methods);
  4         7  
  4         312  
123 4     4   1945 use Clone qw(clone);
  4         10309  
  4         812  
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   31 no warnings 'redefine'; ## no critic (ProhibitNoWarnings)
  4         8  
  4         802  
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   31 no warnings; ## no critic (ProhibitNoWarnings)
  4         8  
  4         8587  
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 378802 my ($class, %args) = @_;
196 180     0   1060 my $self = $class->SUPER::new(sub { }, %args);
197              
198             # if there is json_log_file, then print json to that file
199 180 100       6632 if ($self->{json_log_file}) {
200 106 50       307 $self->{json_fh} = path($self->{json_log_file})->opena_utf8 or die 'unable to open log file - ' . $!;
201 106         61582 $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     6096 if (!$self->{json_log_file} && !$self->{stderr}) {
208 50         97 $self->{stderr} = 1;
209             }
210              
211 180         649 for my $stdfile (['stderr', \*STDERR], ['stdout', \*STDOUT]) {
212 360         1144 my ($name, $fh) = $stdfile->@*;
213 360 100       890 if ($self->{$name}) {
214 102 50       362 $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     627 if (!$self->{$name}{format} || $self->{$name}{format} ne 'json' && $self->{$name}{format} ne 'text');
    100 66        
218 102         462 $self->apply_filehandle_utf8($fh);
219 102         5848 $self->{$name}{fh} = $fh;
220 102   66     409 $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         1413 $self->{code} = $self->curry::log_entry;
226 180         3371 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 215 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       599 unless grep { /utf/i } PerlIO::get_layers($fh, output => 1);
  238         1568  
252 102         24954 $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     64 $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         69  
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         255 "[$from]", $data->{message});
304              
305             # This is good enough if we're in non-colour mode
306 18 100       110 return join ' ', @details unless $opts->{colour};
307              
308 7 50       31 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         14 local $Term::ANSIColor::EACHLINE = "\n";
313 7         20 my ($ts, $level) = splice @details, 0, 2;
314 7         17 $from = shift @details;
315              
316 7         26 return join ' ', colored($ts, qw(bright_blue)), colored($level, @colours), colored($from, qw(grey10)), map { colored($_, @colours) } @details;
  7         953  
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 13005 my ($self, $data) = @_;
335 81         197 $data = $self->_process_data($data);
336 81         128 my $json_data;
337 81         137 my %text_data = ();
338 81   66 75   330 my $get_json = sub { $json_data //= encode_json_text($data) . "\n"; return $json_data; };
  75         361  
  75         1949  
339             my $get_text =
340 81   100 20   272 sub { my $color = shift // 0; $text_data{$color} //= $self->format_line($data, {color => $color}) . "\n"; return $text_data{$color}; };
  20   66     59  
  20         110  
  20         362  
341              
342             # remove substitution context from message
343 81 50       201 if ($data->{message}) {
344 81         210 $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         256 );
351 81 50 33     251 if ($self->{context} && ref($self->{context}) eq 'HASH') {
352 0         0 foreach my $key (keys %{$self->{context}}) {
  0         0  
353 0         0 $log_data{$key} = $self->{context}->{$key};
354             }
355 0         0 my $json_string = $JSON->encode(\%log_data);
356 0         0 $data->{message} = $json_string;
357             }
358              
359 81 100       183 if ($self->{json_fh}) {
360 53         152 _lock($self->{json_fh});
361 53         170 $self->{json_fh}->print($get_json->());
362 53         5996 _unlock($self->{json_fh});
363             }
364              
365 81         200 for my $stdfile (qw(stderr stdout)) {
366 162 100       1156 next unless $self->{$stdfile};
367             my $txt =
368             $self->{$stdfile}{format} eq 'json'
369             ? $get_json->()
370 42 100       132 : $get_text->($self->{$stdfile}{color});
371 42         91 my $fh = $self->{$stdfile}{fh};
372              
373 42         140 _lock($fh);
374 42         200 $fh->print($txt);
375 42         829 _unlock($fh);
376             }
377             }
378              
379             =head2 _process_data
380              
381             Process the data before printing out. Reduce the continues L stack
382             messages and filter the messages based on log level.
383              
384             $object->_process_data($data);
385              
386             =over 4
387              
388             =item * C<$data> hashref - The log data.
389              
390             =back
391              
392             Returns a hashref - the processed data
393              
394             =cut
395              
396             sub _process_data {
397 81     81   145 my ($self, $data) = @_;
398              
399 81         2906 $data = clone($data);
400 81         316 $data = $self->_collapse_future_stack($data);
401 81         198 $data = $self->_filter_stack($data);
402              
403 81         1079 return $data;
404             }
405              
406             =head2 _filter_stack
407              
408             Filter the stack message based on log level.
409              
410             $object->_filter_stack($data);
411              
412             =over 4
413              
414             =item * C<$data> hashref - Log stack data
415              
416             =back
417              
418             Returns hashref - the filtered data
419              
420             =cut
421              
422             sub _filter_stack {
423 81     81   149 my ($self, $data) = @_;
424              
425 81 100       239 return $data if (numeric_level($data->{severity}) <= numeric_level('warn'));
426              
427             # now severity > warn
428 12 100       188 return $data if $self->{log_level} >= numeric_level('debug');
429              
430 3         23 delete $data->{stack};
431              
432 3         6 return $data;
433             }
434              
435             =head2 _collapse_future_stack
436              
437             Go through the caller stack and if continuous L messages then keep
438             only one at the first.
439              
440             $object->_collapse_future_stack($data);
441              
442             =over 4
443              
444             =item * C<$data> hashref - Log stack data
445              
446             =back
447              
448             Returns a hashref - the reduced log data
449              
450             =cut
451              
452             sub _collapse_future_stack {
453 83     83   1774 my ($self, $data) = @_;
454 83         150 my $stack = $data->{stack};
455 83         151 my @new_stack;
456             my $previous_is_future;
457              
458 83         190 for my $frame ($stack->@*) {
459 214 100 100     677 if ($frame->{package} eq 'Future' || $frame->{package} eq 'Future::PP') {
460 29 100       57 next if ($previous_is_future);
461 6         15 push @new_stack, $frame;
462 6         12 $previous_is_future = 1;
463             } else {
464 185         332 push @new_stack, $frame;
465 185         319 $previous_is_future = 0;
466             }
467             }
468 83         154 $data->{stack} = \@new_stack;
469              
470 83         239 return $data;
471             }
472              
473             =head2 _fh_is_tty
474              
475             Check the filehandle opened to tty
476              
477             =over 4
478              
479             =item * C<$fh> file handle
480              
481             =back
482              
483             Returns boolean
484              
485             =cut
486              
487             sub _fh_is_tty {
488 21     21   35 my $fh = shift;
489              
490 21         238 return -t $fh; ## no critic (ProhibitInteractiveTest)
491             }
492              
493             =head2 _in_container
494              
495             Returns true if we think we are currently running in a container.
496              
497             At the moment this only looks for a C<.dockerenv> file in the root directory;
498             future versions may expand this to provide a more accurate check covering
499             other container systems such as `runc`.
500              
501             Returns boolean
502              
503             =cut
504              
505             sub _in_container {
506 18     18   389 return -r '/.dockerenv';
507             }
508              
509             =head2 _linux_flock_data
510              
511             Based on the type of lock requested, it packs into linux binary flock structure
512             and return the string of that structure.
513              
514             Linux struct flock: "s s l l i"
515             short l_type short - Possible values: F_RDLCK(0) - read lock, F_WRLCK(1) - write lock, F_UNLCK(2) - unlock
516             short l_whence - starting offset
517             off_t l_start - relative offset
518             off_t l_len - number of consecutive bytes to lock
519             pid_t l_pid - process ID
520              
521             =over 4
522              
523             =item * C<$type> integer lock type - F_WRLCK or F_UNLCK
524              
525             =back
526              
527             Returns a string of the linux flock structure
528              
529             =cut
530              
531             sub _linux_flock_data {
532 102     102   187 my ($type) = @_;
533 102         163 my $FLOCK_STRUCT = "s s l l i";
534              
535 102         477 return pack($FLOCK_STRUCT, $type, SEEK_SET, 0, 0, 0);
536             }
537              
538             =head2 _flock
539              
540             call fcntl to lock or unlock a file handle
541              
542             =over 4
543              
544             =item * C<$fh> file handle
545              
546             =item * C<$type> lock type, either F_WRLCK or F_UNLCK
547              
548             =back
549              
550             Returns boolean or undef
551              
552             =cut
553              
554             # We don't use `flock` function directly here
555             # In some cases the program will do fork after the log file opened.
556             # In such case every subprocess can get lock of the log file at the same time.
557             # Using fcntl to lock a file can avoid this problem
558             sub _flock {
559 102     102   185 my ($fh, $type) = @_;
560 102         221 my $lock = _linux_flock_data($type);
561 102         1206 my $result = fcntl($fh, F_SETLKW, $lock);
562              
563 102 50       456 return $result if $result;
564              
565 0         0 return undef;
566             }
567              
568             =head2 _lock
569              
570             Lock a file handler with fcntl.
571              
572             =over 4
573              
574             =item * C<$fh> File handle
575              
576             =back
577              
578             Returns boolean
579              
580             =cut
581              
582             sub _lock {
583 51     51   92 my ($fh) = @_;
584              
585 51         97 return _flock($fh, F_WRLCK);
586             }
587              
588             =head2 _unlock
589              
590             Unlock a file handler locked by fcntl
591              
592             =over 4
593              
594             =item * C<$fh> File handle
595              
596             =back
597              
598             Returns boolean
599              
600             =cut
601              
602             sub _unlock {
603 51     51   121 my ($fh) = @_;
604              
605 51         145 return _flock($fh, F_UNLCK);
606             }
607              
608             =head2 level
609              
610             Return the current log level name.
611              
612             =cut
613              
614             sub level {
615 9     9 1 283 my $self = shift;
616 9         50 return $num_to_name{$self->{log_level}};
617             }
618              
619             1;
620              
621             =head1 AUTHOR
622              
623             Deriv Group Services Ltd. C<< DERIV@cpan.org >>
624              
625             =head1 LICENSE
626              
627             Copyright Deriv Group Services Ltd 2020-2021. Licensed under the same terms as Perl itself.