FU: Add super awesome and butt-ugly FU::debug_info web interface

This is so much more useful than embedding debugging info inside pages,
as I've been doing before.
This commit is contained in:
Yorhel 2025-02-27 09:10:37 +01:00
parent de36b90cde
commit b06cc24826
2 changed files with 430 additions and 23 deletions

100
FU.pm
View file

@ -130,21 +130,27 @@ sub init_db($info) {
} }
my @before_request; sub _caller_info {
my @after_request; my($i, @c, @x) = (1);
sub before_request :prototype(&) ($f) { push @before_request, $f } $x[0] !~ /^FU(?:$|::)/ && push @c, [ @x[0..3] ] while (@x = caller $i++);
sub after_request :prototype(&) ($f) { unshift @after_request, $f } \@c
}
our @before_request;
our @after_request;
sub before_request :prototype(&) ($f) { push @before_request, [ $f, _caller_info ] }
sub after_request :prototype(&) ($f) { unshift @after_request, [ $f, _caller_info ] }
my %path_routes; our %path_routes;
my %re_routes; our %re_routes;
sub _add_route($path, $sub, $method) { sub _add_route($path, $sub, $method) {
if (ref $path eq 'REGEXP' || ref $path eq 'Regexp') { if (ref $path eq 'REGEXP' || ref $path eq 'Regexp') {
push $re_routes{$method}->@*, [ qr/^$path$/, $sub ]; push $re_routes{$method}->@*, [ qr/^$path$/, $sub, _caller_info ];
} elsif (!ref $path) { } elsif (!ref $path) {
confess("A route has already been registered for $method $path") if $path_routes{$method}{$path}; confess("A route has already been registered for $method $path") if $path_routes{$method}{$path};
$path_routes{$method}{$path} = $sub; $path_routes{$method}{$path} = [ $sub, _caller_info ];
} else { } else {
confess('Path argument in route registration must be a string or regex'); confess('Path argument in route registration must be a string or regex');
} }
@ -207,6 +213,12 @@ sub _monitor {
} }
our $debug_info = [];
sub debug_info($path, $storage=undef, $history=100) {
$debug_info = { path => $path, storage => $storage, history => $history }
}
our $hdrname_re = qr/[!#\$\%&'\*\+-\.^_`\|~0-9a-zA-Z]{1,127}/; our $hdrname_re = qr/[!#\$\%&'\*\+-\.^_`\|~0-9a-zA-Z]{1,127}/;
our $method_re = qr/(?:HEAD|GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)/; our $method_re = qr/(?:HEAD|GET|POST|DELETE|OPTIONS|PUT|PATCH|QUERY)/;
@ -305,7 +317,7 @@ sub _log_err($e) {
} }
sub _do_req($c) { sub _do_req($c) {
local $REQ = { hdr => {}, trace_start => time }; local $REQ = { hdr => {}, trace_start => time, trace_id => sprintf('%010x%08x%04x', int time, $$, int rand 1<<16) };
local $fu = bless {}, 'FU::obj'; local $fu = bless {}, 'FU::obj';
$REQ->{ip} = $c->{client_sock} isa 'IO::Socket::INET' ? $c->{client_sock}->peerhost : '127.0.0.1'; $REQ->{ip} = $c->{client_sock} isa 'IO::Socket::INET' ? $c->{client_sock}->peerhost : '127.0.0.1';
@ -315,15 +327,27 @@ sub _do_req($c) {
_read_req $c; _read_req $c;
$REQ->{trace_start} = time; $REQ->{trace_start} = time;
for my $h (@before_request) { $h->() }
my $path = fu->path; my $path = fu->path;
my $method = fu->method eq 'HEAD' ? 'GET' : fu->method; my $method = fu->method eq 'HEAD' ? 'GET' : fu->method;
# Intercept requests for debug_info, ensuring no website hooks get called.
if (debug && $method eq 'GET' && $debug_info->{path} && $path eq $debug_info->{path}) {
require FU::DebugImpl;
FU::DebugImpl::render();
fu->_flush($c->{fcgi_obj} || $c->{client_sock});
fu->error(-1);
}
for my $h (@before_request) { $h->[0]->() }
my $r = $path_routes{$method}{$path}; my $r = $path_routes{$method}{$path};
if ($r) { $r->() } if ($r) {
else { $REQ->{trace_han} = [ $path, $r->[1] ];
$r->[0]->();
} else {
for $r ($re_routes{ fu->method }->@*) { for $r ($re_routes{ fu->method }->@*) {
if($path =~ $r->[0]) { if($path =~ $r->[0]) {
$REQ->{trace_han} = [ $r->[0], $r->[2] ];
$r->[1]->(@{^CAPTURE}); $r->[1]->(@{^CAPTURE});
fu->done; fu->done;
} }
@ -333,11 +357,12 @@ sub _do_req($c) {
1; 1;
}; };
return if !$ok && ref $@ eq 'FU::err' && $@->[0] == -1; return if !$ok && ref $@ eq 'FU::err' && $@->[0] == -1;
$REQ->{trace_exn} = $ok ? undef : $@;
my $err = $ok || _is_done($@) ? undef : $@; my $err = $ok || _is_done($@) ? undef : $@;
_log_err $err; _log_err $err;
for my $h (@after_request) { for my $h (@after_request) {
$ok = eval { $h->(); 1 }; $ok = eval { $h->[0]->(); 1 };
_log_err $@ if !$ok; _log_err $@ if !$ok;
$err = $@ if !$err && !$ok && !_is_done($@); $err = $@ if !$err && !$ok && !_is_done($@);
} }
@ -361,7 +386,12 @@ sub _do_req($c) {
$REQ->{trace_end} = time; $REQ->{trace_end} = time;
fu->_flush($c->{fcgi_obj} || $c->{client_sock}); fu->_flush($c->{fcgi_obj} || $c->{client_sock});
my $proc_ms = (time - $REQ->{trace_start}) * 1000; if (debug && $REQ->{trace_id} && $debug_info->{history} && $debug_info->{storage}) {
require FU::DebugImpl;
FU::DebugImpl::save();
}
my $proc_ms = ($REQ->{trace_end} - $REQ->{trace_start}) * 1000;
log_write(sprintf "%.0fms%s %s-%s %s-%d\n", $proc_ms, log_write(sprintf "%.0fms%s %s-%s %s-%d\n", $proc_ms,
$REQ->{trace_nsql} ? $REQ->{trace_nsql} ?
sprintf ' (sql %.0f+%.0fms, %d/%d/%d)', sprintf ' (sql %.0f+%.0fms, %d/%d/%d)',
@ -639,11 +669,15 @@ sub formdata {
# Response generation methods # Response generation methods
sub done { die bless [200,'Done'], 'FU::err' } sub done { die bless [200,'Done',FU::_caller_info], 'FU::err' }
sub error($,$code,$msg=$code) { die bless [$code,$msg], 'FU::err' } sub error($,$code,$msg=$code) { die bless [$code,$msg,FU::_caller_info], 'FU::err' }
sub status($, $code) { $FU::REQ->{status} = $code } sub status($, $code) { $FU::REQ->{status} = $code }
sub set_body($, $data) { $FU::REQ->{resbody} = $data } sub set_body($, $data) {
confess "Invalid undef body" if !defined $data;
confess "Invalid attempt to set body to $data" if ref $data;
$FU::REQ->{resbody} = $data;
}
sub reset { sub reset {
fu->status(200); fu->status(200);
@ -950,9 +984,29 @@ handling and performance tracing.
Enable or disable debug mode. Returns the current mode when no argument is Enable or disable debug mode. Returns the current mode when no argument is
given. given.
Debug mode currently only enables more verbose logging, but it may influence Debug mode currently enables more verbose logging and the C<debug_info>
other features in the future as well. You're of course free to use the debug interface below. It may influence other features in the future as well. You're
setting to enable or disable debugging features in your own code. of course free to use the debug setting to enable or disable debugging features
in your own code.
=item FU::debug_info($path, $storage, $history)
Enable the built-in web interface for inspecting debug info. The interface is
accessible from your browser at the given C<$path>, which is matched against
C<< fu->path >>.
When the optional C<$storage> argument is given and set to an existing
directory, detailed request data is logged and stored in that directory, which
is then made available through the web interface. The C<$history> argument sets
the number of requests to keep, which defaults to 100.
Request logging and the web interface are only available when C<FU::debug> mode
is enabled.
B<WARNING:> This interface exposes internal and potentially sensitive
information. When this option is configured, make sure to B<ABSOLUTELY NEVER>
enable debug mode in production! Or at least set an absolutely impossible to
guess C<$path>.
=item FU::log_slow_reqs($ms) =item FU::log_slow_reqs($ms)
@ -1271,7 +1325,7 @@ though.
This method loads the entire file contents in memory and does not support range This method loads the entire file contents in memory and does not support range
requests, so DO NOT use it to send large files. Actual web servers are much requests, so DO NOT use it to send large files. Actual web servers are much
more efficient at sending static files. more efficient at serving static files.
The content-type header is determined from the file extension in C<$path>, The content-type header is determined from the file extension in C<$path>,
using the configured C<FU::mime_types>. As fallback, files that look like they using the configured C<FU::mime_types>. As fallback, files that look like they
@ -1424,7 +1478,7 @@ external process manager.
=back =back
When C<--monitor> or C<--max-reqs> are set or C<<--proc>> is larger than 1, FU When C<--monitor> or C<--max-reqs> are set or C<--proc> is larger than 1, FU
starts a supervisor process to ensure the requested number of worker processes starts a supervisor process to ensure the requested number of worker processes
are running and that they are restarted when necessary. When FU has been loaded are running and that they are restarted when necessary. When FU has been loaded
with the C<-spawn> flag, this supervisor process runs directly from the context with the C<-spawn> flag, this supervisor process runs directly from the context

353
FU/DebugImpl.pm Normal file
View file

@ -0,0 +1,353 @@
# Internal module used by FU.pm
package FU::DebugImpl 0.1;
use v5.36;
use experimental 'for_list';
use FU;
use FU::XMLWriter ':html5_', 'fragment', 'xml_escape';
use Time::HiRes 'time';
use POSIX 'strftime';
sub fmtts { strftime '%Y-%m-%d %H:%M:%S UTC', gmtime shift }
sub loc_($loc) {
txt_ '[internal]' if !@$loc;
for (0..$#$loc) {
br_ if $_;
my $l = $loc->[$_];
my $f = $_ == $#$loc ? '(main)' : $loc->[$_+1][3];
txt_ "$l->[1]:$l->[2] $f";
}
}
sub fmtpre_($code) {
lit_ xml_escape($code) =~ s/^\s+//r =~ s/\s+$//r =~ s/\n/<br>/rg;
}
sub clean_re($str) {
# Regex formatting isn't stable, but this cleans up the crap I'm seeing a little bit.
"$str" =~ s/^\(\?\^u:\^\(\?\^u://r =~ s/\)\$\)$//r;
}
my @tabs = (
req => sub {
table_ sub {
tr_ sub { td_ 'Method'; td_ fu->method };
tr_ sub { td_ 'Path'; td_ fu->path };
tr_ sub { td_ 'Query'; td_ fu->query };
tr_ sub { td_ 'Client IP'; td_ fu->ip };
tr_ sub { td_ 'Received'; td_ fmtts $FU::REQ->{trace_start} };
};
h2_ 'Headers';
table_ sub {
tr_ sub {
td_ $_;
td_ fu->headers->{$_};
} for sort keys fu->headers->%*;
};
h2_ 'Body';
p_ 'TODO';
('Request')
},
res => sub {
my $r = $FU::REQ;
return () if !exists $r->{trace_end};
table_ sub {
tr_ sub { td_ 'Status'; td_ $r->{status} };
tr_ sub {
td_ 'Handler';
td_ $r->{trace_han} ? sub {
txt_ clean_re $r->{trace_han}[0];
br_;
loc_ $r->{trace_han}[1];
} : 'N/A';
};
my $exn = $r->{trace_exn};
tr_ sub {
td_ 'Exception';
td_ !defined $exn ? 'N/A' : ref $exn eq 'FU::err' ? sub {
txt_ $exn->[0];
txt_ " $exn->[1]" if $exn->[1] ne $exn->[0];
br_;
loc_ $exn->[2];
} : $exn;
};
tr_ sub { td_ 'Timing'; td_ sprintf '%.1f ms', ($r->{trace_end}-$r->{trace_start})*1000 };
};
h2_ 'Headers';
table_ sub {
tr_ sub {
td_ $_;
td_ $r->{reshdr}{$_};
} for keys $r->{reshdr}->%*;
};
('Response')
},
sql => sub {
return () if !$FU::REQ->{trace_sql};
table_ sub {
thead_ sub { tr_ sub {
td_ class => 'num', 'Exec';
td_ class => 'num', 'Prep';
td_ class => 'num', 'Rows';
td_ 'Query';
} };
tr_ sub {
td_ class => 'num', sprintf '%.1f ms', $_->{exec_time}*1000;
td_ class => 'num', !defined $_->{prepare_time} ? '-' : $_->{prepare_time} ? sprintf '%.1f ms', $_->{prepare_time}*1000 : 'cache';
td_ class => 'num', $_->{nrows};
td_ class => 'code', sub { fmtpre_ $_->{query} };
# TODO: Params, both separate and interpolated
} for $FU::REQ->{trace_sql}->@*;
};
('Queries', scalar $FU::REQ->{trace_sql}->@*)
},
fu => sub {
return () if !keys fu->%*;
# TODO: Contents of the 'fu' object
('fu obj')
},
proc => sub {
table_ sub {
tr_ sub { td_ 'PID'; td_ $$ };
tr_ sub { td_ 'NAME'; td_ $0 };
tr_ sub { td_ 'ARGV'; td_ join ' ', @ARGV };
tr_ sub { td_ 'USER'; td_ "$< / $>" };
tr_ sub { td_ 'GROUP'; td_ "$( / $)" };
tr_ sub { td_ 'OS'; td_ $^O };
tr_ sub { td_ 'Perl'; td_ $^V };
tr_ sub { td_ 'Up for'; td_ sprintf '%.3f seconds', time - $^T };
};
('Process')
},
env => sub {
table_ sub {
tr_ sub {
td_ $_;
td_ $ENV{$_};
} for sort keys %ENV;
};
('Environment', scalar keys %ENV)
},
inc => sub {
table_ sub {
tr_ sub {
td_ $_;
td_ $INC{$_};
} for sort keys %INC;
};
('Included files', scalar keys %INC)
},
han => sub {
my $cnt = 0;
my sub tbl_($title, $lst) {
return if !@$lst;
$cnt += @$lst;
h2_ $title;
table_ sub {
tr_ sub {
td_ clean_re $_->[0];
td_ sub { loc_ $_->[1] };
} for @$lst;
};
}
for my $meth (qw/GET POST DELETE OPTIONS PUT PATCH QUERY/) {
my($path, $re) = ($FU::path_routes{$meth}, $FU::re_routes{$meth});
my @lst = (
(map [$_, $path->{$_}[1]], $path ? sort keys %$path : ()),
(map [$_->[0], $_->[2]], $re ? @$re : ())
);
tbl_ $meth, \@lst;
}
tbl_ before_request => [ map [$_, $FU::before_request[$_][1]], 0..$#FU::before_request ];
tbl_ after_request => [ map [$_, $FU::after_request[$_][1]], 0..$#FU::after_request ];
('Handlers', $cnt)
},
pgst => sub {
return () if !$FU::DB;
my $lst = eval { $FU::DB->q(
'SELECT generic_plans + custom_plans, statement FROM pg_prepared_statements ORDER BY generic_plans + custom_plans DESC, statement'
)->cache(0)->alla } || do { warn "Unable to collect prepared statement list: $@"; return () };
return () if !@$lst;
table_ sub {
thead_ sub { tr_ sub {
td_ 'Num';
td_ 'Query';
} };
tr_ sub {
td_ $_->[0];
td_ class => 'code', sub { fmtpre_ $_->[1] };
} for @$lst;
};
('Prepared statements', scalar @$lst)
},
);
sub collect {
my @t;
for my ($id, $sub) (@tabs) {
my($title, $num);
my $html = fragment { ($title, $num) = $sub->() };
push @t, { id => $id, title => $title, num => $num, html => $html } if $title;
}
\@t
}
sub framework_($data) {
html_ sub {
head_ sub {
title_ 'FU Debugging Interface';
meta_ name => 'viewport', content => 'width=device-width, initial-scale=1.0, user-scalable=yes';
style_ type => 'text/css', <<~_;
html { box-sizing: border-box; color: #000; background: #fff }
*, *:before, *:after { box-sizing: inherit }
* { margin: 0; padding: 0; font: inherit; color: inherit }
body { display: grid; grid: 45px 400px / 220px auto; }
header { grid-column: 1 / 3; grid-row: 1 / 2 }
nav { grid-column: 1 / 2; grid-row: 2 / 3 }
main { grid-column: 2 / 3; grid-row: 2 / 3 }
header, nav { background: #eee }
main { border-top: 2px solid #009; border-left: 2px solid #009 }
nav { border-bottom: 2px solid #009 }
header { display: flex; justify-content: space-between; padding: 10px }
header h1 { font-size: 20px; font-weight: bold }
header menu { list-style-type: none; display: flex; gap: 15px }
body > input { display: none }
nav { padding-top: 20px }
nav menu { list-style-type: none }
nav label { display: block; width: 100%; padding: 2px 10px; cursor: pointer; white-space: nowrap }
nav label:hover { background-color: #fff }
nav label span { float: right; font-size: 80% }
main { padding: 10px 20px }
main h2 { margin: 30px 0 5px -10px; font-size: 20px; font-weight: bold }
main h2:first-child { margin-top: 0 }
p, pre, table { margin: 5px 0 }
pre, .code { font-family: monospace; white-space: pre }
table { border-collapse: collapse }
td { padding: 1px 10px 1px 0; font-size: 12px; vertical-align: top }
tr:hover { background-color: #eee }
thead { font-weight: bold }
.num { text-align: right; white-space: nowrap }
_
style_ type => 'text/css', join "\n", map +(
"#tab_$_:checked ~ nav menu li label[for=tab_$_] { background-color: #fff }",
"#tab_$_:not(:checked) ~ main #tabc_$_ { display: none }",
), map $_->{id}, @$data;
};
body_ sub {
header_ sub {
h1_ 'FU Debugging Interface';
menu_ sub {
li_ sub { a_ href => '?last', 'Last' };
li_ sub { a_ href => '?cur', 'Current' };
li_ sub { a_ href => '?', 'Listing' };
};
};
input_ type => 'radio', name => 'tab', id => "tab_$_->{id}", checked => $_ eq $data->[0] ? 'checked' : undef for @$data;
nav_ sub {
menu_ sub {
li_ sub {
label_ for => "tab_$_->{id}", sub {
txt_ $_->{title};
span_ $_->{num} if defined $_->{num};
}
} for @$data;
};
} if @$data;
main_ sub {
div_ id => "tabc_$_->{id}", sub {
h2_ $_->{title};
lit_ $_->{html};
} for @$data;
};
};
};
}
sub listing {
opendir my $dh, $FU::debug_info->{storage} or do {
warn "Error opening '$FU::debug_info->{storage}': $!\n";
return;
};
my @f;
/^fu-([0-9a-f]{22})\.txt$/ && push @f, $1 while (readdir $dh);
return [sort @f];
}
sub listing_ {
my $lst = listing;
return p_ 'Request logging disabled.' if !$FU::debug_info->{storage} || !$FU::debug_info->{history};
return p_ 'No requests logged.' if !@$lst;
table_ sub {
tr_ sub {
open my $fh, '<:utf8', "$FU::debug_info->{storage}/fu-$_.txt" or return;
my($ts, $time, $status, $method, $uri) = split / /, scalar <$fh>, 5;
td_ sub { a_ href => "?$_", $_ };
td_ class => 'num', fmtts $ts;
td_ class => 'num', sprintf '%.0f ms', $time*1000;
td_ class => 'num', $status;
td_ $method;
td_ $uri;
} for reverse @$lst;
}
}
sub load($id) {
open my $fn, '<', "$FU::debug_info->{storage}/fu-$id.txt" or fu->error(404);
scalar <$fn>;
local $/=undef;
fu->set_body(scalar <$fn>);
}
sub render {
my $q = fu->query;
if (!$q) {
fu->set_body(framework_ [{id => 'lst', title => 'Recent Requests', html => fragment \&listing_ }]);
} elsif ($q eq 'cur') {
fu->set_body(framework_ collect);
} elsif ($q eq 'last') {
my $lst = listing;
fu->error(404) if !@$lst;
load $lst->[$#$lst];
} elsif ($FU::debug_info->{storage} && $q =~ /^[0-9a-f]{22}$/) {
load $q;
} else {
fu->error(404);
}
}
sub save {
my $files = listing;
unlink sprintf '%s/fu-%s.txt', $FU::debug_info->{storage}, shift @$files while @$files >= $FU::debug_info->{history};
delete $FU::REQ->{txn};
my $fn = "$FU::debug_info->{storage}/fu-$FU::REQ->{trace_id}.txt";
open my $fh, '>', $fn or do {
warn "Error opening '$fn': $!\n";
return;
};
my $line = sprintf "%d %f %s %s %s\n",
time, time - $FU::REQ->{trace_start}, $FU::REQ->{status},
fu->method, fu->path.(fu->query?'?'.fu->query:'');
utf8::encode($line);
print $fh $line;
print $fh framework_ collect;
}
1;