File Coverage

blib/lib/Log/Log4perl/Layout/JSON.pm
Criterion Covered Total %
statement 175 186 94.0
branch 62 78 79.4
condition 25 36 69.4
subroutine 18 18 100.0
pod 0 2 0.0
total 280 320 87.5


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