File Coverage

blib/lib/Circle/Loggable.pm
Criterion Covered Total %
statement 45 201 22.3
branch 1 58 1.7
condition 0 18 0.0
subroutine 15 33 45.4
pod 0 10 0.0
total 61 320 19.0


line stmt bran cond sub pod time code
1             # You may distribute under the terms of the GNU General Public License
2             #
3             # (C) Paul Evans, 2014 -- leonerd@leonerd.org.uk
4              
5             package Circle::Loggable;
6              
7 4     4   28 use strict;
  4         6  
  4         98  
8 4     4   17 use warnings;
  4         7  
  4         95  
9 4     4   17 use base qw( Circle::Commandable Circle::Configurable );
  4         12  
  4         563  
10              
11             our $VERSION = '0.173320';
12              
13 4     4   25 use File::Basename qw( dirname );
  4         14  
  4         197  
14 4     4   22 use File::Path qw( make_path );
  4         13  
  4         202  
15 4     4   21 use POSIX qw( strftime mktime );
  4         13  
  4         36  
16              
17             __PACKAGE__->APPLY_Inheritable_Setting( log_enabled =>
18             description => "Enable logging of events",
19             type => 'bool',
20             );
21              
22             __PACKAGE__->APPLY_Inheritable_Setting( log_path =>
23             description => "Path template for log file name",
24             type => 'str',
25             );
26              
27 4     4   369 use Struct::Dumb qw( readonly_struct );
  4         8  
  4         31  
28              
29             # Data about the log file itself
30             readonly_struct LogId => [qw(
31             path time_start time_until itempath line_timestamp_fmt
32             )];
33              
34             # Data about logging from a particular item
35             readonly_struct LogCtx => [qw(
36             path_residue
37             )];
38              
39             our $NO_LOG = 0;
40              
41             sub push_log
42             {
43 8     8 0 16 my $self = shift;
44 8         20 my ( $event, $time, $args ) = @_;
45              
46 8 50       68 return unless $self->setting_log_enabled;
47 0 0         return if $NO_LOG;
48              
49             # Best-effort
50 0 0         eval {
51 0           my $logger = $self->logger( $time );
52              
53 0           my $ctx = $self->{logctx};
54              
55 0           $logger->log( $ctx, $time, $event, $args );
56 0           1;
57             } and return;
58              
59             {
60 0           local $NO_LOG = 1;
  0            
61 0           warn "Unable to log - $@";
62             }
63             }
64              
65             my %time_format_to_idx = (
66             Y => 5,
67             m => 4,
68             d => 3,
69             H => 2,
70             M => 1,
71             S => 0,
72             );
73              
74             # Returns a LogId and a LogCtx
75             sub split_logpath
76             {
77 0     0 0   my $self = shift;
78 0           my ( $time ) = @_;
79              
80 0           my @pcs = split m{/}, $self->enumerable_path;
81 0           shift @pcs; # trim leading /
82 0 0         @pcs or @pcs = ( "Global" );
83 0           my $path_used = 0;
84 0           my %ts_used = map { $_ => 0 } qw( Y m d H M );
  0            
85              
86 0           my @timestamp = localtime $time;
87              
88             my %formats = (
89             # Specific kinds of time format so we can track the granulity being used
90             ( map {
91 0           my $format = $_;
92             $format => sub {
93 0     0     $ts_used{$format}++;
94 0           strftime( "%$format", @timestamp )
95 0           };
96             } qw( Y m d H M ) ),
97             P => sub {
98 0     0     my ( $limit ) = @_;
99 0 0         defined $limit or $limit = @pcs;
100              
101 0           my $path_lower = $path_used;
102 0           my $path_upper = $limit;
103              
104 0 0         $path_used = $path_upper if $path_upper > $path_used;
105              
106 0   0       return join '/', map { $_ // "" } @pcs[$path_lower..$path_upper-1];
  0            
107             },
108 0           );
109              
110 0           my $path = $self->setting_log_path;
111 0 0         $path =~ s<%(?:{([^}]*)})?(.)>
  0            
112             {exists $formats{$2} ? $formats{$2}->($1)
113             : die "Unrecognised escape '%$2"}eg;
114              
115 0   0       # Reset to zero all the fields that aren't used
116 0 0         $ts_used{$_} or $timestamp[$time_format_to_idx{$_}] = 0 for qw( Y m d H M S );
117             $timestamp[3] or $timestamp[3] = 1; # mday is 1-based
118 0            
119             my $time_start = strftime( "%Y/%m/%d %H:%M:%S", @timestamp );
120              
121             # Increment the last timestamp field before a field not used in the file
122 0   0       # path
123 0           $ts_used{$_} or $timestamp[$time_format_to_idx{$_}+1]++, last for qw( m d H M S );
124             my $time_until = mktime @timestamp;
125 0 0          
  0            
126 0 0         my $time_fmt_day = join "/", map { $ts_used{$_} ? () : ( "\%$_" ) } qw( Y m d );
  0            
127             my $time_fmt_sec = join ":", map { $ts_used{$_} ? () : ( "\%$_" ) } qw( H M S );
128              
129             my $logid = LogId(
130             $path,
131             $time_start,
132 0           $time_until,
133 0           join( '/', grep { defined } @pcs[0..$path_used-1] ),
  0            
134             join( " ", grep { length } $time_fmt_day, $time_fmt_sec ),
135             );
136              
137 0           my $logctx = LogCtx(
  0            
138             join( '/', grep { defined } @pcs[$path_used..$#pcs] ),
139             );
140 0            
141             return ( $logid, $logctx );
142             }
143              
144             our %LOGGER_FOR_PATH;
145              
146             sub logger
147 0     0 0   {
148 0           my $self = shift;
149             my ( $time ) = @_;
150 0            
151 0           my ( $logid, $logctx ) = $self->split_logpath( $time );
152             my $path = $logid->path;
153 0 0 0        
154 0           if( defined $self->{logpath} and $self->{logpath} ne $path ) {
155             $self->close_logger;
156 0 0 0       }
157 0           if( defined $self->{loguntil} and $time >= $self->{loguntil} ) {
158             $self->close_logger;
159             }
160 0   0        
161 0           my $logger = $LOGGER_FOR_PATH{$path} ||= do {
162 0 0         my $dir = dirname( $path );
163 0 0         unless( -d $dir ) {
164             make_path( $dir, { mode => 0700 } ) or die "Cannot mkdir $dir - $!";
165             }
166 0            
167             Circle::Loggable::Backend::CircleLog->open( $logid );
168             };
169 0 0          
170 0           if( !defined $self->{logpath} ) {
171 0           $self->{logpath} = $path;
172 0           $self->{loguntil} = $logid->time_until;
173             $logger->hold_ref;
174              
175             # TODO set up a timer to expire and close the log at that time
176             }
177 0            
178             $self->{logctx} = $logctx;
179 0            
180             return $logger;
181             }
182              
183             sub close_logger
184 0     0 0   {
185             my $self = shift;
186 0 0 0        
187             my $logger = $LOGGER_FOR_PATH{$self->{logpath} // ""} or return;
188 0            
189 0 0         $logger->drop_ref;
190 0           if( !$logger->refcount ) {
191 0           delete $LOGGER_FOR_PATH{$self->{logpath}};
192             $logger->close;
193             }
194 0            
195             undef $self->{logpath};
196             }
197              
198             sub command_log
199       0 0   : Command_description("Configure logging")
200 4     4   4219 {
  4         9  
  4         24  
201             }
202              
203             sub command_log_info
204             : Command_description("Show information about logging")
205             : Command_subof('log')
206             : Command_default()
207 0     0 0 0 {
208 0         0 my $self = shift;
209             my ( $cinv ) = @_;
210 0 0       0  
    0          
211 0 0       0 if( $self->_setting_log_enabled_inherits ) {
212             $cinv->respond( "Logging is inherited (currently " . ( $self->setting_log_enabled ? "enabled" : "disabled" ) . ")" );
213             }
214 0         0 elsif( $self->setting_log_enabled ) {
215             $cinv->respond( "Logging is directly enabled" );
216             }
217 0         0 else {
218             $cinv->respond( "Logging is directly disabled" );
219             }
220 0 0       0  
221 0         0 if( $self->setting_log_enabled ) {
222             my ( $logid, $logctx ) = $self->split_logpath( time );
223 0         0  
224 0         0 $cinv->respond( "Logging to path " . $logid->path );
225 0         0 $cinv->respond( "Timestamp starts " . $logid->time_start );
226 0         0 $cinv->respond( "Timestamp until " . strftime( "%Y/%m/%d %H:%M:%S", localtime $logid->time_until ) );
227             $cinv->respond( "Line timestamp is " . $logid->line_timestamp_fmt );
228 0         0  
229             $cinv->respond( "Path residue is " . $logctx->path_residue );
230             }
231 0         0  
232 4     4   1095 return;
  4         8  
  4         19  
233             }
234              
235             sub command_log_enable
236             : Command_description("Enable logging of this item and its children")
237             : Command_subof('log')
238 0     0 0 0 {
239 0         0 my $self = shift;
240             my ( $cinv ) = @_;
241 0         0  
242 0         0 $self->setting_log_enabled( 1 );
243 0         0 $cinv->respond( "Logging enabled" );
244 4     4   738 return;
  4         7  
  4         14  
245             }
246              
247             sub command_log_disable
248             : Command_description("Disable logging of this item and its children")
249             : Command_subof('log')
250 0     0 0 0 {
251 0         0 my $self = shift;
252             my ( $cinv ) = @_;
253 0         0  
254 0         0 $self->setting_log_enabled( 0 );
255 0         0 $cinv->respond( "Logging disabled" );
256 4     4   531 return;
  4         7  
  4         13  
257             }
258              
259             sub command_log_inherit
260             : Command_description("Inherit log enabled state from parent")
261             : Command_subof('log')
262 0     0 0 0 {
263 0         0 my $self = shift;
264             my ( $cinv ) = @_;
265 0         0  
266 0 0       0 $self->setting_log_enabled( undef );
267 0         0 $cinv->respond( "Logging inherited (currently " . $self->setting_log_enabled ? "enabled" : "disabled" );
268 4     4   675 return;
  4         15  
  4         14  
269             }
270              
271             sub command_log_rotate
272             : Command_description("Rotate the current log file handle")
273             : Command_subof('log')
274 0     0 0 0 {
275 0         0 my $self = shift;
276             my ( $cinv ) = @_;
277 0         0  
278 0         0 my $path;
279 0         0 my $n_suffix = 1;
280             $n_suffix++ while -f ( $path = "$self->{logpath}.$n_suffix" );
281 0 0       0  
282 0         0 unless( rename( $self->{logpath}, $path ) ) {
283 0         0 $cinv->responderr( "Cannot rename $self->{logpath} to $path - $!" );
284             return;
285             }
286 0         0  
287             $cinv->respond( "Log file rotated to $path" );
288 0         0  
289 0         0 $self->{logger}->close;
290             undef $self->{logger};
291 0         0  
292 4     4   864 return;
  4         6  
  4         15  
293             }
294              
295             package # hide
296             Circle::Loggable::Backend::CircleLog;
297 4     4   449  
  4         8  
  4         14  
298             use POSIX qw( strftime );
299              
300             sub open
301 0     0     {
302 0           my $class = shift;
303             my ( $id ) = @_;
304 0            
305 0 0         my $path = $id->path;
306 0           open my $fh, ">>", $path or die "Cannot open event log $path - $!";
307             chmod $fh, 0600;
308 0            
309 0           $fh->binmode( ":encoding(UTF-8)" );
310             $fh->autoflush;
311 0            
  0            
  0            
  0            
312             $fh->print( "!LOG START=\"${\$id->time_start}\" ITEMS=\"${\$id->itempath}\" TIMESTAMP_FMT=\"${\$id->line_timestamp_fmt}\"\n" );
313 0            
314             return bless {
315             fh => $fh,
316             refcount => 0,
317             id => $id,
318             }, $class;
319             }
320 0     0      
321 0     0     sub refcount { shift->{refcount} }
322 0     0     sub hold_ref { shift->{refcount}++ }
323             sub drop_ref { shift->{refcount}-- }
324              
325             sub close
326 0     0     {
327 0 0         my $self = shift;
328             warn "Closing $self with references open" if $self->{refcount};
329 0            
330             close $self->{fh};
331             }
332              
333             sub log
334 0     0     {
335 0           my $self = shift;
336             my ( $ctx, $time, $event, $args ) = @_;
337 0            
338 0 0         my $line = strftime( $self->{id}->line_timestamp_fmt, localtime $time );
339 0           $line .= " ".$ctx->path_residue if length $ctx->path_residue;
340 0           $line .= " $event";
341 0           $line .= " ".$self->encode( $args );
342             $line .= "\n";
343 0            
344             $self->{fh}->print( $line );
345             }
346              
347             ## This should output a valid YAML encoding of a data tree, on a single line
348             # using flow-style mappings and sequences
349             # Similar to JSON except without quoted keys
350              
351             sub encode
352 0     0     {
353 0           my $self = shift;
354             my ( $args ) = @_;
355 0 0          
    0          
    0          
356 0           if( !ref $args ) {
357 0           my $str = "$args";
358 0           $str =~ s/(["\\])/\\$1/g;
359 0           $str =~ s/\n/\\n/g;
360 0           $str =~ s/\t/\\t/g;
  0            
361 0           $str =~ s/([\x00-\x1f\x80-\x9f])/sprintf "\\x%02x", ord $1/eg;
362             return qq("$str");
363             }
364             elsif( ref $args eq "HASH" ) {
365 0           return "{" . join( ", ", map {
  0            
366             "$_: ".$self->encode( $args->{$_} )
367             } sort keys %$args ) . "}";
368             }
369             elsif( ref $args eq "ARRAY" ) {
370 0           return "[" . join( ", ", map {
  0            
371             $self->encode( $args->[$_] )
372             } 0 .. $#$args ) . "]";
373             }
374 0           else {
375             return "$args";
376             }
377             }
378              
379             0x55AA;