File Coverage

blib/lib/Log/Any/Adapter/DERIV.pm
Criterion Covered Total %
statement 165 173 95.3
branch 33 40 82.5
condition 21 29 72.4
subroutine 39 41 95.1
pod 7 8 87.5
total 265 291 91.0


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