# Internal module used by FU.pm package FU::DebugImpl 0.5; use v5.36; use experimental 'for_list'; use FU; use FU::XMLWriter ':html5_', 'fragment', 'xml_escape'; use Time::HiRes 'time', 'clock_gettime', 'CLOCK_MONOTONIC'; 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]; $f = "$l->[0]::$f" if $f !~ /^\Q$l->[0]/; txt_ $f; small_ " @ $l->[1]:$l->[2]"; } } 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; } sub raw_data($str) { my $d = substr $str, 0, 32*1024; my $trunc = length $str > 32*1024 ? ', truncated' : ''; return utf8::decode($d) ? ("utf8$trunc", $d) : ("hex$trunc", unpack('H*', $d) =~ s/(.{128})/$1\n/rg =~ s/(.{16})/$1 /rg); } my @sections = ( req => sub { my $r = $FU::REQ; 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(time - (($r->{trace_end}||clock_gettime(CLOCK_MONOTONIC)) - $r->{trace_start})) }; }; h2_ 'Headers'; table_ sub { tr_ sub { td_ $_; td_ fu->headers->{$_}; } for sort keys fu->headers->%*; }; if ((fu->header('content-length')||0) > 0) { h2_ 'Body'; section_ class => 'tabs', sub { my $json = eval { fu->json({type=>'any'}) }; details_ name => 'reqbody', open => !0, sub { summary_ 'JSON'; pre_ FU::Util::json_format($json, pretty => 1, canonical => 1); } if $json; my $formdata = eval { fu->formdata({type=>'hash'}) }; details_ name => 'reqbody', open => !0, sub { summary_ 'Form data'; table_ sub { for my $k (sort keys %$formdata) { tr_ sub { td_ $k; td_ $_; } for ref $formdata->{$k} ? $formdata->{$k}->@* : ($formdata->{$k}); } }; } if $formdata; my $multipart = eval { fu->multipart }; details_ name => 'reqbody', open => !0, sub { summary_ 'Multipart'; pre_ join "\n", map $_->describe, @$multipart; } if $multipart; details_ name => 'reqbody', open => !0,sub { my($lbl, $data) = raw_data $r->{body}; summary_ "Raw ($lbl)"; pre_ $data; }; } } ('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 { for my $k (sort keys $r->{reshdr}->%*) { my $v = $r->{reshdr}{$k}; tr_ sub { td_ $k; td_ $_; } for !defined $v ? () : ref $v ? @$v : ($v); } }; my $body = $r->{resbody_orig} // $r->{resbody}; if (length $body) { h2_ 'Body'; section_ class => 'tabs', sub { my $json = ($r->{reshdr}{'content-type'}||'') =~ /^application\/json/ && eval { FU::Util::json_parse($body, utf8 => 1) }; details_ name => 'resbody', open => !0, sub { summary_ 'JSON'; pre_ FU::Util::json_format($json, pretty => 1, canonical => 1); } if $json; details_ name => 'resbody', open => !0,sub { my($lbl, $data) = raw_data $body; summary_ "Raw ($lbl)"; pre_ $data; }; } } ('Response') }, sql => sub { return () if !$FU::REQ->{trace_sql}; # TODO: Summarize main table, expand to display full query, params table, interpolated query 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', $_->{query}; } for $FU::REQ->{trace_sql}->@*; }; ('Queries', scalar $FU::REQ->{trace_sql}->@*) }, fu => sub { return () if !keys fu->%*; # TODO: This is kinda lazy, an expandable table might be nicer. require Data::Dumper; pre_ sub { lit_ Data::Dumper->new([fu])->Sortkeys(1)->Terse(1)->Dump; }; ('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', $_->[1]; } for @$lst; }; ('Prepared statements', scalar @$lst) }, ); sub collect { my @t; for my ($id, $sub) (@sections) { my($title, $num); my $html = fragment { ($title, $num) = $sub->() }; utf8::decode($html); 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 } /* Ugh, fixed positioning */ header { position: fixed; top: 0; left: 0; width: 100%; height: 40px; z-index: 2 } nav { position: fixed; top: 38px; left: 0; width: 200px; z-index: 2 } main { margin: 0 0 0 200px } header, nav { background: #eee } header { border-bottom: 2px solid #009 } nav { border-bottom: 2px solid #009; border-right: 2px solid #009 } header { display: flex; justify-content: space-between; align-items: baseline; padding: 5px 10px } header h1 { font-size: 120%; 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 a { display: block; width: 100%; text-decoration: none; padding: 2px 10px; cursor: pointer; white-space: nowrap } nav a:hover { background-color: #fff } nav a span { float: right; font-size: 80% } main { padding: 0 10px 30px 10px } main h1 { background: #eee; padding: 5px 10px 5px 205px; margin: 40px -10px 10px -210px; scroll-margin-top: 40px; font-size: 130%; font-weight: bold } main h2 { margin: 20px 0 5px 0; font-size: 120%; font-weight: bold } p, table, pre { margin: 5px 0 } pre { font-family: monospace; white-space: pre; overflow-x: auto; padding-bottom: 15px; /* for the scrollbar, kinda browser-specific */ } table { border-collapse: collapse } td { padding: 1px 10px 1px 0; font-size: 12px; vertical-align: top } td.code { font-family: monospace } tr:hover { background-color: #eee } thead { font-weight: bold } .num { text-align: right; white-space: nowrap } section.tabs { position: relative; display: flex; flex-wrap: wrap; z-index: 1; } section.tabs summary { cursor: pointer; order: 0; display: block; padding: 3px 5px; margin-right: 10px; background: #ddd } section.tabs summary:hover, section.tabs details[open] summary { background: #eee } section.tabs details { display: contents } section.tabs details *:nth-child(2) { order: 1; width: 100% } small { color: #555; font-size: 90% } _ }; 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' }; }; }; nav_ sub { menu_ sub { li_ sub { a_ href => "#$_->{id}", sub { txt_ $_->{title}; span_ $_->{num} if defined $_->{num}; }; } for @$data; }; } if @$data; main_ sub { for (@$data) { h1_ id => $_->{id}, $_->{title}; lit_ $_->{html}; } }; }; }; } 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->notfound; 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->notfound if !@$lst; load $lst->[$#$lst]; } elsif ($FU::debug_info->{storage} && $q =~ /^[0-9a-f]{22}$/) { load $q; } else { fu->notfound } } 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, $FU::REQ->{trace_end} - $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;