File Coverage

blib/lib/EntityModel/Log.pm
Criterion Covered Total %
statement 124 209 59.3
branch 35 86 40.7
condition 10 39 25.6
subroutine 29 44 65.9
pod 25 25 100.0
total 223 403 55.3


line stmt bran cond sub pod time code
1             package EntityModel::Log;
2             # ABSTRACT: Logging class used by EntityModel
3 1     1   24351 use strict;
  1         3  
  1         49  
4 1     1   6 use warnings;
  1         2  
  1         36  
5 1     1   928 use parent qw{Exporter};
  1         449  
  1         6  
6              
7             our $VERSION = '0.006';
8              
9             =head1 NAME
10              
11             EntityModel::Log - simple logging support for L
12              
13             =head1 VERSION
14              
15             version 0.006
16              
17             =head1 SYNOPSIS
18              
19             use EntityModel::Log ':all';
20             # Log everything down to level 0 (debug)
21             EntityModel::Log->instance->min_level(0);
22              
23             # STDERR by default, or Test::More::note if you have it loaded
24             logDebug("Test something");
25             logInfo("Object [%s] found", $obj->name);
26             logError("Fatal problem");
27             logInfo(sub { my $str = heavy_operation(); return 'Failed: %s', $str });
28              
29             logInfo("Stack trace - note that it must have at least one parameter (%s): %S", 'like this');
30             logInfo("No stack trace without parameters despite %S");
31              
32             my $log = EntityModel::Log->instance;
33             $log->debug("OO-style debug");
34             $log->info("OO-style info");
35             $log->warning("OO-style warning");
36             $log->error("OO-style error");
37              
38             =head1 DESCRIPTION
39              
40             Yet another logging class. Provides a procedural and OO interface as usual - intended for use
41             with L only, if you're looking for a general logging framework try one of the
42             other options in the L section.
43              
44             =cut
45              
46             # Need to be able to switch off logging in UNITCHECK stages, since that segfaults perl5.10.1 and possibly other versions
47             our $DISABLE = 0;
48              
49 1     1   1147 use Time::HiRes qw{time};
  1         2006  
  1         5  
50 1     1   1077 use POSIX qw{strftime};
  1         7823  
  1         8  
51 1     1   1222 use Exporter;
  1         2  
  1         41  
52 1     1   6 use List::Util qw{min max};
  1         10  
  1         127  
53 1     1   7 use Scalar::Util qw{blessed};
  1         2  
  1         108  
54 1     1   1296 use IO::Handle;
  1         9707  
  1         51  
55 1     1   20 use File::Basename ();
  1         1  
  1         17  
56 1     1   925 use Data::Dump ();
  1         9455  
  1         30  
57 1     1   983 use Data::Dump::Filtered ();
  1         379  
  1         2902  
58              
59             our %EXPORT_TAGS = ( 'all' => [qw/&logDebug &logInfo &logWarning &logError/] );
60             our @EXPORT_OK = ( @{$EXPORT_TAGS{'all'}} );
61              
62             # Internal singleton instance
63             my $instance;
64              
65             =head2 instance
66              
67             Returns a handle to the main instance of L.
68              
69             =cut
70              
71 4   66 4 1 1606 sub instance { my $class = shift; $instance ||= $class->new }
  4         29  
72              
73             =head1 PROCEDURAL METHODS
74              
75             =cut
76              
77             my @LogType = (
78             'Debug',
79             'Info',
80             'Warning',
81             'Error',
82             'Fatal',
83             );
84              
85             =head2 _raise_error_on_global_instance
86              
87             Raise the given (code, message, ...) log event on the L global instance.
88              
89             =cut
90              
91 2     2   9 sub _raise_error_on_global_instance { __PACKAGE__->instance->raise(@_); }
92              
93             =head2 logDebug
94              
95             Raise a debug message. Expect a high volume of these during normal operation
96             so a production server would typically have these disabled.
97              
98             =cut
99              
100 1     1 1 513 sub logDebug { unshift @_, 0; goto &_raise_error_on_global_instance; }
  1         6  
101              
102             =head2 logInfo
103              
104             Raise an informational message, which we'd like to track for stats
105             reasons - indicates normal operations rather than an error condition.
106              
107             =cut
108              
109 0     0 1 0 sub logInfo { unshift @_, 1; goto &_raise_error_on_global_instance; }
  0         0  
110              
111             =head2 logWarning
112              
113             Raise a warning message, for things like 'requested delete for object that does not exist'.
114             You might expect a few of these in regular operations due to concurrent access and timing issues,
115             so they may not necessarily indicate real system problems.
116              
117             =cut
118              
119 0     0 1 0 sub logWarning { unshift @_, 2; goto &_raise_error_on_global_instance; }
  0         0  
120              
121             =head2 logError
122              
123             Raise an error - this is likely to be a genuine system problem.
124              
125             =cut
126              
127 1     1 1 2 sub logError { unshift @_, 3; goto &_raise_error_on_global_instance; }
  1         4  
128              
129             =head2 logStack
130              
131             Raise an error with stack - this is likely to be a genuine system problem.
132              
133             =cut
134              
135             sub logStack {
136 0     0 1 0 my $txt = __PACKAGE__->instance->parse_message(@_);
137              
138 0         0 $txt .= join("\n", map {
139 0         0 sprintf("%s:%s %s", $_->{filename}, $_->{line}, $_->{subroutine})
140             } _stack_trace());
141 0         0 _raise_error_on_global_instance(3, $txt);
142             }
143              
144             =head2 _stack_trace
145              
146             Get a stack trace, as an array of hashref entries, skipping the top two levels.
147              
148             =cut
149              
150             sub _stack_trace {
151 0   0 0   0 my $skip = shift || 0;
152 0   0     0 my $dump = shift || 0;
153 0         0 my $idx = 1;
154 0         0 my @trace;
155 0         0 my $pkg = __PACKAGE__;
156             {
157 0         0 package DB;
158 0   0     0 while($idx < 99 && (my @stack = caller($idx))) {
159 0         0 ++$idx;
160 0 0       0 next if $skip-- > 0;
161              
162 0         0 my %info;
163 0   0     0 @info{qw/package filename line subroutine hasargs wantarray evaltext is_require hints bitmask hinthash/} = map $_ // '', @stack;
164 0         0 $info{args} = [ @DB::args ];
165              
166             # TODO not happy with this. maybe switch to ->isa?
167 0 0       0 push @trace, \%info unless $info{package} eq $pkg;
168             }
169             }
170              
171 0         0 foreach my $info (@trace) {
172 0         0 $info->{file} = File::Basename::basename($info->{filename});
173 0         0 $info->{code} = '';
174 0 0       0 if($dump) { # could include source context using something like $info{filename} =~ m{^$basePath/(.*)$} || $info{filename} =~ m{^/perl-module-path/(.*)$}) {
175             # I'm hoping this entire function can be replaced by a module from somewhere
176 0 0       0 if(-r $info->{filename}) {
177             # Start from five lines before the required line, but clamp to zero
178 0   0     0 my $start = max(0, ($info->{line} // 0) - 5);
179              
180             # Probably not a safe thing to do, but most modules seem to be ascii or utf8
181 0 0       0 open my $fh, '<:encoding(utf8)', $info->{filename} or die $! . ' when reading ' . $info->{filename} . ' which we expected to have loaded already';
182              
183 0 0       0 if($start) {
184 0         0 <$fh> for 1..$start;
185             }
186 0         0 my $line = $start;
187 0   0     0 $info->{code} .= sprintf("%5d %s", $line++, scalar(<$fh> // last)) for 0..10;
188 0         0 close $fh;
189             }
190             }
191             }
192 0         0 return @trace;
193             }
194              
195             =head2 _level_from_string
196              
197             Returns the level matching the given string.
198              
199             =cut
200              
201             sub _level_from_string {
202 0     0   0 my $str = lc(shift);
203 0         0 my $idx = 0;
204 0         0 foreach (@LogType) {
205 0 0       0 return $idx if $str eq lc($_);
206 0         0 ++$idx;
207             }
208 0         0 die "Bad log level [$str]";
209             }
210              
211             =head2 _timestamp
212              
213             Generate a string in ISO8601-ish format representing the time of this log event.
214              
215             =cut
216              
217             sub _timestamp {
218 2     2   9 my $now = Time::HiRes::time;
219 2         312 return strftime("%Y-%m-%d %H:%M:%S", gmtime($now)) . sprintf(".%03d", int($now * 1000.0) % 1000.0);
220             }
221              
222             =head2 OO METHODS
223              
224             =cut
225              
226             =head2 new
227              
228             Constructor - currently doesn't do much.
229              
230             =cut
231              
232 1     1 1 25 sub new { bless { handle => undef, is_open => 1, pid => $$ }, shift }
233              
234             =head2 debug
235              
236             Display a debug message.
237              
238             =cut
239              
240 0     0 1 0 sub debug { shift->raise(0, @_) }
241              
242             =head2 info
243              
244             Display an info message.
245              
246             =cut
247              
248 0     0 1 0 sub info { shift->raise(1, @_) }
249              
250             =head2 warning
251              
252             Display a warning message.
253              
254             =cut
255              
256 0     0 1 0 sub warning { shift->raise(2, @_) }
257              
258             =head2 error
259              
260             Display an error message.
261              
262             =cut
263              
264 0     0 1 0 sub error { shift->raise(3, @_) }
265              
266             =head2 path
267              
268             Accessor for path setting, if given a new path will close existing file and direct all new output to the given path.
269              
270             =cut
271              
272             sub path {
273 0     0 1 0 my $self = shift;
274 0 0       0 if(@_) {
275 0 0       0 $self->close if $self->is_open;
276 0         0 $self->{path} = shift;
277 0         0 $self->open;
278 0         0 return $self;
279             }
280 0         0 return $self->{path};
281             }
282              
283             =head2 pid
284              
285             Current PID, used for fork tracking.
286              
287             =cut
288              
289             sub pid {
290 2     2 1 3 my $self = shift;
291 2 100       5 if(@_) {
292 1         3 $self->{pid} = shift;
293 1         2 return $self;
294             }
295 1         6 return $self->{pid};
296             }
297              
298             =head2 is_open
299              
300             Returns true if our log file is already open.
301              
302             =cut
303              
304             sub is_open {
305 7     7 1 11 my $self = shift;
306 7 100       19 if(@_) {
307 3         5 $self->{is_open} = shift;
308 3         5 return $self;
309             }
310 4         37 return $self->{is_open};
311             }
312              
313             =head2 disabled
314              
315             Returns true if we're running disabled.
316             =cut
317              
318             sub disabled {
319 2     2 1 3 my $self = shift;
320 2 50       7 if(@_) {
321 0         0 $self->{disabled} = shift;
322 0         0 return $self;
323             }
324 2         8 return $self->{disabled};
325             }
326              
327             =head2 close
328              
329             Close the log file if it's currently open.
330              
331             =cut
332              
333             sub close : method {
334 2     2 1 3 my $self = shift;
335 2 50       9 return $self unless $self->is_open;
336              
337 2 100       8 if(my $h = delete $self->{handle}) {
338 1 50       12 $h->close or die "Failed to close log file: $!\n";
339             }
340 2         17 $self->is_open(0);
341 2         9 return $self;
342             }
343              
344             =head2 close_after_fork
345              
346             Close any active handle if we've forked. This method just does the closing, not the check for $$.
347              
348             =cut
349              
350             sub close_after_fork {
351 0     0 1 0 my $self = shift;
352 0 0       0 return unless $self->is_open;
353              
354             # Don't close STDOUT/STDERR. Bit of a hack really, we should perhaps just close when we were given a path?
355 0 0 0     0 return if $self->handle == \*STDERR || $self->handle == \*STDOUT;
356 0         0 $self->close;
357 0         0 return $self;
358             }
359              
360             =head2 open
361              
362             Open the logfile.
363              
364             =cut
365              
366             sub open : method {
367 0     0 1 0 my $self = shift;
368 0 0       0 return $self if $self->is_open;
369 0 0       0 open my $fh, '>>', $self->path or die $! . " for " . $self->path;
370 0         0 binmode $fh, ':encoding(utf-8)';
371 0         0 $fh->autoflush(1);
372 0         0 $self->{handle} = $fh;
373 0         0 $self->is_open(1);
374 0         0 $self->pid($$);
375 0         0 return $self;
376             }
377              
378             =head2 reopen
379              
380             Helper method to close and reopen logfile.
381              
382             =cut
383              
384             sub reopen {
385 0     0 1 0 my $self = shift;
386 0 0       0 $self->close if $self->is_open;
387 0         0 $self->open;
388 0         0 return $self;
389             }
390              
391             =head2 parse_message
392              
393             Generate appropriate text based on whatever we get passed.
394              
395             Each item in the parameter list is parsed first, then the resulting items are passed through L. If only a single item is in the list then the resulting string is returned directly.
396              
397             Item parsing handles the following types:
398              
399             =over 4
400              
401             =item * Single string is passed through unchanged
402              
403             =item * Arrayref or hashref is expanded via L
404              
405             =item * Other references are stringified
406              
407             =item * Undef items are replaced with the text 'undef'
408              
409             =back
410              
411             In addition, if the first parameter is a coderef then it is expanded in place (recursively - a coderef can return another coderef). Note that this only happens for the *first* parameter at each
412             level of recursion.
413              
414             =cut
415              
416             sub parse_message {
417 8     8 1 3384 my $self = shift;
418 8 50       22 return '' unless @_;
419              
420 8   66     74 unshift @_, $_[0]->() while $_[0] && ref($_[0]) eq 'CODE';
421              
422             # Decompose parameters into strings
423 8         33 my @data;
424             ITEM:
425 8         16 while(@_) {
426 21         29 my $entry = shift;
427              
428             # Convert to string if we can
429 21 100       49 if(my $ref = ref $entry) {
430 6 100 33     31 if($ref =~ /^CODE/) {
    50          
431 5         13 unshift @_, $entry->();
432 5         33 next ITEM;
433             } elsif($ref eq 'ARRAY' or $ref eq 'HASH') {
434 1         7 $entry = Data::Dump::dump($entry);
435             } else {
436 0         0 $entry = "$entry";
437             }
438             }
439 16   50     263 $entry //= 'undef';
440 16         42 push @data, $entry;
441             }
442              
443             # Format appropriately
444 8   50     20 my $fmt = shift(@data) // '';
445 8 100       30 return $fmt unless @data;
446              
447             # Special-case the stack trace feature. A bit too special really :(
448 5         10 $fmt =~ s/%S/join("\n", '', map {
  0         0  
449 0         0 _stack_line($_)
450             } _stack_trace(0, 1))/e;
451 5 50       11 die "Format undef" unless defined $fmt;
452 5 50       10 die "Undefined entry in data, others are " . join ', ', map { defined($_) } @data if grep { !defined($_) } @data;
  0         0  
  8         24  
453 5         33 return sprintf($fmt, @data);
454             }
455              
456             sub _stack_line {
457 0     0   0 my $info = shift;
458             my $txt = sprintf ' => %-32.32s %s(%s) args %s',
459             $info->{package} . ':' . $info->{line},
460             ($info->{subroutine} =~ m{ ( [^:]+$ ) }x),
461             ($info->{package} eq 'EntityModel::Log')
462             ? ('')
463             : (join ', ', map Data::Dump::Filtered::dump_filtered($info, sub {
464 0     0   0 my ($ctx, $obj) = @_;
465 0 0       0 return undef unless $ctx->is_blessed;
466 0         0 return { dump => "$obj" };
467 0 0 0     0 })), join ' ', map $_ // '', @{ $info->{args} };
  0         0  
468 0         0 $txt =~ s{%}{%%}g;
469 0         0 return $txt;
470             }
471              
472             =head2 min_level
473              
474             Accessor for the current minimum logging level. Values correspond to:
475              
476             =over 4
477              
478             =item * 0 - Debug
479              
480             =item * 1 - Info
481              
482             =item * 2 - Warning
483              
484             =item * 3 - Error
485              
486             =item * 4 - Fatal
487              
488             =back
489              
490             Returns $self when setting a value, otherwise the current value is returned.
491              
492             =cut
493              
494             sub min_level {
495 1     1 1 2 my $self = shift;
496 1 50       6 if(@_) {
497 1         4 $self->{min_level} = shift;
498 1         4 return $self;
499             }
500 0         0 return $self->{min_level};
501             }
502              
503             =head2 raise
504              
505             Raise a log message
506              
507             =over 4
508              
509             =item * $level - numeric log level
510              
511             =item * @data - message data
512              
513             =back
514              
515             =cut
516              
517             sub raise {
518 2 50   2 1 11 return $_[0] if $_[0]->disabled;
519              
520 2         4 my $self = shift;
521 2         2 my $level = shift;
522 2         15 my ($pkg, $file, $line, $sub) = caller(1);
523              
524             # caller(0) gives us the wrong sub for our purposes - we want whatever raised the logXX line
525 2         6 (undef, undef, undef, $sub) = caller(2);
526              
527             # Apply minimum log level based on method, then class, then default 'info'
528 2 50 33     28 my $minLevel = ($sub ? $self->{mask}{$sub}{level} : undef)
      33        
      50        
529             // $self->{mask}{$pkg}{level}
530             // $self->{min_level}
531             // 1;
532 2 50       6 return $self if $minLevel > $level;
533              
534 2         7 my $txt = $self->parse_message(@_);
535              
536             # Explicitly get time from Time::HiRes for ms accuracy
537 2         6 my $ts = _timestamp();
538              
539 2         9 my $type = sprintf("%-8.8s", $LogType[$level]);
540 2         15 $self->output("$ts $type $file:$line $txt");
541 2         8 return $self;
542             }
543              
544             =head2 output
545              
546             Sends output to the current filehandle.
547              
548             =cut
549              
550             sub output {
551 2     2 1 4 my $self = shift;
552 2         4 my $msg = shift;
553 2 100       23 if(my $handle = $self->get_handle) {
554 1         12 $handle->print($msg . "\n");
555 1         10 return $self;
556             }
557              
558 1         5 Test::More::note($msg);
559 1         289 return $self;
560             }
561              
562             =head2 get_handle
563              
564             Returns a handle if we have one, and 0 if we should fall back to L::note.
565              
566             =cut
567              
568             sub get_handle {
569 2     2 1 5 my $self = shift;
570             # Fall back to Test::More if available, unless we already have a handle
571 2 100       7 if(!$self->{handle}) {
572 1 50       9 return 0 if $ENV{HARNESS_ACTIVE};
573             # Exists, but undef, means STDERR fallback
574 0 0       0 return \*STDERR if exists $self->{handle};
575             }
576              
577 1 50       3 $self->close_after_fork unless $$ == $self->pid;
578              
579 1 50       6 $self->open unless $self->is_open;
580 1         4 return $self->handle;
581             }
582              
583             =head2 handle
584              
585             Direct(-ish) accessor for the file handle.
586              
587             =cut
588              
589             sub handle {
590 2     2 1 5 my $self = shift;
591 2 100       7 if(@_) {
592 1 50       5 $self->close if $self->is_open;
593 1         3 $self->{handle} = shift;
594 1         2 $self->is_open(1);
595 1         5 $self->pid($$);
596 1         2 return $self;
597             }
598 1 50       5 $self->reopen unless $self->{handle};
599 1         4 return $self->{handle};
600             }
601              
602 1 50   1   848 END { $instance->close if $instance; }
603              
604             1;
605              
606             __END__