Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions stackcollapse-phpspy.pl
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,22 @@
# ...
#
# Example Output:
# <main>;ccc;bbb;aaa 1
# <main>;aaa 1
# <main>;bbb;aaa;sleep 1
# <main>:sample.php;ccc;bbb;aaa;sleep 1
# <main>:sample.php;aaa;sleep 1
# <main>:sample.php;bbb;aaa;sleep 1
#
# `<main>` frames are labelled with the file they belong to, since PHP names
# the top-level scope of every file `<main>`. The label is the shortest
# trailing part of the path that is unique among the `<main>` 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::<main>`; the class prefix is dropped so every
# file-scope frame reads `<main>:file`. Code with no file (`php -r`, eval)
# stays a plain `<main>`. 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
Expand Down Expand Up @@ -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 `<main>`, or `Class::<main>` when the file
# was included from inside a method. Label them `<main>:path` instead,
# with the path shortened once all input is read (see path_suffix).
if ($depth ne '#' && $func =~ /<main>$/) {
$func = '<main>'; # 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;
# `<internal>` is phpspy's file for `php -r` and eval'd code
$func .= ":$path" if length $path && $path ne '<internal>';
}
}

# decode the utf-8 bytes and make them into characters
# and turn anything that's invalid into U+FFFD
Expand All @@ -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 =~ /^<main>:(.*)$/;

if ($depth ne '#' && $depth == 0) {
$stacks{join(';', reverse @frames)} += 1 if @frames;
@frames = ();
Expand All @@ -84,6 +116,35 @@ sub usage {
}
$stacks{join(';', reverse @frames)} += 1 if @frames;

# Shorten each <main> path to the shortest trailing run of path components
# that is unique among all <main> 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{(<main>:)([^;]+)}{$1 . (exists $short{$2} ? $short{$2} : $2)}ge;
print "$k $v\n";
}
102 changes: 102 additions & 0 deletions tests/test_stackcollapse_main_file.sh
Original file line number Diff line number Diff line change
@@ -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 <internal>, `;` and
# whitespace replaced, trailing slashes ignored, comment lines not counted.
read -r -d '' canned_in <<'EOD'
0 sleep <internal>:-1
1 <main> /var/www/public/index.php:3
# - - -
0 sleep <internal>:-1
1 <main> /var/www/admin/index.php:3
# - - -
0 sleep <internal>:-1
1 <main> /var/www/my app/index.php:3
# - - -
0 sleep <internal>:-1
1 A::<main> /var/www/lib/inc.php:1
2 A::m /var/www/public/index.php:9
3 <main> /var/www/public/index.php:12
# - - -
0 sleep <internal>:-1
1 <main> <internal>:-1
# - - -
0 sleep <internal>:-1
1 <main> /var/www/semi;colon.php:1
# - - -
0 sleep <internal>:-1
1 <main> /var/www/trail.php/:1
# - - -
0 sleep <internal>:-1
1 <main> /var/www/trail.php//:1
# - - -
# <main> /zzz/plain.php:1
0 sleep <internal>:-1
1 <main> /var/www/plain.php:1
EOD
read -r -d '' canned_out <<'EOD'
<main>:admin/index.php;sleep 1
<main>:my_app/index.php;sleep 1
<main>:plain.php;sleep 1
<main>:public/index.php;A::m;<main>:inc.php;sleep 1
<main>:public/index.php;sleep 1
<main>:semi_colon.php;sleep 1
<main>:trail.php;sleep 2
<main>;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 `<main>` 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
require __DIR__ . '/inc.php';
EOD
echo "$php_src" >"$php_dir/entry.php"
read -r -d '' php_src <<'EOD'
<?php
class A {
public function m() {
require __DIR__ . '/sub/inc.php';
}
}
(new A())->m();
EOD
echo "$php_src" >"$php_dir/inc.php"
read -r -d '' php_src <<'EOD'
<?php
sleep(1);
EOD
echo "$php_src" >"$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 \
'^<main>:entry\.php;<main>:my_app/inc\.php;A::m;<main>:sub/inc\.php;sleep \d+$' "$collapsed"
test_assert main_file_no_bare 0 "$(grep -cP '<main>(?!:)' <<<"$collapsed")"
test_assert main_file_no_scope_prefix 0 "$(grep -c '::<main>' <<<"$collapsed")"
}
test_fn=_live
test_invoke
Loading