circle-be/lib/Circle/Loggable.pm
# You may distribute under the terms of the GNU General Public License
#
# (C) Paul Evans, 2014 -- leonerd@leonerd.org.uk
package Circle::Loggable;
use strict;
use warnings;
use base qw( Circle::Commandable Circle::Configurable );
our $VERSION = '0.173320';
use File::Basename qw( dirname );
use File::Path qw( make_path );
use POSIX qw( strftime mktime );
__PACKAGE__->APPLY_Inheritable_Setting( log_enabled =>
description => "Enable logging of events",
type => 'bool',
);
__PACKAGE__->APPLY_Inheritable_Setting( log_path =>
description => "Path template for log file name",
type => 'str',
);
use Struct::Dumb qw( readonly_struct );
# Data about the log file itself
readonly_struct LogId => [qw(
path time_start time_until itempath line_timestamp_fmt
)];
# Data about logging from a particular item
readonly_struct LogCtx => [qw(
path_residue
)];
our $NO_LOG = 0;
sub push_log
{
my $self = shift;
my ( $event, $time, $args ) = @_;
return unless $self->setting_log_enabled;
return if $NO_LOG;
# Best-effort
eval {
my $logger = $self->logger( $time );
my $ctx = $self->{logctx};
$logger->log( $ctx, $time, $event, $args );
1;
} and return;
{
local $NO_LOG = 1;
warn "Unable to log - $@";
}
}
my %time_format_to_idx = (
Y => 5,
m => 4,
d => 3,
H => 2,
M => 1,
S => 0,
);
# Returns a LogId and a LogCtx
sub split_logpath
{
my $self = shift;
my ( $time ) = @_;
my @pcs = split m{/}, $self->enumerable_path;
shift @pcs; # trim leading /
@pcs or @pcs = ( "Global" );
my $path_used = 0;
my %ts_used = map { $_ => 0 } qw( Y m d H M );
my @timestamp = localtime $time;
my %formats = (
# Specific kinds of time format so we can track the granulity being used
( map {
my $format = $_;
$format => sub {
$ts_used{$format}++;
strftime( "%$format", @timestamp )
};
} qw( Y m d H M ) ),
P => sub {
my ( $limit ) = @_;
defined $limit or $limit = @pcs;
my $path_lower = $path_used;
my $path_upper = $limit;
$path_used = $path_upper if $path_upper > $path_used;
return join '/', map { $_ // "" } @pcs[$path_lower..$path_upper-1];
},
);
my $path = $self->setting_log_path;
$path =~ s<%(?:{([^}]*)})?(.)>
{exists $formats{$2} ? $formats{$2}->($1)
: die "Unrecognised escape '%$2"}eg;
# Reset to zero all the fields that aren't used
$ts_used{$_} or $timestamp[$time_format_to_idx{$_}] = 0 for qw( Y m d H M S );
$timestamp[3] or $timestamp[3] = 1; # mday is 1-based
my $time_start = strftime( "%Y/%m/%d %H:%M:%S", @timestamp );
# Increment the last timestamp field before a field not used in the file
# path
$ts_used{$_} or $timestamp[$time_format_to_idx{$_}+1]++, last for qw( m d H M S );
my $time_until = mktime @timestamp;
my $time_fmt_day = join "/", map { $ts_used{$_} ? () : ( "\%$_" ) } qw( Y m d );
my $time_fmt_sec = join ":", map { $ts_used{$_} ? () : ( "\%$_" ) } qw( H M S );
my $logid = LogId(
$path,
$time_start,
$time_until,
join( '/', grep { defined } @pcs[0..$path_used-1] ),
join( " ", grep { length } $time_fmt_day, $time_fmt_sec ),
);
my $logctx = LogCtx(
join( '/', grep { defined } @pcs[$path_used..$#pcs] ),
);
return ( $logid, $logctx );
}
our %LOGGER_FOR_PATH;
sub logger
{
my $self = shift;
my ( $time ) = @_;
my ( $logid, $logctx ) = $self->split_logpath( $time );
my $path = $logid->path;
if( defined $self->{logpath} and $self->{logpath} ne $path ) {
$self->close_logger;
}
if( defined $self->{loguntil} and $time >= $self->{loguntil} ) {
$self->close_logger;
}
my $logger = $LOGGER_FOR_PATH{$path} ||= do {
my $dir = dirname( $path );
unless( -d $dir ) {
make_path( $dir, { mode => 0700 } ) or die "Cannot mkdir $dir - $!";
}
Circle::Loggable::Backend::CircleLog->open( $logid );
};
if( !defined $self->{logpath} ) {
$self->{logpath} = $path;
$self->{loguntil} = $logid->time_until;
$logger->hold_ref;
# TODO set up a timer to expire and close the log at that time
}
$self->{logctx} = $logctx;
return $logger;
}
sub close_logger
{
my $self = shift;
my $logger = $LOGGER_FOR_PATH{$self->{logpath} // ""} or return;
$logger->drop_ref;
if( !$logger->refcount ) {
delete $LOGGER_FOR_PATH{$self->{logpath}};
$logger->close;
}
undef $self->{logpath};
}
sub command_log
: Command_description("Configure logging")
{
}
sub command_log_info
: Command_description("Show information about logging")
: Command_subof('log')
: Command_default()
{
my $self = shift;
my ( $cinv ) = @_;
if( $self->_setting_log_enabled_inherits ) {
$cinv->respond( "Logging is inherited (currently " . ( $self->setting_log_enabled ? "enabled" : "disabled" ) . ")" );
}
elsif( $self->setting_log_enabled ) {
$cinv->respond( "Logging is directly enabled" );
}
else {
$cinv->respond( "Logging is directly disabled" );
}
if( $self->setting_log_enabled ) {
my ( $logid, $logctx ) = $self->split_logpath( time );
$cinv->respond( "Logging to path " . $logid->path );
$cinv->respond( "Timestamp starts " . $logid->time_start );
$cinv->respond( "Timestamp until " . strftime( "%Y/%m/%d %H:%M:%S", localtime $logid->time_until ) );
$cinv->respond( "Line timestamp is " . $logid->line_timestamp_fmt );
$cinv->respond( "Path residue is " . $logctx->path_residue );
}
return;
}
sub command_log_enable
: Command_description("Enable logging of this item and its children")
: Command_subof('log')
{
my $self = shift;
my ( $cinv ) = @_;
$self->setting_log_enabled( 1 );
$cinv->respond( "Logging enabled" );
return;
}
sub command_log_disable
: Command_description("Disable logging of this item and its children")
: Command_subof('log')
{
my $self = shift;
my ( $cinv ) = @_;
$self->setting_log_enabled( 0 );
$cinv->respond( "Logging disabled" );
return;
}
sub command_log_inherit
: Command_description("Inherit log enabled state from parent")
: Command_subof('log')
{
my $self = shift;
my ( $cinv ) = @_;
$self->setting_log_enabled( undef );
$cinv->respond( "Logging inherited (currently " . $self->setting_log_enabled ? "enabled" : "disabled" );
return;
}
sub command_log_rotate
: Command_description("Rotate the current log file handle")
: Command_subof('log')
{
my $self = shift;
my ( $cinv ) = @_;
my $path;
my $n_suffix = 1;
$n_suffix++ while -f ( $path = "$self->{logpath}.$n_suffix" );
unless( rename( $self->{logpath}, $path ) ) {
$cinv->responderr( "Cannot rename $self->{logpath} to $path - $!" );
return;
}
$cinv->respond( "Log file rotated to $path" );
$self->{logger}->close;
undef $self->{logger};
return;
}
package # hide
Circle::Loggable::Backend::CircleLog;
use POSIX qw( strftime );
sub open
{
my $class = shift;
my ( $id ) = @_;
my $path = $id->path;
open my $fh, ">>", $path or die "Cannot open event log $path - $!";
chmod $fh, 0600;
$fh->binmode( ":encoding(UTF-8)" );
$fh->autoflush;
$fh->print( "!LOG START=\"${\$id->time_start}\" ITEMS=\"${\$id->itempath}\" TIMESTAMP_FMT=\"${\$id->line_timestamp_fmt}\"\n" );
return bless {
fh => $fh,
refcount => 0,
id => $id,
}, $class;
}
sub refcount { shift->{refcount} }
sub hold_ref { shift->{refcount}++ }
sub drop_ref { shift->{refcount}-- }
sub close
{
my $self = shift;
warn "Closing $self with references open" if $self->{refcount};
close $self->{fh};
}
sub log
{
my $self = shift;
my ( $ctx, $time, $event, $args ) = @_;
my $line = strftime( $self->{id}->line_timestamp_fmt, localtime $time );
$line .= " ".$ctx->path_residue if length $ctx->path_residue;
$line .= " $event";
$line .= " ".$self->encode( $args );
$line .= "\n";
$self->{fh}->print( $line );
}
## This should output a valid YAML encoding of a data tree, on a single line
# using flow-style mappings and sequences
# Similar to JSON except without quoted keys
sub encode
{
my $self = shift;
my ( $args ) = @_;
if( !ref $args ) {
my $str = "$args";
$str =~ s/(["\\])/\\$1/g;
$str =~ s/\n/\\n/g;
$str =~ s/\t/\\t/g;
$str =~ s/([\x00-\x1f\x80-\x9f])/sprintf "\\x%02x", ord $1/eg;
return qq("$str");
}
elsif( ref $args eq "HASH" ) {
return "{" . join( ", ", map {
"$_: ".$self->encode( $args->{$_} )
} sort keys %$args ) . "}";
}
elsif( ref $args eq "ARRAY" ) {
return "[" . join( ", ", map {
$self->encode( $args->[$_] )
} 0 .. $#$args ) . "]";
}
else {
return "$args";
}
}
0x55AA;