log_event: structured event logs
The documentation in this commit is probably what you want to read, but:
this adds ->log_event and ->log_debug_event methods to log structured
data in a form of the compact, human-readable-ish logfmt format, with
automatic expansion of nested structures.
It adds "proxy_ctx" for data to be logged on every line from a proxy
logger, acting like the free-form "proxy_prefix" argument, but with
logfmt data.
Ricardo Signes
1 year, 6 months ago
33 | 33 | logger => $arg->{logger}, |
34 | 34 | debug => $arg->{debug}, |
35 | 35 | proxy_prefix => $arg->{proxy_prefix}, |
36 | proxy_ctx => $arg->{proxy_ctx}, | |
36 | 37 | }; |
37 | 38 | |
38 | 39 | bless $guts => $class; |
42 | 43 | my ($self, $arg) = @_; |
43 | 44 | $arg ||= {}; |
44 | 45 | |
45 | (ref $self)->_new({ | |
46 | my @proxy_ctx = ($self->{proxy_ctx} // [])->@*; | |
47 | ||
48 | if (my $ctx = $arg->{proxy_ctx}) { | |
49 | @proxy_ctx = _ARRAY0($ctx) | |
50 | ? (@proxy_ctx, @$ctx) | |
51 | : (@proxy_ctx, $ctx->%{ sort keys %$ctx }); | |
52 | } | |
53 | ||
54 | my $prox = (ref $self)->_new({ | |
46 | 55 | parent => $self, |
47 | 56 | logger => $self->logger, |
48 | 57 | debug => $arg->{debug}, |
49 | 58 | muted => $arg->{muted}, |
50 | 59 | proxy_prefix => $arg->{proxy_prefix}, |
60 | proxy_ctx => \@proxy_ctx, | |
51 | 61 | }); |
52 | 62 | } |
53 | 63 | |
125 | 135 | $self->log($arg, @rest); |
126 | 136 | } |
127 | 137 | |
138 | sub log_event { | |
139 | my ($self, $event, $data) = @_; | |
140 | ||
141 | return if $self->get_muted; | |
142 | ||
143 | my $message = $self->logger->log_event($event, [ | |
144 | ($self->{proxy_ctx} ? $self->{proxy_ctx}->@* : ()), | |
145 | (_ARRAY0($data) ? @$data : $data->%{ sort keys %$data }) | |
146 | ]); | |
147 | } | |
148 | ||
149 | sub log_debug_event { | |
150 | my ($self, $event, $data) = @_; | |
151 | ||
152 | return if $self->get_muted; | |
153 | return unless $self->get_debug; | |
154 | ||
155 | $self->logger->log_event($event, [ | |
156 | ($self->{proxy_ctx} ? $self->{proxy_ctx}->@* : ()), | |
157 | (_ARRAY0($data) ? @$data : $data->%{ sort keys %$data }) | |
158 | ]); | |
159 | } | |
160 | ||
128 | 161 | sub info { shift()->log(@_); } |
129 | 162 | sub fatal { shift()->log_fatal(@_); } |
130 | 163 | sub debug { shift()->log_debug(@_); } |
6 | 6 | use File::Spec (); |
7 | 7 | use Log::Dispatch; |
8 | 8 | use Params::Util qw(_ARRAY0 _HASH0 _CODELIKE); |
9 | use Scalar::Util qw(blessed weaken); | |
9 | use Scalar::Util qw(blessed refaddr weaken); | |
10 | 10 | use String::Flogger; |
11 | 11 | use Try::Tiny 0.04; |
12 | 12 | |
382 | 382 | $self->log($arg, @rest); |
383 | 383 | } |
384 | 384 | |
385 | =method log_event | |
386 | ||
387 | This method is like C<log>, but is used for structured logging instead of free | |
388 | form text. It's invoked like this: | |
389 | ||
390 | $logger->log($event_type => $data_ref); | |
391 | ||
392 | C<$event_type> should be a simple string, probably a valid identifier, that | |
393 | identifies the kind of event being logged. It is suggested, but not required, | |
394 | that all events of the same type have the same kind of structured data in them. | |
395 | ||
396 | C<$data_ref> is a set of key/value pairs of data to log in this event. It can | |
397 | be an arrayref (in which case the ordering of pairs is preserved) or a hashref | |
398 | (in which case they are sorted by key). | |
399 | ||
400 | The logged string will be in logfmt format, meaning a series of key=value | |
401 | pairs separated by spaces and following these rules: | |
402 | ||
403 | =for :list | |
404 | * an "identifier" is a string of printable ASCII characters between C<!> and | |
405 | C<~>, excluding C<\> and C<=> | |
406 | * keys must be valid identifiers | |
407 | * if a key is empty, C<~> is used instead | |
408 | * if a key contains characters not permitted in an identifier, they are | |
409 | replaced by C<?> | |
410 | * values must I<either> be valid identifiers, or be quoted | |
411 | * quoted value start and end with C<">; inside the value, C<"> becomes C<\"> | |
412 | and C<\> becomes C<\\> | |
413 | ||
414 | When values are undef, they are represented as C<~>. | |
415 | ||
416 | When values are array references, the index/values are mapped over, so that: | |
417 | ||
418 | key => [ 'a', 'b' ] | |
419 | ||
420 | becomes | |
421 | ||
422 | key.0=a key.1=b | |
423 | ||
424 | When values are hash references, the key/values are mapped, with keys sorted, | |
425 | so that: | |
426 | ||
427 | key => { b => 2, a => 1 } | |
428 | ||
429 | becomes | |
430 | ||
431 | key.a=1 key.b=2 | |
432 | ||
433 | This expansion is performed recursively. If a value itself recurses, | |
434 | appearances of a reference after the first time will be replaced with a string | |
435 | like C<&foo.bar>, pointing to the first occurrence. I<This is not meant to be | |
436 | a robust serialization mechanism.> It's just here to help you be a little | |
437 | lazy. Don't push the limits. | |
438 | ||
439 | =cut | |
440 | ||
441 | # ASCII after SPACE but excluding = and " | |
442 | my $IDENT_RE = qr{\A[\x21\x23-\x3C\x3E-\x7E]+\z}; | |
443 | ||
444 | sub _pairs_to_kvstr_aref { | |
445 | my ($self, $aref, $seen, $prefix) = @_; | |
446 | ||
447 | $seen //= {}; | |
448 | ||
449 | my @kvstrs; | |
450 | ||
451 | KEY: for (my $i = 0; $i < @$aref; $i += 2) { | |
452 | # replace non-ident-safe chars with ? | |
453 | my $key = length $aref->[$i] ? "$aref->[$i]" : '~'; | |
454 | $key =~ tr/\x21\x23-\x3C\x3E-\x7E/?/c; | |
455 | ||
456 | # If the prefix is "" you can end up with a pair like ".foo=1" which is | |
457 | # weird but probably best. And that means you could end up with | |
458 | # "foo..bar=1" which is also weird, but still probably for the best. | |
459 | $key = "$prefix.$key" if defined $prefix; | |
460 | ||
461 | my $value = $aref->[$i+1]; | |
462 | ||
463 | if (! defined $value) { | |
464 | $value = '~missing~'; | |
465 | } elsif (ref $value) { | |
466 | my $refaddr = refaddr $value; | |
467 | ||
468 | if ($seen->{ $refaddr }) { | |
469 | $value = $seen->{ $refaddr }; | |
470 | } elsif (_ARRAY0($value)) { | |
471 | $seen->{ $refaddr } = "&$key"; | |
472 | ||
473 | push @kvstrs, $self->_pairs_to_kvstr_aref( | |
474 | [ map {; $_ => $value->[$_] } (0 .. $#$value) ], | |
475 | $seen, | |
476 | $key, | |
477 | )->@*; | |
478 | ||
479 | next KEY; | |
480 | } elsif (_HASH0($value)) { | |
481 | $seen->{ $refaddr } = "&$key"; | |
482 | ||
483 | push @kvstrs, $self->_pairs_to_kvstr_aref( | |
484 | [ $value->%{ sort keys %$value } ], | |
485 | $seen, | |
486 | $key, | |
487 | )->@*; | |
488 | ||
489 | next KEY; | |
490 | } else { | |
491 | $value = "$value"; # Meh. | |
492 | } | |
493 | } | |
494 | ||
495 | my $str = "$key=" | |
496 | . ($value =~ $IDENT_RE | |
497 | ? "$value" | |
498 | : (q{"} . ($value =~ s{\\}{\\\\}gr =~ s{"}{\\"}gr) . q{"})); | |
499 | ||
500 | push @kvstrs, $str; | |
501 | } | |
502 | ||
503 | return \@kvstrs; | |
504 | } | |
505 | ||
506 | sub _format_event { | |
507 | my ($self, $aref) = @_; | |
508 | ||
509 | my $kvstr_aref = $self->_pairs_to_kvstr_aref($aref, {}, undef); | |
510 | ||
511 | return join q{ }, @$kvstr_aref; | |
512 | } | |
513 | ||
514 | sub log_event { | |
515 | my ($self, $type, $data) = @_; | |
516 | ||
517 | return if $self->get_muted; | |
518 | ||
519 | my $message = $self->_format_event([ | |
520 | event => $type, | |
521 | (_ARRAY0($data) ? @$data : $data->%{ sort keys %$data }) | |
522 | ]); | |
523 | ||
524 | $self->dispatcher->log( | |
525 | level => 'info', | |
526 | message => $message, | |
527 | ); | |
528 | ||
529 | return; | |
530 | } | |
531 | ||
532 | =method log_debug_event | |
533 | ||
534 | This method is just like C<log_event>, but will log nothing unless the logger | |
535 | has its C<debug> property set to true. | |
536 | ||
537 | =cut | |
538 | ||
539 | sub log_debug_event { | |
540 | my ($self, $type, $data) = @_; | |
541 | ||
542 | return unless $self->get_debug; | |
543 | ||
544 | $self->log_event($type, $data); | |
545 | } | |
546 | ||
385 | 547 | =method set_debug |
386 | 548 | |
387 | 549 | $logger->set_debug($bool); |
615 | 777 | = proxy_prefix |
616 | 778 | This is a prefix that will be applied to anything the proxy logger logs, and |
617 | 779 | cannot be changed. |
780 | = proxy_ctx | |
781 | This is data to be inserted in front of event data logged through the proxy. | |
782 | It will appear I<after> the C<event> key but before the logged event data. It | |
783 | can be in the same format as the C<$data_ref> argument to C<log_event>. At | |
784 | present, the context data is expanded on every logged event, but don't rely on | |
785 | this, it may be optimized, in the future, to only be computed once. | |
618 | 786 | = debug |
619 | 787 | This can be set to true or false to change the proxy's "am I in debug mode?" |
620 | 788 | setting. It can be changed or cleared later on the proxy. |
629 | 797 | my ($self, $arg) = @_; |
630 | 798 | $arg ||= {}; |
631 | 799 | |
632 | $self->proxy_class->_new({ | |
800 | my $proxy = $self->proxy_class->_new({ | |
633 | 801 | parent => $self, |
634 | 802 | logger => $self, |
635 | 803 | proxy_prefix => $arg->{proxy_prefix}, |
636 | 804 | (exists $arg->{debug} ? (debug => ($arg->{debug} ? 1 : 0)) : ()), |
637 | 805 | }); |
806 | ||
807 | if (my $ctx = $arg->{proxy_ctx}) { | |
808 | $proxy->{proxy_ctx} = _ARRAY0($ctx) | |
809 | ? [ @$ctx ] | |
810 | : [ $ctx->%{ sort keys %$ctx } ]; | |
811 | } | |
812 | ||
813 | return $proxy; | |
638 | 814 | } |
639 | 815 | |
640 | 816 | =head2 parent |
0 | use strict; | |
1 | use warnings; | |
2 | ||
3 | use JSON::MaybeXS; | |
4 | use Log::Dispatchouli; | |
5 | use Test::More 0.88; | |
6 | use Test::Deep; | |
7 | ||
8 | sub event_logs_ok { | |
9 | my ($event_type, $data, $line, $desc) = @_; | |
10 | ||
11 | local $Test::Builder::Level = $Test::Builder::Level+1; | |
12 | ||
13 | my $logger = Log::Dispatchouli->new_tester({ | |
14 | log_pid => 0, | |
15 | ident => 't/basic.t', | |
16 | }); | |
17 | ||
18 | $logger->log_event($event_type, $data); | |
19 | ||
20 | messages_ok($logger, [$line], $desc); | |
21 | } | |
22 | ||
23 | sub messages_ok { | |
24 | my ($logger, $lines, $desc) = @_; | |
25 | ||
26 | local $Test::Builder::Level = $Test::Builder::Level+1; | |
27 | ||
28 | my @messages = map {; $_->{message} } $logger->events->@*; | |
29 | ||
30 | my $ok = cmp_deeply( | |
31 | \@messages, | |
32 | $lines, | |
33 | $desc, | |
34 | ); | |
35 | ||
36 | $logger->clear_events; | |
37 | ||
38 | unless ($ok) { | |
39 | diag "GOT: $_" for @messages; | |
40 | } | |
41 | ||
42 | return $ok; | |
43 | } | |
44 | ||
45 | sub logger_trio { | |
46 | my $logger = Log::Dispatchouli->new_tester({ | |
47 | log_pid => 0, | |
48 | ident => 't/basic.t', | |
49 | }); | |
50 | ||
51 | my $proxy1 = $logger->proxy({ proxy_ctx => { 'inner' => 'proxy' } }); | |
52 | my $proxy2 = $proxy1->proxy({ proxy_ctx => { 'outer' => 'proxy' } }); | |
53 | ||
54 | return ($logger, $proxy1, $proxy2); | |
55 | } | |
56 | ||
57 | subtest "very basic stuff" => sub { | |
58 | event_logs_ok( | |
59 | 'world-series' => [ phl => 1, hou => 0, games => [ 'done', 'in-progress' ] ], | |
60 | 'event=world-series phl=1 hou=0 games.0=done games.1=in-progress', | |
61 | "basic data with an arrayref value", | |
62 | ); | |
63 | ||
64 | event_logs_ok( | |
65 | 'programmer-sleepiness' => { | |
66 | weary => 8.62, | |
67 | excited => 3.2, | |
68 | motto => q{Never say "never" ever again.}, | |
69 | }, | |
70 | 'event=programmer-sleepiness excited=3.2 motto="Never say \\"never\\" ever again." weary=8.62', | |
71 | "basic data as a hashref", | |
72 | ); | |
73 | ||
74 | event_logs_ok( | |
75 | 'rich-structure' => [ | |
76 | array => [ | |
77 | { name => [ qw(Ricardo Signes) ], limbs => { arms => 2, legs => 2 } }, | |
78 | [ 2, 4, 6 ], | |
79 | ], | |
80 | ], | |
81 | join(q{ }, qw( | |
82 | event=rich-structure | |
83 | array.0.limbs.arms=2 | |
84 | array.0.limbs.legs=2 | |
85 | array.0.name.0=Ricardo | |
86 | array.0.name.1=Signes | |
87 | array.1.0=2 | |
88 | array.1.1=4 | |
89 | array.1.2=6 | |
90 | )), | |
91 | "a structured nested a few levels", | |
92 | ); | |
93 | ||
94 | event_logs_ok( | |
95 | 'empty-key' => { '' => 'disgusting' }, | |
96 | 'event=empty-key ~=disgusting', | |
97 | "cope with jerks putting empty keys into the data structure", | |
98 | ); | |
99 | ||
100 | event_logs_ok( | |
101 | 'bogus-subkey' => { valid => { 'foo bar' => 'revolting' } }, | |
102 | 'event=bogus-subkey valid.foo?bar=revolting', | |
103 | "cope with bogus key characters in recursion", | |
104 | ); | |
105 | }; | |
106 | ||
107 | subtest "very basic proxy operation" => sub { | |
108 | my ($logger, $proxy1, $proxy2) = logger_trio(); | |
109 | ||
110 | $proxy2->log_event(pie_picnic => [ | |
111 | pies_eaten => 1.2, | |
112 | joy_harvested => 6, | |
113 | ]); | |
114 | ||
115 | messages_ok( | |
116 | $logger, | |
117 | [ | |
118 | 'event=pie_picnic inner=proxy outer=proxy pies_eaten=1.2 joy_harvested=6' | |
119 | ], | |
120 | 'got the expected log output from events', | |
121 | ); | |
122 | }; | |
123 | ||
124 | subtest "debugging in the proxies" => sub { | |
125 | my ($logger, $proxy1, $proxy2) = logger_trio(); | |
126 | ||
127 | $proxy1->set_debug(1); | |
128 | ||
129 | $logger->log_debug_event(0 => [ seq => 0 ]); | |
130 | $proxy1->log_debug_event(1 => [ seq => 1 ]); | |
131 | $proxy2->log_debug_event(2 => [ seq => 2 ]); | |
132 | ||
133 | $proxy2->set_debug(0); | |
134 | ||
135 | $logger->log_debug_event(0 => [ seq => 3 ]); | |
136 | $proxy1->log_debug_event(1 => [ seq => 4 ]); | |
137 | $proxy2->log_debug_event(2 => [ seq => 5 ]); | |
138 | ||
139 | messages_ok( | |
140 | $logger, | |
141 | [ | |
142 | # 'event=0 seq=0', # not logged, debugging | |
143 | 'event=1 inner=proxy seq=1', | |
144 | 'event=2 inner=proxy outer=proxy seq=2', | |
145 | # 'event=0 seq=3', # not logged, debugging | |
146 | 'event=1 inner=proxy seq=4', | |
147 | # 'event=2 inner=proxy outer=proxy seq=5', # not logged, debugging | |
148 | ], | |
149 | 'got the expected log output from events', | |
150 | ); | |
151 | }; | |
152 | ||
153 | # NOT TESTED HERE: "mute" and "unmute", which rjbs believes are probably | |
154 | # broken already. Their tests don't appear to test the important case of "root | |
155 | # logger muted, proxy explicitly unmuted". | |
156 | ||
157 | subtest "recursive structure" => sub { | |
158 | my ($logger, $proxy1, $proxy2) = logger_trio(); | |
159 | ||
160 | my $struct = {}; | |
161 | ||
162 | $struct->{recurse} = $struct; | |
163 | ||
164 | $logger->log_event('recursive-thing' => [ recursive => $struct ]); | |
165 | ||
166 | messages_ok( | |
167 | $logger, | |
168 | [ | |
169 | 'event=recursive-thing recursive.recurse=&recursive', | |
170 | ], | |
171 | "an event with recursive stuff terminates", | |
172 | ); | |
173 | }; | |
174 | ||
175 | subtest "reused JSON booleans" => sub { | |
176 | # It's not that this is extremely special, but we mostly don't want to | |
177 | # recurse into the same reference value multiple times, but we also don't | |
178 | # want the infuriating "reused boolean variable" you get from Dumper. This | |
179 | # is just to make sure I don't accidentally break this case. | |
180 | my ($logger, $proxy1, $proxy2) = logger_trio(); | |
181 | ||
182 | my $struct = { | |
183 | b => [ JSON::MaybeXS::true(), JSON::MaybeXS::false() ], | |
184 | f => [ (JSON::MaybeXS::false()) x 3 ], | |
185 | t => [ (JSON::MaybeXS::true()) x 3 ], | |
186 | }; | |
187 | ||
188 | $logger->log_event('tf-thing' => [ cond => $struct ]); | |
189 | ||
190 | messages_ok( | |
191 | $logger, | |
192 | [ | |
193 | 'event=tf-thing cond.b.0=1 cond.b.1=0 cond.f.0=0 cond.f.1=0 cond.f.2=0 cond.t.0=1 cond.t.1=1 cond.t.2=1', | |
194 | ], | |
195 | "JSON bools do what we expect", | |
196 | ); | |
197 | }; | |
198 | ||
199 | done_testing; |