From bd82eb48de42c3b7e1682d5c7e3ea33f6bca5139 Mon Sep 17 00:00:00 2001 From: dleibovic Date: Sat, 5 Sep 2026 18:20:32 -0400 Subject: [PATCH] Label
frames by file in stackcollapse-phpspy.pl Co-Authored-By: Claude Fable 5.1 --- stackcollapse-phpspy.pl | 69 ++++++++++++++++- tests/test_stackcollapse_main_file.sh | 102 ++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) create mode 100755 tests/test_stackcollapse_main_file.sh diff --git a/stackcollapse-phpspy.pl b/stackcollapse-phpspy.pl index c9558da..50de87d 100755 --- a/stackcollapse-phpspy.pl +++ b/stackcollapse-phpspy.pl @@ -28,9 +28,22 @@ # ... # # Example Output: -#
;ccc;bbb;aaa 1 -#
;aaa 1 -#
;bbb;aaa;sleep 1 +#
:sample.php;ccc;bbb;aaa;sleep 1 +#
:sample.php;aaa;sleep 1 +#
:sample.php;bbb;aaa;sleep 1 +# +# `
` frames are labelled with the file they belong to, since PHP names +# the top-level scope of every file `
`. The label is the shortest +# trailing part of the path that is unique among the `
` files in the +# input: usually just the basename, or e.g. `admin/index.php` and +# `public/index.php` when two files share one. This means the same file can +# be labelled differently in two captures if a same-named file appears in +# only one of them, which matters when comparing collapsed output across +# runs (e.g. with difffolded.pl). A file included from inside a method is +# reported by phpspy as `Class::
`; the class prefix is dropped so every +# file-scope frame reads `
:file`. Code with no file (`php -r`, eval) +# stays a plain `
`. phpspy truncates paths to 255 bytes, so very long +# paths may yield fragments rather than filenames. # # To make a flamegraph: # ./stackcollapse-phpspy.pl infile | ./vendor/flamegraph.pl > svg.out @@ -59,11 +72,27 @@ sub usage { # internals my %stacks; my @frames; +my %main_files; while (defined(my $line = <>)) { next unless $line =~ /^(?:#|\d+) \S/; - my ($depth, $func) = (split ' ', $line)[0,1]; + my ($depth, $func, $loc) = split ' ', $line, 3; + + # phpspy names file-scope frames `
`, or `Class::
` when the file + # was included from inside a method. Label them `
:path` instead, + # with the path shortened once all input is read (see path_suffix). + if ($depth ne '#' && $func =~ /
$/) { + $func = '
'; # drop the class prefix, if any + if (defined $loc && $loc =~ /^(.*):-?\d+\s*$/) { # "path:lineno" + my $path = $1; + $path =~ s{/+$}{}; # ignore trailing slashes + # replace `;` and whitespace with `_`; both delimit folded output + $path =~ s/[;\s]/_/g; + # `` is phpspy's file for `php -r` and eval'd code + $func .= ":$path" if length $path && $path ne ''; + } + } # decode the utf-8 bytes and make them into characters # and turn anything that's invalid into U+FFFD @@ -75,6 +104,9 @@ sub usage { # turn it back into a string $func = encode("utf-8", $func); + # remember the (now sanitised) path for the shortening pass below + $main_files{$1} = 1 if $depth ne '#' && $func =~ /^
:(.*)$/; + if ($depth ne '#' && $depth == 0) { $stacks{join(';', reverse @frames)} += 1 if @frames; @frames = (); @@ -84,6 +116,35 @@ sub usage { } $stacks{join(';', reverse @frames)} += 1 if @frames; +# Shorten each
path to the shortest trailing run of path components +# that is unique among all
files seen. +sub path_suffix { + my ($path, $n) = @_; + my @parts = split m{/}, $path, -1; + $n = @parts if $n > @parts; + return join('/', @parts[-$n .. -1]); +} + +my %short; +my @pending = keys %main_files; +my $len = 0; +while (@pending) { + $len++; + my %count; + $count{path_suffix($_, $len)}++ for @pending; + my @still_pending; + for my $path (@pending) { + my $suffix = path_suffix($path, $len); + if ($count{$suffix} == 1 || $suffix eq $path) { + $short{$path} = $suffix; + } else { + push @still_pending, $path; + } + } + @pending = @still_pending; +} + while ( my ($k, $v) = each %stacks ) { + $k =~ s{(
:)([^;]+)}{$1 . (exists $short{$2} ? $short{$2} : $2)}ge; print "$k $v\n"; } diff --git a/tests/test_stackcollapse_main_file.sh b/tests/test_stackcollapse_main_file.sh new file mode 100755 index 0000000..362f8e7 --- /dev/null +++ b/tests/test_stackcollapse_main_file.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# shellcheck disable=SC2034 # ignore seemingly unused test_invoke params +# shellcheck source=/dev/null +source "$TEST_SH" + +repo=$(dirname "$(dirname "$TEST_SH")") +collapse="$repo/stackcollapse-phpspy.pl" + +# Canned trace covering the labelling rules: shortest unique suffix for +# same-named files, class prefix dropped, no file for , `;` and +# whitespace replaced, trailing slashes ignored, comment lines not counted. +read -r -d '' canned_in <<'EOD' +0 sleep :-1 +1
/var/www/public/index.php:3 +# - - - +0 sleep :-1 +1
/var/www/admin/index.php:3 +# - - - +0 sleep :-1 +1
/var/www/my app/index.php:3 +# - - - +0 sleep :-1 +1 A::
/var/www/lib/inc.php:1 +2 A::m /var/www/public/index.php:9 +3
/var/www/public/index.php:12 +# - - - +0 sleep :-1 +1
:-1 +# - - - +0 sleep :-1 +1
/var/www/semi;colon.php:1 +# - - - +0 sleep :-1 +1
/var/www/trail.php/:1 +# - - - +0 sleep :-1 +1
/var/www/trail.php//:1 +# - - - +#
/zzz/plain.php:1 +0 sleep :-1 +1
/var/www/plain.php:1 +EOD +read -r -d '' canned_out <<'EOD' +
:admin/index.php;sleep 1 +
:my_app/index.php;sleep 1 +
:plain.php;sleep 1 +
:public/index.php;A::m;
:inc.php;sleep 1 +
:public/index.php;sleep 1 +
:semi_colon.php;sleep 1 +
:trail.php;sleep 2 +
;sleep 1 +EOD +_canned() { + test_assert main_file_canned "$canned_out" "$("$collapse" <<<"$canned_in" | LC_ALL=C sort)" +} +test_fn=_canned +test_invoke + +# Live trace: three files, each contributing a `
` frame to the same +# stack. The innermost is included from inside a method, two of the files +# share a basename, and the directory name has a space. +tmp_dir=$(mktemp -d) +_exit() { rm -rf "$tmp_dir"; } +trap _exit EXIT +php_dir="$tmp_dir/my app" +mkdir -p "$php_dir/sub" +read -r -d '' php_src <<'EOD' +"$php_dir/entry.php" +read -r -d '' php_src <<'EOD' +m(); +EOD +echo "$php_src" >"$php_dir/inc.php" +read -r -d '' php_src <<'EOD' +"$php_dir/sub/inc.php" + +_live() { + local collapsed + collapsed=$( + "$PHPSPY" -O/dev/null -E/dev/null 2>"$tmp_dir/phpspy.err" \ + -- "${PHP[@]}" "$php_dir/entry.php" \ + | "$collapse" + ) + [ -n "$collapsed" ] || cat "$tmp_dir/phpspy.err" >&2 + test_assert_re main_file_live \ + '^
:entry\.php;
:my_app/inc\.php;A::m;
:sub/inc\.php;sleep \d+$' "$collapsed" + test_assert main_file_no_bare 0 "$(grep -cP '
(?!:)' <<<"$collapsed")" + test_assert main_file_no_scope_prefix 0 "$(grep -c '::
' <<<"$collapsed")" +} +test_fn=_live +test_invoke