File Coverage

blib/lib/Log/Any/Adapter/DERIV.pm
Criterion Covered Total %
statement 147 155 94.8
branch 33 40 82.5
condition 21 29 72.4
subroutine 34 36 94.4
pod 4 5 80.0
total 239 265 90.1


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   163879 use strict;
  4         30  
  4         113  
5 4     4   20 use warnings;
  4         8  
  4         215  
6              
7             our $AUTHORITY = 'cpan:DERIV'; # AUTHORITY
8             our $VERSION = '0.003';
9              
10 4     4   27 use feature qw(state);
  4         10  
  4         452  
11 4     4   1791 use parent qw(Log::Any::Adapter::Coderef);
  4         1259  
  4         36  
12              
13 4     4   19732 use utf8;
  4         61  
  4         19  
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   1806 use Time::Moment;
  4         5706  
  4         142  
114 4     4   2592 use Path::Tiny;
  4         38149  
  4         218  
115 4     4   2620 use curry;
  4         1361  
  4         146  
116 4     4   1286 use JSON::MaybeUTF8 qw(:v1);
  4         17601  
  4         520  
117 4     4   29 use PerlIO;
  4         8  
  4         42  
118 4     4   105 use Config;
  4         8  
  4         138  
119 4     4   1913 use Term::ANSIColor;
  4         27612  
  4         256  
120 4     4   29 use Log::Any qw($log);
  4         39  
  4         31  
121 4     4   1003 use Fcntl qw(:DEFAULT :seek :flock);
  4         9  
  4         1619  
122 4     4   33 use Log::Any::Adapter::Util qw(numeric_level logging_methods);
  4         8  
  4         300  
123 4     4   1772 use Clone qw(clone);
  4         9502  
  4         766  
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   36 no warnings 'redefine'; ## no critic (ProhibitNoWarnings)
  4         8  
  4         738  
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   30 no warnings; ## no critic (ProhibitNoWarnings)
  4         7  
  4         7529  
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 301515 my ($class, %args) = @_;
196 180     0   1084 my $self = $class->SUPER::new(sub { }, %args);
197              
198             # if there is json_log_file, then print json to that file
199 180 100       6763 if ($self->{json_log_file}) {
200 106 50       283 $self->{json_fh} = path($self->{json_log_file})->opena_utf8 or die 'unable to open log file - ' . $!;
201 106         58506 $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     4741 if (!$self->{json_log_file} && !$self->{stderr}) {
208 50         89 $self->{stderr} = 1;
209             }
210              
211 180         613 for my $stdfile (['stderr', \*STDERR], ['stdout', \*STDOUT]) {
212 360         1084 my ($name, $fh) = $stdfile->@*;
213 360 100       1656 if ($self->{$name}) {
214 102 50       361 $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     574 if (!$self->{$name}{format} || $self->{$name}{format} ne 'json' && $self->{$name}{format} ne 'text');
    100 66        
218 102         424 $self->apply_filehandle_utf8($fh);
219 102         5530 $self->{$name}{fh} = $fh;
220 102   66     352 $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         1352 $self->{code} = $self->curry::log_entry;
226 180         3223 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 188 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       509 unless grep { /utf/i } PerlIO::get_layers($fh, output => 1);
  238         1446  
252 102         23622 $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 37 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     60 $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       42 my $from = $data->{stack}[-1] ? join '->', @{$data->{stack}[-1]}{qw(package method)} : 'main';
  18         57  
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         222 "[$from]", $data->{message});
304              
305             # This is good enough if we're in non-colour mode
306 18 100       111 return join ' ', @details unless $opts->{colour};
307              
308 7 50       26 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         16 local $Term::ANSIColor::EACHLINE = "\n";
313 7         19 my ($ts, $level) = splice @details, 0, 2;
314 7         12 $from = shift @details;
315              
316 7         28 return join ' ', colored($ts, qw(bright_blue)), colored($level, @colours), colored($from, qw(grey10)), map { colored($_, @colours) } @details;
  7         931  
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 12246 my ($self, $data) = @_;
335 81         200 $data = $self->_process_data($data);
336 81         112 my $json_data;
337 81         143 my %text_data = ();
338 81   66 75   286 my $get_json = sub { $json_data //= encode_json_text($data) . "\n"; return $json_data; };
  75         332  
  75         1888  
339             my $get_text =
340 81   100 20   278 sub { my $color = shift // 0; $text_data{$color} //= $self->format_line($data, {color => $color}) . "\n"; return $text_data{$color}; };
  20   66     56  
  20         103  
  20         345  
341              
342 81 100       244 if ($self->{json_fh}) {
343 53         148 _lock($self->{json_fh});
344 53         179 $self->{json_fh}->print($get_json->());
345 53         3309 _unlock($self->{json_fh});
346             }
347              
348 81         194 for my $stdfile (qw(stderr stdout)) {
349 162 100       1073 next unless $self->{$stdfile};
350             my $txt =
351             $self->{$stdfile}{format} eq 'json'
352             ? $get_json->()
353 42 100       127 : $get_text->($self->{$stdfile}{color});
354 42         84 my $fh = $self->{$stdfile}{fh};
355              
356 42         121 _lock($fh);
357 42         182 $fh->print($txt);
358 42         760 _unlock($fh);
359             }
360             }
361              
362             =head2 _process_data
363              
364             Process the data before printing out. Reduce the continues L stack
365             messages and filter the messages based on log level.
366              
367             $object->_process_data($data);
368              
369             =over 4
370              
371             =item * C<$data> hashref - The log data.
372              
373             =back
374              
375             Returns a hashref - the processed data
376              
377             =cut
378              
379             sub _process_data {
380 81     81   138 my ($self, $data) = @_;
381              
382 81         2308 $data = clone($data);
383 81         292 $data = $self->_collapse_future_stack($data);
384 81         188 $data = $self->_filter_stack($data);
385              
386 81         1040 return $data;
387             }
388              
389             =head2 _filter_stack
390              
391             Filter the stack message based on log level.
392              
393             $object->_filter_stack($data);
394              
395             =over 4
396              
397             =item * C<$data> hashref - Log stack data
398              
399             =back
400              
401             Returns hashref - the filtered data
402              
403             =cut
404              
405             sub _filter_stack {
406 81     81   140 my ($self, $data) = @_;
407              
408 81 100       233 return $data if (numeric_level($data->{severity}) <= numeric_level('warn'));
409              
410             # now severity > warn
411 12 100       178 return $data if $self->{log_level} >= numeric_level('debug');
412              
413 3         23 delete $data->{stack};
414              
415 3         4 return $data;
416             }
417              
418             =head2 _collapse_future_stack
419              
420             Go through the caller stack and if continuous L messages then keep
421             only one at the first.
422              
423             $object->_collapse_future_stack($data);
424              
425             =over 4
426              
427             =item * C<$data> hashref - Log stack data
428              
429             =back
430              
431             Returns a hashref - the reduced log data
432              
433             =cut
434              
435             sub _collapse_future_stack {
436 83     83   1266 my ($self, $data) = @_;
437 83         136 my $stack = $data->{stack};
438 83         138 my @new_stack;
439             my $previous_is_future;
440              
441 83         176 for my $frame ($stack->@*) {
442 214 100 100     646 if ($frame->{package} eq 'Future' || $frame->{package} eq 'Future::PP') {
443 29 100       55 next if ($previous_is_future);
444 6         12 push @new_stack, $frame;
445 6         10 $previous_is_future = 1;
446             } else {
447 185         278 push @new_stack, $frame;
448 185         276 $previous_is_future = 0;
449             }
450             }
451 83         153 $data->{stack} = \@new_stack;
452              
453 83         214 return $data;
454             }
455              
456             =head2 _fh_is_tty
457              
458             Check the filehandle opened to tty
459              
460             =over 4
461              
462             =item * C<$fh> file handle
463              
464             =back
465              
466             Returns boolean
467              
468             =cut
469              
470             sub _fh_is_tty {
471 21     21   40 my $fh = shift;
472              
473 21         226 return -t $fh; ## no critic (ProhibitInteractiveTest)
474             }
475              
476             =head2 _in_container
477              
478             Returns true if we think we are currently running in a container.
479              
480             At the moment this only looks for a C<.dockerenv> file in the root directory;
481             future versions may expand this to provide a more accurate check covering
482             other container systems such as `runc`.
483              
484             Returns boolean
485              
486             =cut
487              
488             sub _in_container {
489 18     18   394 return -r '/.dockerenv';
490             }
491              
492             =head2 _linux_flock_data
493              
494             Based on the type of lock requested, it packs into linux binary flock structure
495             and return the string of that structure.
496              
497             Linux struct flock: "s s l l i"
498             short l_type short - Possible values: F_RDLCK(0) - read lock, F_WRLCK(1) - write lock, F_UNLCK(2) - unlock
499             short l_whence - starting offset
500             off_t l_start - relative offset
501             off_t l_len - number of consecutive bytes to lock
502             pid_t l_pid - process ID
503              
504             =over 4
505              
506             =item * C<$type> integer lock type - F_WRLCK or F_UNLCK
507              
508             =back
509              
510             Returns a string of the linux flock structure
511              
512             =cut
513              
514             sub _linux_flock_data {
515 102     102   159 my ($type) = @_;
516 102         181 my $FLOCK_STRUCT = "s s l l i";
517              
518 102         451 return pack($FLOCK_STRUCT, $type, SEEK_SET, 0, 0, 0);
519             }
520              
521             =head2 _flock
522              
523             call fcntl to lock or unlock a file handle
524              
525             =over 4
526              
527             =item * C<$fh> file handle
528              
529             =item * C<$type> lock type, either F_WRLCK or F_UNLCK
530              
531             =back
532              
533             Returns boolean or undef
534              
535             =cut
536              
537             # We don't use `flock` function directly here
538             # In some cases the program will do fork after the log file opened.
539             # In such case every subprocess can get lock of the log file at the same time.
540             # Using fcntl to lock a file can avoid this problem
541             sub _flock {
542 102     102   184 my ($fh, $type) = @_;
543 102         178 my $lock = _linux_flock_data($type);
544 102         1123 my $result = fcntl($fh, F_SETLKW, $lock);
545              
546 102 50       394 return $result if $result;
547              
548 0         0 return undef;
549             }
550              
551             =head2 _lock
552              
553             Lock a file handler with fcntl.
554              
555             =over 4
556              
557             =item * C<$fh> File handle
558              
559             =back
560              
561             Returns boolean
562              
563             =cut
564              
565             sub _lock {
566 51     51   87 my ($fh) = @_;
567              
568 51         98 return _flock($fh, F_WRLCK);
569             }
570              
571             =head2 _unlock
572              
573             Unlock a file handler locked by 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 _unlock {
586 51     51   115 my ($fh) = @_;
587              
588 51         112 return _flock($fh, F_UNLCK);
589             }
590              
591             =head2 level
592              
593             Return the current log level name.
594              
595             =cut
596              
597             sub level {
598 9     9 1 264 my $self = shift;
599 9         43 return $num_to_name{$self->{log_level}};
600             }
601              
602             1;
603              
604             =head1 AUTHOR
605              
606             Deriv Group Services Ltd. C<< DERIV@cpan.org >>
607              
608             =head1 LICENSE
609              
610             Copyright Deriv Group Services Ltd 2020-2021. Licensed under the same terms as Perl itself.