File Coverage

blib/lib/Log/ProgramInfo.pm
Criterion Covered Total %
statement 174 192 90.6
branch 42 82 51.2
condition 16 24 66.6
subroutine 19 21 90.4
pod 0 9 0.0
total 251 328 76.5


line stmt bran cond sub pod time code
1             package Log::ProgramInfo;
2              
3             =head1 NAME
4              
5             Log::ProgramInfo - log global info from a perl programs.
6              
7             =cut
8              
9             ### HISTORY ###################################################################
10             # Version Date Developer Comments
11             # 0.1.1 2015-04-02 John Macdonald Initial release to CPAN
12             # 0.1.2 2015-04-04 John Macdonald Minor cleanups to initial release
13             # 0.1.3 2015-04-09 John Macdonald Rename s/JobInof/ProgramInfo/
14             # 0.1.4 2015-04-09 John Macdonald Add README
15             # 0.1.5 2015-04-10 John Macdonald Change log extension and env
16             # variable names to reflect
17             # the ProgramInfo name.
18             # 0.1.6 2015-04-23 John Macdonald Fix env name ..._FILE -> ..._NAME
19             # Fix env handling - capture at start
20             # - apply at end
21             # Log username(s) groupname(s)
22             # Include identifying info in separator line
23             # Make output more easily parsable
24             # 0.1.7 2015-05-20 John Macdonald Add user-pluggable log routines
25             # Move log parser from test to module.
26             # 0.1.8 2015-06-12 John Macdonald Catch most signals (not just HUP)
27             #
28              
29             =head1 VERSION
30              
31             Version 0.1.8
32              
33             =cut
34              
35             our $VERSION = '0.1.8';
36              
37 1     1   28680 use feature qw(say);
  1         4  
  1         128  
38 1     1   829 use Data::Dumper;
  1         10879  
  1         100  
39 1     1   635 use FindBin;
  1         1134  
  1         49  
40 1     1   724 use Time::HiRes qw(time);
  1         1901  
  1         5  
41 1     1   1208 use DateTime;
  1         160626  
  1         56  
42 1     1   13 use DateTime::Duration;
  1         1  
  1         35  
43 1     1   6 use Carp qw(carp croak cluck);
  1         2  
  1         105  
44 1     1   8 use Fcntl qw(:flock);
  1         1  
  1         1643  
45              
46              
47             =head1 SYNOPSIS
48              
49             use Log::ProgramInfo qw(
50             [ -logname LOGNAME ]
51             [ -logdir LOGDIR ]
52             [ -logext LOGEXT ]
53             [ -logdate none|date|time|datetime ]
54             [ -stdout ]
55             [ -suppress ]
56             );
57              
58             # main program does lots of stuff...
59             exit 0;
60              
61             After the program has run, this module will automatically
62             log information about this run into a log file. It will
63             list such things as:
64             - program
65             - name
66             - version
67             - command line arguments
68             - version of perl
69             - modules loaded
70             - source code location
71             - Version
72             - run time
73              
74             The log is appended to the file:
75             LOGDIR/LOGDATE-LOGNAME.LOGEXT
76             where
77             LOGDIR defaults to . (the current directory when the program terminates)
78             LOGDATE defaults to the date that the program was started
79             LOGNAME defaults to the basename of the program
80             LOGEXT defaults to ".programinfo"
81              
82             The -ARG specifiers in the "import" list can be used to over-ride these defaults. Specifying:
83              
84             -logname LOGNAME will use LOGNAME instead of the program name
85             -logdir LOGDIR will use LOGDIR instead of the current directory
86             - if it is a relative path, it will be based on the
87             current directory at termination of execution
88             -logext EXT will add .EXT to the log filename
89             -logext .EXT will add .EXT to the log filename
90             -logext "" will add no extension to the log filename
91             -logdate STRING
92             will specify the LOGDATE portion of the filename. The STRING can be:
93             none LOGNAME (and no dash)
94             date YYYYMMDD-LOGNAME (this is the default)
95             time HHMMSS-LOGNAME
96             datetime YYYYMMDDHHMMSS-LOGNAME
97              
98             -stdout will cause the log to be sent to stdout instead of a file
99             -suppress will suppress logging (unless environment variable
100             LOGPROGRAMINFO_SUPPRESS is explcitly set to 0 or null)
101              
102             Normally, neither -suppress nor -stdout will be set in the
103             use statement, and the environment variables can then be
104             used to disable the logging completely or to send it to
105             stdout instead of to the selected file.
106              
107             For some programs, however, it may be desired to not normally
108             provide any logging. Specifying -suppress will accomplish
109             this. In such a case, setting the environment variable
110             LOGPROGRAMINFO_SUPPRESS=0 will over-ride that choice, causing
111             the log to be written (as specified by the other options
112             and environment variables).
113              
114             Environment variables can over-ride these parameters:
115             LOGPROGRAMINFO_SUPPRESS=x boolean suppresses all logging if true
116             LOGPROGRAMINFO_STDOUT=x boolean sets -stdout
117             LOGPROGRAMINFO_DIR=DIR string sets the target directory name
118             LOGPROGRAMINFO_NAME=NAME string sets the target filename LOGNAME
119             LOGPROGRAMINFO_EXT=EXT string sets the target extension
120             LOGPROGRAMINFO_DATE=DATE string sets the target filename LOGDATE selector
121              
122             Adding extra loggable information:
123             If you want to add your own classes of loggable info, there are a
124             few restrictions.
125              
126             You define a logging extension routine using:
127              
128             Log::ProgramInfo::add_extra_logger( \&my_logger );
129              
130             Your logger routine should be defined as:
131              
132             sub my_logger {
133             my $write_entry = shift;
134             $write_entry->( $key1, $value );
135             $write_entry->( $key1, $key2, $value );
136             }
137              
138             The $write_entry function passed to my_logger must be called with
139             2 or 3 arguments. The leading arguments are major (and minor if
140             desired) keys, the final one os the value for the key(s).
141              
142             Try to keep the first key to 7 characters, and the second to 8 to
143             keep the log readable by humans. (It will be parseable even if you
144             use longer keys.)
145              
146             Parsing the log file:
147             The log file is designed to be easily parsed.
148              
149             A log always starts with a line beginning with 8 hash marks in column
150             one (########) plus some identifying info.
151              
152             The value lines are of the form:
153              
154             key : value
155             key1 : key2 : value
156              
157             The first key is extended to at least 7 characters with blanks, the
158             second key (if any) is extended to at least 8 characters. There is
159             always a separator of (space(colon)(space) between a key and the
160             following field. (A key can be provided with leading spaces for making
161             the log more readable by humans - the readlog function in the test suite
162             will remove such spaces.)
163              
164             Two subroutines are available to do this parsing for you:
165              
166             my $firstonly = 0;
167             @logs = readlog( $filepath [, $acceptsub] [, $firstonly] );
168             @logs = parselog( $filehandle [, $acceptsub] [, $firstonly] );
169              
170             $logs = readlog( $filepath [, $acceptsub ], 1 );
171             $logs = parselog( $filehandle [, $acceptsub ] ,1 );
172              
173             The first parameter to each is either a string pathname (for readlog)
174             or an already opened readable file handle (for parselog).
175              
176             If a subroutine reference arg $acceptsub is provided, each log that is
177             read will be passed to that sub reference. If the acceptsub returns
178             true the log is retained, otherwise it is discarded. If a trailing
179             (non-sub-ref) value is provided, it selects whether only the first
180             (acceptable) log found will be returned as a single hash reference, or
181             whether all of the (accepted) logs in the file will be returned as a
182             list of hash references.`
183              
184             The hash reference for each accepted log contains the key/value (or
185             key1 => { key2/value pairs }) from that log.
186              
187             Whenever a key (or key1/key2 pair) is seen multiple times, the value
188             is an array ref instead of a scalar. This only happens with the
189             MODULE pairs (MODULE/NAME, MODULE/LOC, MODULE/VERSION), and the INC
190             key. (User-provided keys are not currently permitted to use the same
191             key set multiple times.)
192              
193             =cut
194              
195             # preserve command line info
196             my @args = @ARGV;
197             my $progbase;
198             my $starttime;
199              
200             my %option;
201              
202             my %valid_dates;
203              
204             my %_omap;
205             my %env_options;
206              
207             my $kill_caught;
208              
209             my ($uid, $gid);
210             my %cache;
211             my %modkeys = ( NAME => 1, VERSION => 1, LOC => 1 );
212              
213             sub readlog {
214 4     4 0 10650619 my $file = shift;
215 4 50       221 open my $fh, '<', $file or die "Cannot open $file: $!";
216 4         35 return parselog( $fh, @_ );
217             }
218              
219             sub parselog {
220 4     4 0 48 my $fh = shift;
221 4         10 my $accept;
222 4 50       19 $accept = shift if ref($_[0]) eq 'CODE';
223 4         11 my $firstonly = shift;
224 4         8 my @logs;
225 4         18 while (my $log = parse1log( $fh )) {
226 4 50 33     14 next if $accept && ! $accept->($log);
227 4 50       10 return $log if $firstonly;
228 4         19 push @logs, $log;
229             }
230 4         233 return @logs;
231             }
232              
233             sub parse1log {
234 8     8 0 14 my $fh = shift;
235 8         15 my $log;
236 8         213 while (my $line = <$fh>) {
237 827 100       2058 return $log if $line =~ /^$/;
238 823 100       1507 next if $line =~ /^########/;
239 819         870 chomp $line;
240 819   100     1312 $log ||= {};
241 819         2296 my @keys = split ': ', $line;
242 819         5185 s/^\s*// for @keys;
243 819         7692 s/\s*$// for @keys;
244 819 50       1720 die "Unexpected syntax in log line: $line\n" unless scalar(@keys) >= 2;
245 819         1034 my $val = pop @keys;
246 819         1059 my $key = shift @keys;
247 819 100       1190 if (scalar(@keys) == 0) {
248 77 100       108 if ($key eq 'INC') {
249 28   100     108 my $list = $log->{$key} ||= [];
250 28         113 push @$list, $val;
251             }
252             else {
253 49 50       100 die "repeated key: $key" if exists $log->{$key};
254 49         334 $log->{$key} = $val;
255             }
256             }
257             else {
258 742         820 my $key2 = shift @keys;
259 742 50       1298 die "invalid nested key: {" . join( '}{', $key, $key2, @keys, $val ) . "}"
260             if scalar(@keys);
261 742 100       1483 if ($key eq 'MODULE') {
262 720 50       1470 die "Unknown MODULE key ($key2)" unless $modkeys{$key2};
263 720   100     1692 my $list = $log->{$key}{$key2} ||= [];
264 720         3035 push @$list, $val;
265             }
266             else {
267 22 50       76 die "repeated key: $key $key2" if exists $log->{$key}{$key2};
268 22         139 $log->{$key}{$key2} = $val;
269             }
270             }
271             }
272 4         13 return $log;
273             }
274              
275             my @extra_loggers = ();
276              
277             sub add_extra_logger {
278 1     1 0 3045 for my $logger (@_) {
279 1 50       12 croak "arg to extra_loggers is not a code ref: " . Dumper($logger)
280             unless ref $logger eq 'CODE';
281 0         0 push @extra_loggers, $logger;
282             }
283             }
284              
285             sub groupmap {
286 2     2 0 10 my $list = shift;
287 2         3 my @res;
288             my %unique;
289 2   66     7 push @res, ($cache{$_} //= getgrgid $_) for grep { ! $unique{$_}++ } split ' ', $list;
  2         124  
290 2         5 my $g1 = shift @res;
291 2         8 return join( '+', $g1, join( ',', @res ) );
292             }
293              
294             BEGIN {
295 1     1   3 $progbase = $FindBin::Script;
296 1         9 $starttime = DateTime->from_epoch(epoch => time);
297 1         394 $valid_dates{$_} = 1 for qw( date time datetime none );
298 1         741 $uid = getpwuid $<;
299 1         39 my $euid = getpwuid $>;
300 1         5 $gid = groupmap $(;
301 1         3 my $egid = groupmap $);
302 1 50       5 $uid = "$euid($uid)" if $uid ne $euid;
303 1 50       8 $gid = "$egid // $gid" if $egid ne $gid;
304              
305 1         21 %option = (
306             suppress => 0,
307             stdout => 0,
308             logdir => ".",
309             logdate => "date",
310             logname => $progbase,
311             logext => ".programinfo",
312             );
313              
314 1         7 %_omap = (
315             LOGPROGRAMINFO_SUPPRESS => 'suppress',
316             LOGPROGRAMINFO_STDOUT => 'stdout',
317             LOGPROGRAMINFO_DIR => 'logdir',
318             LOGPROGRAMINFO_DATE => 'logdate',
319             LOGPROGRAMINFO_NAME => 'logname',
320             LOGPROGRAMINFO_EXT => 'logext',
321             );
322              
323 1         11 while( my($k,$v) = each %_omap ) {
324 6 50       26 $env_options{$v} = $ENV{$k} if exists $ENV{$k};
325             }
326 1         16 $SIG{HUP} = \&catch_sig;
327 1         6 $SIG{INT} = \&catch_sig;
328 1         4 $SIG{PIPE} = \&catch_sig;
329 1         13 $SIG{TERM} = \&catch_sig;
330 1         5 $SIG{USR1} = \&catch_sig;
331 1         2041 $SIG{USR2} = \&catch_sig;
332             }
333              
334             sub import {
335 1     1   16 my $mod = shift;
336              
337 1         4 while (scalar(@_)) {
338 1 50       13 if ($_[0] =~ /^-(logname|logdir|logext|logdate)$/) {
    0          
339 1         4 my $key = $1;
340 1 50       3 croak "Option to Log::ProgramInfo requires a value: $_[0]" if scalar(@_) == 1;
341 1         2 shift;
342 1         3 my $val = shift;
343 1         5 $option{$key} = $val;
344             }
345             elsif ($_[0] =~ /^-(stdout|suppress)$/) {
346 0         0 my $key = $1;
347 0         0 shift;
348 0         0 $option{$key} = 1;
349             }
350             else {
351 0         0 last;
352             }
353             }
354              
355 1 50 33     6 croak "Unknown option to Log::ProgramInfo: $_[0]" if (@_ and $_[0] =~ /^-/);
356 1 50       3 croak "Import arguments not supported from Log::ProgramInfo: " . join( ',', @_ ) if @_;
357 1 50       4 croak "Unknown logdate option: $option{logdate}"
358             unless exists $valid_dates{ $option{logdate} };
359              
360 1 50       20 say STDERR "resolved option hash: ", Dumper(\%option) if $ENV{DUMP_LOG_IMPORTS};
361             }
362              
363             END {
364 1     1   7443 my $exit_status = $?;
365 1         3 local $?; # protect program exit code from END actions
366 1         5 finish_log($exit_status);
367             }
368              
369             sub catch_sig {
370 0     0 0 0 my $signame = shift;
371 0         0 local $?; # protect program exit code from END actions
372 0         0 finish_log("Killed with signal: $signame");
373             }
374              
375             sub log_entry {
376 355     355 0 355 my $logfh = shift;
377 355 100       522 if (@_ == 2 ) {
    50          
378 23         62 printf $logfh "%-7s : %s\n", @_;
379             } elsif (@_ == 3 ) {
380 332         812 printf $logfh "%-7s : %-8s : %s\n", @_;
381             } else {
382 0         0 my $msg = "log_entry needs 2 or 3 arguments, got "
383             . scalar(@_);
384 0 0       0 $msg .= ': (' . join( '), (', @_ ) . ')' if @_;
385 0         0 cluck $msg;
386             }
387             }
388              
389             sub finish_log {
390 1 50   1 0 5 return if $kill_caught++; # only write log once - first kill, or termination
391 1         2 my $exit_status = shift;
392 1 50       6 unless ($option{suppress}) {
393             # pull ENV var over-rides
394 1         7 while (my ($k, $v) = each %env_options) {
395 0         0 $option{$k} = $v;
396             }
397 1         2 my $logfh;
398 1         15 my $endtime = DateTime->from_epoch(epoch => time);
399              
400 1 50       380 if ($option{stdout}) {
401 0         0 open $logfh, ">>&STDOUT";
402             }
403             else {
404 1         3 my $dopt = $option{logdate};
405 1 0       12 my $date =
    50          
    50          
406             ( "none" eq $dopt ) ? ''
407             : ( "date" eq $dopt ) ? $starttime->ymd('')
408             : ( "time" eq $dopt ) ? $starttime->hms('')
409              
410             # : ("datetime" eq $dopt) # validated, so must be 'datetime '
411             : ( $starttime->ymd('') . $starttime->hms('') );
412 1 50       16 $date .= '-' if $date;
413 1 50       7 $option{logext} = ".$option{logext}" if $option{logext} =~ m(^[^.]);
414 1         4 my $log_path = "$option{logdir}/$date$option{logname}$option{logext}";
415 1 50       137 open( $logfh, ">>", $log_path )
416             or carp "cannot open log file $log_path: $!";
417 1         157 say STDERR "Appending log info to $log_path";
418 1         4 my $lock_cnt = 0;
419 1         1 while (1) {
420 1 50       11 flock $logfh, LOCK_EX and last;
421 0 0       0 croak "$0 [$$]: flock failed on $log_path: $!" if $lock_cnt > 30;
422 0 0       0 say STDERR "Waiting for lock on $log_path" unless $lock_cnt++;
423 0         0 print STDERR ".";
424 0         0 sleep(2);
425             }
426 1 50       4 say "" if $lock_cnt;
427             }
428              
429 1         24 say $logfh join( ' ', "########", $uid, '(', $gid, ') :', $progbase, @args );
430              
431 1         5 my $mod = show_modules();
432 1         68 for my $key ( sort keys %$mod ) {
433 109         80 my ( $ver, $loc ) = @{ $mod->{$key} };
  109         152  
434 109         113 log_entry( $logfh, MODULE => NAME => $key );
435 109         122 log_entry( $logfh, MODULE => VERSION => $ver );
436 109         120 log_entry( $logfh, MODULE => LOC => $loc );
437             }
438 1         12 for my $inc (@INC) {
439 11         13 log_entry( $logfh, INC => $inc );
440             }
441              
442 5         15388 log_entry( $logfh, UNAME => $_->[1], do { my $out = qx( uname $_->[0] ); chomp $out; $out } )
  5         47  
  5         78  
443 1         11 for (
444             [ -s => "System" ],
445             [ -n => "Name" ],
446             [ -r => "OSRel" ],
447             [ -v => "OSVer" ],
448             [ -m => "Machine" ]
449             );
450 1         19 log_entry( $logfh, PERL => $^X );
451 1         5 log_entry( $logfh, PERLVer => $] );
452 1         4 log_entry( $logfh, User => $uid );
453 1         4 log_entry( $logfh, Group => $gid );
454 1         6 log_entry( $logfh, ProgDir => $FindBin::Bin );
455 1         5 log_entry( $logfh, Program => $progbase );
456 1   50     16 log_entry( $logfh, Version => ( $::VERSION // "(No VERSION)" ) );
457 1         3 log_entry( $logfh, Args => scalar(@args) );
458 1         3 my $acnt = 0;
459 1         5 log_entry( $logfh, " arg" => sprintf("%8d", ++$acnt), $args[$acnt-1] ) for @args;
460 1         12 log_entry( $logfh, Start => $starttime->datetime() . "." . sprintf( "%03d", $starttime->millisecond ) );
461 1         6 log_entry( $logfh, End => $endtime->datetime() . "." . sprintf( "%03d", $endtime->millisecond ) );
462 1         12 my $dur = $endtime->subtract_datetime_absolute($starttime);
463 1         242 log_entry( $logfh, Elapsed => $dur->delta_seconds . "." .
464             sprintf( "%03d", $dur->delta_nanoseconds/1_000_000) );
465 1         5 log_entry( $logfh, EndStat => $exit_status );
466              
467 1     0   3 $_->(sub { log_entry( $logfh, @_ ) }) for @extra_loggers;
  0         0  
468              
469 1         3 say $logfh "";
470              
471 1         325 close($logfh);
472             }
473             }
474              
475             # Print version and loading path information for modules
476             sub show_modules {
477 1     1 0 1 my $module_infos = {};
478              
479             # %INC looks like this:
480             # {
481             # ...
482             # "Data/Dump.pm"
483             # => "/whatever/perl/lib/site_perl/5.18.1/Data/Dump.pm",
484             # ...
485             # }
486             # So let's convert it to this:
487             # {
488             # ...
489             # "Data::Dump"
490             # => [ "1.4.2",
491             # "/whatever/perl/lib/site_perl/5.18.1/Data/Dump.pm",
492             # ],
493             # ...
494             # }
495 1         103 foreach my $module_inc_name ( keys(%INC) ) {
496 109         101 my $real_name = $module_inc_name;
497 109         186 $real_name =~ s|/|::|g;
498 109         209 $real_name =~ s|\.pm$||;
499              
500 109         1278 my $version = eval { $real_name->VERSION }
501 109   66     84 // eval { ${"${real_name}::VERSION"} }
  23   100     22  
  23         121  
502             // 'unknown';
503             # stringify, in case it is a weird format
504             # - I don't think the 'invalid' alternative can be hit, but safer to have it in
505 109   50     138 $version = eval { $version . '' } // 'invalid';
  109         174  
506              
507 109         333 $module_infos->{$real_name} = [ $version, $INC{$module_inc_name} ];
508             }
509              
510 1         17 return $module_infos;
511             }
512              
513             1;