File Coverage

blib/lib/Log/Log4perl/Layout/JSON.pm
Criterion Covered Total %
statement 180 191 94.2
branch 64 80 80.0
condition 25 36 69.4
subroutine 18 18 100.0
pod 0 2 0.0
total 287 327 87.7


line stmt bran cond sub pod time code
1             package Log::Log4perl::Layout::JSON;
2             $Log::Log4perl::Layout::JSON::VERSION = '0.60';
3             # ABSTRACT: Layout a log message as a JSON hash, including MDC data
4              
5 8     8   30356 use 5.010;
  8         35  
6 8     8   53 use strict;
  8         16  
  8         173  
7 8     8   66 use warnings;
  8         22  
  8         245  
8              
9 8     8   851 use parent qw(Log::Log4perl::Layout);
  8         652  
  8         62  
10              
11 8     8   758 use Carp;
  8         18  
  8         498  
12 8     8   61 use JSON::MaybeXS;
  8         25  
  8         460  
13 8     8   53 use Log::Log4perl ();
  8         16  
  8         178  
14 8     8   50 use Log::Log4perl::Layout::PatternLayout;
  8         15  
  8         225  
15 8     8   42 use Log::Log4perl::Level;
  8         16  
  8         58  
16 8     8   1566 use Scalar::Util qw(blessed);
  8         19  
  8         3207  
17              
18             # TODO
19             # add eval around encode
20             # add way to include/exclude MDC items when include_mdc is enabled (eg by name and perhaps allow a regex)
21             # more tests
22             # consider ways to limit depth/breadth of encoded mdc data
23             # add overall message size limit
24              
25             use Class::Tiny {
26              
27             prefix => "",
28             format_prefix => 0,
29              
30             codec => sub {
31 22         237 return JSON::MaybeXS->new
32             ->indent(0) # to prevent newlines (and save space)
33             ->ascii(1) # to avoid encoding issues downstream
34             ->allow_unknown(1) # encode null on bad value (instead of exception)
35             ->convert_blessed(1) # call TO_JSON on blessed ref, if it exists
36             ->allow_blessed(1) # encode null on blessed ref that can't be converted
37             ;
38             },
39              
40             # mdc_handler is a code ref that, when called, returns name-value pairs
41             # of values from the MDC
42             mdc_handler => sub {
43 22         145 my $self = shift;
44              
45 22 100       381 return sub { } unless $self->include_mdc;
46              
47 5         59 my $mdc_hash = Log::Log4perl::MDC->get_context;
48              
49 5 100       99 if (my $mdc_field = $self->name_for_mdc) {
50             return sub {
51 5 100       33 return () unless %$mdc_hash;
52 2         8 return ($mdc_field => $mdc_hash);
53 2         23 };
54             }
55             else {
56             return sub {
57 10 50       196 return %$mdc_hash unless $self->canonical;
58 10         88 return map { $_ => $mdc_hash->{$_} } sort keys %$mdc_hash;
  17         48  
59 3         33 };
60             }
61             },
62              
63             field => sub {
64 1         7 my $self = shift;
65              
66 1         2 my $content = {};
67              
68 1 50       17 unless ($self->exclude_message) {
69 0         0 $content->{message} = { value => '%m{chomp}' };
70             }
71              
72 1         10 return $content;
73             },
74             maxkb => undef,
75             canonical => 0,
76             include_mdc => 0,
77             exclude_message => 0,
78             name_for_mdc => undef,
79             max_json_length_kb => 20,
80              
81             # if format_prefix is true, the prefix is a PatternLayout that itself can be formatted
82             _prefix_layout => sub {
83 3         65 my $self = shift;
84              
85 3         53 return Log::Log4perl::Layout::PatternLayout->new($self->prefix);
86             },
87 8     8   4858 };
  8         14952  
  8         120  
88 8     8   27749 BEGIN { push our @ISA, 'Class::Tiny::Object' }
89              
90             my $last_render_error;
91              
92              
93             sub BUILD { ## no critic (RequireArgUnpacking)
94 22     22 0 76518 my ($self, $args) = @_;
95              
96 22         50 delete $args->{value}; # => 'Log::Log4perl::Layout::JSON'
97              
98 22 50       79 if (my $arg = $args->{canonical}) {
99 22         554 $self->codec->canonical($arg->{value});
100             }
101              
102 22 100       524 if ($args->{field}) {
103 21         47 my $field = delete $args->{field};
104 21         64 $self->_build_field_values($field);
105 21         2464 $self->field($field);
106             }
107              
108 22 100       156 if ($args->{maxkb}) {
109 2         4 my $maxkb = delete $args->{maxkb};
110              
111 2         6 $self->_build_maxkb_values($maxkb);
112              
113 2         36 $self->maxkb($maxkb);
114             }
115              
116             # Optionally override encoding from ascii to utf8
117 22 100       73 if (my $arg = $args->{utf8}) {
118 1         3 delete $args->{utf8};
119 1         17 $self->codec( $self->codec->ascii(0)->utf8(1) );
120             }
121              
122 22         54 for my $arg_name (qw(
123             canonical prefix include_mdc exclude_message name_for_mdc max_json_length_kb format_prefix
124             )) {
125 154 100       553 my $arg = delete $args->{$arg_name}
126             or next;
127 46         790 $self->$arg_name( $arg->{value} );
128             }
129              
130 22 50       121 warn "Unknown configuration items: @{[ sort keys %$args ]}"
  0         0  
131             if %$args;
132              
133             #use Data::Dumper; warn Dumper $self;
134              
135             # sanity check to catch problems with the config at build time
136 22         33 if (1) {
137 22         43 undef $last_render_error;
138 22         104 $self->render("Testing $self config", "test", 1, 0);
139 22 50       983 die $last_render_error if $last_render_error;
140             }
141              
142 22         62 return $self;
143             }
144              
145             sub _process_field_values {
146 74     74   167 my($self, $m, $category, $priority, $caller_level, $fields, $layed_out) = @_;
147              
148 74         105 $caller_level++;
149              
150 74   66     1244 $fields //= $self->field;
151 74   100     885 $layed_out //= {};
152              
153 74         210 for my $field (keys %$fields) {
154 218         13971 my $value = $fields->{$field};
155              
156 218 100 66     1096 if (blessed($value) and blessed($value) eq 'Log::Log4perl::Layout::PatternLayout') {
    100          
    50          
157 190         529 $layed_out->{$field} = $value->render($m, $category, $priority, $caller_level);
158             }
159             elsif (ref($value) eq 'HASH') {
160 24   50     112 $layed_out->{$field} //= {};
161 24         74 $self->_process_field_values($m, $category, $priority, $caller_level, $value, $layed_out->{$field});
162             }
163             elsif (ref($value) eq 'CODE') {
164 4         14 $layed_out->{$field} = $value->($m, $category, $priority, $caller_level);
165             }
166             }
167              
168 74         5735 return $layed_out;
169             }
170              
171             sub _build_field_values {
172              
173 33     33   64 my($self, $field_hash) = @_;
174              
175 33         83 for my $key (keys %$field_hash) {
176 100         6340 my $value = $field_hash->{$key};
177 100 100       249 next unless ref $value eq 'HASH';
178              
179 95 100 100     386 if (exists $value->{value} && !ref($value->{value})) {
    100 100        
180             $field_hash->{$key} = Log::Log4perl::Layout::PatternLayout->new($value->{value})
181 81         270 }
182             elsif (exists $value->{value} && ref($value->{value}) ne 'HASH') {
183             $field_hash->{$key} = $value->{value}
184 2         7 }
185             else {
186 12         72 $self->_build_field_values($value)
187             }
188             }
189             }
190              
191             sub _build_maxkb_values {
192 4     4   8 my($self, $maxkb_hash) = @_;
193              
194 4         8 for my $key (keys %$maxkb_hash) {
195 4         8 my $value = $maxkb_hash->{$key};
196 4 100 66     13 if (exists $value->{value} && !ref($value->{value})) {
197 2         9 $maxkb_hash->{$key} = $value->{value};
198             }
199             else {
200 2         6 $self->_build_maxkb_values($value)
201             }
202             }
203             }
204              
205             sub _truncate_value {
206 8     8   20 my($self, $value_ref, $maxkb) = @_;
207              
208 8 50 33     20 return if !$value_ref || !${$value_ref};
  8         22  
209 8 50       17 return if !$maxkb;
210              
211 8 100 66     11 if (ref(${$value_ref}) eq 'HASH' && ref($maxkb) eq 'HASH') {
  8 50 33     27  
212 4         12 for my $maxkb_key (keys %$maxkb) {
213 4         8 my $maxkb_val = $maxkb->{$maxkb_key};
214 4 50       6 next unless ${$value_ref}->{$maxkb_key};
  4         9  
215              
216 4         8 $self->_truncate_value(\${$value_ref}->{$maxkb_key}, $maxkb_val)
  4         11  
217             }
218             }
219             elsif (!ref(${$value_ref}) && !ref($maxkb)) {
220 4         5 my $len = length ${$value_ref};
  4         8  
221 4         10 my $maxb = $maxkb * 1024;
222              
223 4 100       14 return if $len < $maxb;
224              
225 2         6 my $trunc_marker = "...[truncated, was $len chars total]...";
226 2         6 substr(${$value_ref}, $maxb - length($trunc_marker)) = $trunc_marker;
  2         25  
227             }
228             }
229              
230              
231             sub render {
232 50     50 0 51738 my($self, $message, $category, $priority, $caller_level) = @_;
233              
234             # increment caller_level to resolve caller appropriately
235 50         90 $caller_level++;
236              
237 50         78 my $m = '';
238 50         78 my @data = ();
239              
240             # Will receive array ref if "warp_message" appender option is set to "0"
241 50 100 66     240 if ( ref($message) eq 'ARRAY' and @{$message} > 1 and @{$message} % 2 == 0 ) {
  3 100 100     14  
  3         13  
242 2         3 @data = @{$message};
  2         5  
243 2         5 $m = 'WARP_MESSAGE_0';
244             }
245             elsif ( ref($message) eq 'ARRAY' ) {
246 1         2 @data = @{$message};
  1         3  
247 1         3 $m = shift @data;
248             }
249             else {
250 47         79 $m = $message;
251             }
252              
253 50         118 my $layed_out_fields = $self->_process_field_values($m, $category, $priority, $caller_level);
254              
255 50         1194 my @fields = (
256             %$layed_out_fields,
257             @data, # append extra fields but before mdc
258             $self->mdc_handler->($self) # MDC fields override non-MDC fields (not sure if this is a feature)
259             );
260              
261             # might need to remove dummy message
262 50 100       147 if ( $m eq 'WARP_MESSAGE_0' ) {
263 2         6 for ( my $i = 0; $i < $#fields; $i += 2 ) {
264 3 100       9 if ( $fields[$i] eq 'message' ) {
265 2         4 splice( @fields, $i, 2 );
266 2         6 last;
267             }
268             }
269             }
270              
271 50 100       820 if (my $maxkb = $self->maxkb) {
272 4         28 for ( my $i = 0; $i < $#fields; $i += 2) {
273 4         9 my($k, $v_ref) = ( $fields[$i], \$fields[$i+1] );
274              
275 4         9 my $field_maxkb = $maxkb->{$k};
276 4 50       27 next unless $field_maxkb;
277              
278 4         11 $self->_truncate_value($v_ref, $field_maxkb);
279             }
280             }
281              
282 50         950 my $max_json_length = $self->max_json_length_kb * 1024;
283 50         317 my @dropped;
284             my $json;
285              
286             RETRY: {
287              
288             # MDC items might contain refs that cause encode to croak
289             # or the JSON might be too long
290             # so we fall-back to include progressively less data data
291 56         114 eval {
  56         105  
292 56         934 $json = $self->codec->encode(+{ @fields });
293              
294 56 100       990 die sprintf "length %d > %d\n", length($json), $max_json_length
295             if length($json) > $max_json_length;
296             };
297 56 100       128 if ($@) {
298 6         12 chomp $@;
299 6         9 my $encode_error = $@;
300              
301             # first look for any top-level field that's more than half of max_json_length
302             # for non-ref values truncate the string and add some explanatory text
303             # for ref values replace with undef
304             # this should catch most cases of an individual field that's too big
305 6         8 my @truncated;
306 6         18 for my $i (0 .. @fields/2) {
307 45         90 my ($k, $v) = ($fields[$i], $fields[$i+1]);
308              
309             # we use eval here to protect against fatal encoding errors
310             # (they'll get dealt with by the field pruning below)
311 45         55 my $len;
312 45 50       72 if (ref $v) {
313 0         0 my $encoded = eval { $self->codec->encode(+{ $k => $v }) };
  0         0  
314 0 0       0 if (not defined $encoded) {
315 0         0 $fields[$i+1] = undef;
316 0         0 push @truncated, sprintf "%s %s set to undef after encoding error (%s)", $k, ref($v), $@;
317 0         0 next;
318             }
319 0         0 $len = length $encoded;
320             }
321             else {
322 45   50     83 $len = length($v) || 0;
323             }
324 45 100       95 next if $len <= $max_json_length/2;
325              
326 3 50       8 if (ref $v) {
327 0         0 $fields[$i+1] = undef;
328 0         0 push @truncated, sprintf "truncated %s %s from %d to undef", $k, ref($v), $len;
329             }
330             else {
331 3         8 my $trunc_marker = sprintf("...[truncated, was %d chars total]...", $len);
332 3         10 substr($fields[$i+1], ($max_json_length/2) - length($trunc_marker)) = $trunc_marker;
333 3         13 push @truncated, sprintf "truncated %s from %d to %d", $k, $len, length($fields[$i+1]);
334             }
335             }
336              
337 6         10 my $msg;
338 6 100       13 if (@truncated) {
339 2         6 $msg = join(", ", @truncated).", retrying";
340             }
341             else {
342 4         9 my ($name) = splice @fields, -2;
343 4         7 push @dropped, $name;
344 4         12 $msg = "retrying without ".join(", ", @dropped);
345             }
346              
347             # TODO get smarter here, especially if name_for_mdc is being used.
348             #
349             # Could encode each field and order by size then discard from top down.
350             # Note: if we edit any refs we'd need to edit clones
351             # If the 'message' field itself is > $max_json_length/2 then truncate
352             # the message to $max_json_length/2 first so we don't loose all the context data.
353             # Add an extra field to indicate truncation has happened?
354              
355              
356 6         22 $last_render_error = sprintf "Error encoding %s: %s (%s)",
357             ref($self), $encode_error, $msg;
358             # avoid warn due to recursion risk
359 6         22 print STDERR "$last_render_error\n";
360              
361 6 50       84 goto RETRY if @fields;
362             }
363             }
364              
365 50         844 my $prefix = $self->prefix;
366              
367 50 100       962 if ($self->format_prefix) {
368 6         113 return $self->_prefix_layout->render($message, $category, $priority, $caller_level) . $json . "\n";
369             }
370             else {
371 44         859 return $self->prefix . $json . "\n";
372             }
373             }
374              
375             1;
376              
377             __END__
378              
379             =pod
380              
381             =encoding UTF-8
382              
383             =head1 NAME
384              
385             Log::Log4perl::Layout::JSON - Layout a log message as a JSON hash, including MDC data
386              
387             =head1 VERSION
388              
389             version 0.60
390              
391             =head1 SYNOPSIS
392              
393             Example configuration:
394              
395             log4perl.appender.Example.layout = Log::Log4perl::Layout::JSON
396             log4perl.appender.Example.layout.field.message = %m{chomp}
397             log4perl.appender.Example.layout.field.category = %c
398             log4perl.appender.Example.layout.field.class = %C
399             log4perl.appender.Example.layout.field.file = %F{1}
400             log4perl.appender.Example.layout.field.sub = %M{1}
401             log4perl.appender.Example.layout.include_mdc = 1
402              
403             # Optional truncation of specific fields
404             log4perl.appender.Example.layout.maxkb.message = 2
405              
406             # Note: Appender option!
407             # log4perl.appender.Example.warp_message = 0
408              
409             See below for more configuration options.
410              
411             =head1 DESCRIPTION
412              
413             This class implements a C<Log::Log4perl> layout format, similar to
414             L<Log::Log4perl::Layout::PatternLayout> except that the output is a JSON hash.
415              
416             The JSON hash is ASCII encoded, with no newlines or other whitespace, and is
417             suitable for output, via Log::Log4perl appenders, to files and syslog etc.
418              
419             Contextual data in the L<Log::Log4perl::MDC> hash will be included if
420             L</include_mdc> is true.
421              
422             =head1 LAYOUT CONFIGURATION
423              
424             =head2 field
425              
426             Specify one or more fields to include in the JSON hash. The value is a string
427             containing one of more L<Log::Log4perl::Layout::PatternLayout> placeholders.
428             For example:
429              
430             log4perl.appender.Example.layout.field.message = %m{chomp}
431             log4perl.appender.Example.layout.field.category = %c
432             log4perl.appender.Example.layout.field.where = %F{1}:%L
433              
434             If no fields are specified, the default is C<message = %m{chomp}>.
435             It is recommended that C<message> be the first field.
436              
437             =head2 prefix
438              
439             Specify a prefix string for the JSON. For example:
440              
441             log4perl.appender.Example.layout.prefix = @cee:
442              
443             See http://blog.gerhards.net/2012/03/cee-enhanced-syslog-defined.html
444              
445             =head2 format_prefix
446              
447             If this is turned on, the prefix is treated as a
448             L<Log::Log4perl::Layout::PatternLayout> string, and will be rendered as a
449             pattern layout.
450              
451             For example:
452              
453             log4perl.appender.Example.layout.prefix = %m{chomp} @cee:
454             log4perl.appender.Example.layout.format_prefix = 1
455              
456             Would log C<Hello World> as:
457              
458             Hello World @cee:{ .. MDC as JSON ... }
459              
460             See also L</prefix>
461              
462             =head2 exclude_message
463              
464             Exclude the message from the JSON (default: 0). If you are logging the message
465             in the prefix for example, you may want to omit the message from the JSON
466             layout.
467              
468             =head2 include_mdc
469              
470             Include the data in the Log::Log4perl::MDC hash.
471              
472             log4perl.appender.Example.layout.include_mdc = 1
473              
474             See also L</name_for_mdc>.
475              
476             =head2 maxkb
477              
478             Use this name with the field name to specify a maximum length for a specific
479             field. For example:
480              
481             log4perl.appender.Example.maxkb.message = 2
482              
483             Will truncate message if it is more than 2048 bytes in length. Truncated
484             message will have a marker at the end like
485             C<...[truncated, was $len chars total]...>
486              
487             =head2 name_for_mdc
488              
489             Use this name as the key in the JSON hash for the contents of MDC data
490              
491             log4perl.appender.Example.layout.name_for_mdc = mdc
492              
493             If not set then MDC data is placed at top level of the hash.
494              
495             Where MDC field names match the names of fields defined by the Log4perl
496             configuration then the MDC values take precedence. This is currently construde
497             as a feature.
498              
499             =head2 canonical
500              
501             If true then use canonical order for hash keys when encoding the JSON.
502              
503             log4perl.appender.Example.layout.canonical = 1
504              
505             This is mainly intended for testing.
506              
507             =head2 max_json_length_kb
508              
509             Set the maximum JSON length in kilobytes. The default is 20KB.
510              
511             log4perl.appender.Example.layout.max_json_length_kb = 3.8
512              
513             This is useful where some downstream system has a limit on the maximum size of
514             a message.
515              
516             For example, rsyslog has a C<maxMessageSize> configuration parameter with a
517             default of 4KB. Longer messages are simply truncated (which would corrupt the
518             JSON). We use rsyslog with maxMessageSize set to 128KB.
519              
520             If the JSON is larger than the specified size (not including L</prefix>)
521             then some action is performed to reduce the size of the JSON.
522              
523             Currently fields are simply removed until the JSON is within the size.
524             The MDC field/fields are removed first and then the fields specified in the
525             Log4perl config, in reverse order. A message is printed on C<STDERR> for each
526             field removed.
527              
528             In future this rather dumb logic will be replaced by something smarter.
529              
530             =head2 utf8
531              
532             Switch JSON encoding from ASCII to UTF-8.
533              
534             =head2 warp_message = 0
535              
536             The C<warp_message> B<appender option> is used to specify the desired behavior
537             for handling log calls with multiple arguments.
538             The default behaviour (C<warp_message> not set>) is to concatenate all
539             arguments using C<join( $Log::Log4perl::JOIN_MSG_ARRAY_CHAR, @log_args )> and
540             setting a JSON field C<message> to this simple string.
541              
542             If, on the other hand, C<warp_message = 0> is applied, then for log calls with
543             multiple arguments these are considered name/value pairs and rendered to a
544             hash-like JSON structure.
545             For log calls with an odd number of arguments (3 or more), the first argument
546             is considered the C<message> and the others are again considered
547             name/value pairs.
548              
549             See L<Log::Log4perl::Appender/Appenders Expecting Message Chunks> for more info
550             on the configuration option.
551              
552             =head2 EXAMPLE USING Log::Log4perl::MDC
553              
554             local Log::Log4perl::MDC->get_context->{request} = {
555             request_uri => $req->request_uri,
556             query_parameters => $req->query_parameters
557             };
558              
559             # ...
560              
561             for my $id (@list_of_ids) {
562              
563             local Log::Log4perl::MDC->get_context->{id} = $id;
564              
565             do_something_useful($id);
566              
567             }
568              
569             Using code like that shown above, any log messages produced by
570             do_something_useful() will automatically include the 'contextual data',
571             showing the request URI, the hash of decoded query parameters, and the current
572             value of $id.
573              
574             If there's a C<$SIG{__WARN__}> handler setup to log warnings via C<Log::Log4perl>
575             then any warnings from perl, such as uninitialized values, will also be logged
576             with this context data included.
577              
578             The use of C<local> ensures that contextual data doesn't stay in the MDC
579             beyond the relevant scope. (For more complex cases you could use something like
580             L<Scope::Guard> or simply take care to delete old data.)
581              
582             =head1 HISTORY
583              
584             Originally created and maintained through v0.002003 by Tim Bunce. Versions
585             0.50 and later maintained by Michael Schout <mschout@cpan.org>
586              
587             =for Pod::Coverage BUILD
588             codec
589             mdc_handler
590             render
591              
592             =head1 SOURCE
593              
594             The development version is on github at L<https://https://github.com/mschout/Log-Log4perl-Layout-JSON>
595             and may be cloned from L<git://https://github.com/mschout/Log-Log4perl-Layout-JSON.git>
596              
597             =head1 BUGS
598              
599             Please report any bugs or feature requests on the bugtracker website
600             L<https://github.com/mschout/Log-Log4perl-Layout-JSON/issues>
601              
602             When submitting a bug or request, please include a test-file or a
603             patch to an existing test-file that illustrates the bug or desired
604             feature.
605              
606             =head1 AUTHOR
607              
608             Michael Schout <mschout@cpan.org>
609              
610             =head1 COPYRIGHT AND LICENSE
611              
612             This software is copyright (c) 2014 by Tim Bunce.
613              
614             This is free software; you can redistribute it and/or modify it under
615             the same terms as the Perl 5 programming language system itself.
616              
617             =cut