gecole / profiler
An optional Excimer sampling profiler with a flamegraph visualizer and framework-focused analyzer for the Gecole framework.
Requires
- php: >=8.3
- ext-excimer: *
- ext-mbstring: *
- gecole/framework: dev-main
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-12 22:49:40 UTC
README
An optional, Excimer-based sampling profiler for the Gecole framework with a flamegraph visualizer and an analyzer aimed at optimising the framework itself, not at inspecting application requests.
It is a separate package on purpose:
gecole/telescopeandgecole/pulseanswer “what did the application do?” (requests, queries, jobs, exceptions).gecole/profileranswers “where did the process actually spend its samples?”, with framework-aware grouping, call edges, phase attribution and baseline diffs.
Keeping it separate also means the sampling run does not load a SQLite store or instrument the application connection. Those would themselves distort a CPU profile.
Requirements
- PHP 8.3+
ext-excimerext-mbstringgecole/framework
The extension is available from PECL:
pecl install excimer
# then enable it in php.ini / conf.d
Install
composer require --dev gecole/profiler
Because gecole/framework is currently distributed from dev-main, the
application must allow dev stability (Gecole applications already do).
The standalone gecole-profile binary does not need the service provider.
Register the provider when you want the console commands and the optional HTTP
middleware:
// config/app.php
'providers' => [
// …
\Gecole\Profiler\ProfilerServiceProvider::class,
],
Publish the configuration if you want to edit it:
php bin/console vendor:publish --tag=config
# config/profiler.php now exists; use --force to replace it
Quick start
Profile a console command:
vendor/bin/gecole-profile exec --period=0.5ms --report -- php bin/console migrate:status
Profile one HTTP request through the real kernel:
vendor/bin/gecole-profile http -- GET /admin/dashboard
Open the generated report:
vendor/bin/gecole-profile report --open
To profile the framework's own test suite, run the same binary from a
gecole/framework checkout:
vendor/bin/gecole-profile exec --period=0.5ms \
--name=framework-suite --report -- php tests/run.php
The wrapper starts a child PHP process with
-d auto_prepend_file=vendor/gecole/profiler/resources/prepend.php, so sampling
starts before Application::bootstrap() and includes config loading,
provider registration, container warm-up, routing, middleware and the tests.
Narrow the workload when you are chasing one subsystem; the framework runner accepts a filter:
vendor/bin/gecole-profile exec --period=0.1ms \
--name=db-suite --report -- php tests/run.php database
Short runs need a smaller period and/or --repeat=5 --warmup=1.
Capture commands
Any PHP entry point
vendor/bin/gecole-profile exec -- php bin/console queue:work
vendor/bin/gecole-profile exec -- php public/index.php
Everything after -- belongs to the target. exec accepts a PHP binary or a
PHP file; it does not attach to non-PHP processes (Composer, Bun, etc.). If a
tool is a PHP script with a shebang, invoke it through PHP:
vendor/bin/gecole-profile exec -- php vendor/bin/phpunit
One in-process HTTP request
This boots bootstrap/app.php and passes a real Request through the kernel:
vendor/bin/gecole-profile http -- GET /admin/dashboard
vendor/bin/gecole-profile http --header="Authorization: Bearer …" -- GET /api/projects
vendor/bin/gecole-profile http --payload='{"name":"Demo"}' -- POST /api/projects
It includes bootstrap, routing, middleware, the controller, the view and the response send. It is the closest thing to profiling a real request without a web server.
Repeated / benchmark mode
vendor/bin/gecole-profile exec --repeat=5 --warmup=1 \
--name=suite -- php tests/run.php
Warm-up runs are not captured. Each captured run is written separately, then
merged into one profile with run_count in the metadata. Use this when a single
run is too short for stable sampling (roughly under 5–10k samples).
Live HTTP middleware (optional)
Enable the middleware:
// config/profiler.php
'http' => true,
Then request a profile explicitly:
PROFILER_HTTP=true php bin/console serve
curl -H 'X-Profile: 1' http://localhost:8100/admin
Or profile every request:
'http_always' => true,
The middleware starts after bootstrap, so it answers “which middleware,
controller or view is expensive”, not “what does boot cost”. Its metadata is
marked "partial": true. Use gecole-profile http or exec for the full
process.
Analyse
Report
vendor/bin/gecole-profile report [file] [--top=40] [--framework-only] [--exclude-vendor]
php bin/console profile:report [file] [--top=40]
The terminal output lists hot self-time functions, subsystem shares and inferred phases. The HTML report adds:
- Flamegraph — full-width and responsive; width is sampled time and labels
keep a fixed size on wide screens. There is no zoom. Click a frame to open the
inspector, hover for a tooltip, press
/to search, click a legend key to dim a subsystem, and tick “Dim non-framework” to focus on framework code. - Inspector — the full symbol, subsystem, file:line, frame self/inclusive time, the symbol’s total inclusive time, and the top callers/callees. Copy the symbol or the full stack with one click.
- Self time — leaf frames where the CPU/wall clock actually was.
- Inclusive time — time flowing through a frame, including its children.
- Subsystems — self time grouped by framework namespace (
Database,Foundation,Http,View, …), plus presence (share of samples whose stack contains the subsystem). - Phases — dominant phase per sample, inferred from the deepest framework
marker (
bootstrap,routing,middleware,controller,view,database,response, …). - Middleware — once-per-sample time in each middleware
handle(). - Environment — git commit/branch, PHP/Excimer versions, period, depth, opcache/JIT, wall/CPU time, peak memory.
The header also offers the raw Speedscope JSON and a self-contained SVG export of the flamegraph.
Framework-only mode
vendor/bin/gecole-profile report --framework-only
Tables keep only functions classified as framework code. The flamegraph keeps only samples that contain at least one framework frame and drops the non-framework frames from those stacks. The report states the filtered window, for example:
Framework-only view: 670.0 ms of 1.88 s contained at least one framework frame.
This is usually the most useful view when the workload is a test suite or an application whose own code dominates the samples.
Diff and CI
vendor/bin/gecole-profile diff <baseline> <candidate>
vendor/bin/gecole-profile check <baseline> <candidate> --threshold=1.0
php bin/console profile:check <baseline> <candidate>
Diffs are computed from normalised share, not absolute milliseconds. A
function that grew from 4% to 9% of the run is a regression even if the whole
run happened to be faster. The default threshold is 1 percentage point, and
--min-share (default 0.2) ignores noise below that candidate share.
For CI:
vendor/bin/gecole-profile exec --out=storage/framework/profiles/baseline -- php tests/run.php
git checkout main
vendor/bin/gecole-profile exec --out=storage/framework/profiles/candidate -- php tests/run.php
vendor/bin/gecole-profile check \
storage/framework/profiles/baseline.speedscope.json \
storage/framework/profiles/candidate.speedscope.json
Run both profiles on the same machine and with the same PHP/opcache settings. Absolute timing in CI is noisy; share changes are the useful signal.
List, clean, merge
vendor/bin/gecole-profile list
vendor/bin/gecole-profile clean --keep=20
vendor/bin/gecole-profile merge --out=combined run1.json run2.json
What “framework self” means
- Framework self — summed self time of functions classified as framework
code, divided by total sampled time (CPU time in CPU mode, wall time in
--wallmode). - Framework presence — share of samples whose stack contains at least one framework frame. This includes application/vendor work called by the framework, so it is always ≥ framework self.
The subsystem classifier uses, in order:
Gecole\Framework\<Subsystem>\…→<Subsystem>Gecole\Profiler\…→ProfilerPDO/mysqli/SQLite3→PHP database{closure:…}and file-path frames → mapped from their source path:vendor/gecole/framework/src/<Segment>/→<Segment>(framework code installed as a dependency is still framework code),vendor/→Vendor,tests/→Tests, othersrc/<Segment>/→<Segment>, otherwiseEntry- Namespaced or
Class::methodsymbols →Appunless their file maps - Other internal functions →
PHP
Framework closures and entry files therefore land in their real subsystem
(Foundation, Database, Tests, …) instead of a generic PHP bucket.
Configuration
config/profiler.php (published) or the provider defaults:
| Key | Default | Purpose |
|---|---|---|
enabled | non-production | Enable capture middleware/commands |
storage | storage/framework/profiles | Profile directory |
period | 1ms | Sampling period (1ms, 500us, 0.5ms, 1s; plain number = milliseconds) |
event_type | cpu | cpu or wall |
max_depth | 512 | Excimer stack depth |
max_samples | 0 | Flush every N samples to bound memory; 0 keeps the full log |
http | false | Register the opt-in HTTP middleware |
http_always | false | Profile every request, not only X-Profile |
ignore | profiler, health, favicon.ico | Path prefixes never profiled |
Environment variables follow the same names with a PROFILER_ prefix
(PROFILER_ENABLED, PROFILER_PERIOD, PROFILER_HTTP, …).
The standalone commands default to storage/framework/profiles under the
project root. If the application overrides app.storage_path or
profiler.storage, pass --dir=/path/to/profiles to capture and analysis
commands so both write and read the same directory.
The standalone binary uses command-line options rather than this file:
--period=1ms --wall --depth=512
--max-samples=0 --name=… --out=path
--dir=path --repeat=1 --warmup=0
--no-git --report --open
--quiet --header="K: V" --payload='{…}'
--cookie="k=v" --bootstrap=path --autoload=path
--framework-only --exclude-vendor --top=40
--threshold=1 --min-share=0.2 --fail
CPU vs wall clock
EXCIMER_CPU(default) measures CPU time. Use it to find CPU hotspots and framework overhead. The report's total is sampled CPU time; the metadata still records the real wall and CPU durations.--wall/event_type=wallmeasures elapsed time, including I/O and sleeps. Use it when a page or test is dominated by the database, filesystem or network. The report's total is then wall time.
A function can be cold on CPU and hot on wall (for example PDOStatement::execute).
Profile both when a page is slow and you do not yet know why.
Notes and limitations
- Excimer is a sampling profiler. It gives time attribution, not exact call counts, allocations or file I/O. Use Xdebug/Blackfire if you need exact counts.
- Short runs need a smaller period and/or
--repeat. Fewer than a few thousand samples produce noisy rankings. - CLI
opcacheis often disabled. For realistic framework comparisons, run with the sameopcache.enable_cli/ JIT settings as production, and record them in the metadata. - Disable Telescope/Pulse during a profile. Their SQLite writes and instrumentation add noise to the very samples you are trying to attribute.
auto_prepend_fileapplies to PHP processes, not tocomposer,bunor shell scripts. Run the PHP command explicitly.- HTTP middleware profiles are marked
partialbecause they begin after boot. - Profile files contain full symbol names and source paths. They are development
artifacts; keep
storage/framework/profiles/out of version control.
Development
php tests/run.php # all package tests
php tests/run.php capture # only tests whose name contains “capture”
The suite is dependency-free. Capture tests are skipped when Excimer or the
framework vendor autoload is unavailable. It covers the analyzer, the merged
profile writer, the HTML report, the end-to-end exec capture, repeated
captures, the HTTP runner and the opt-in middleware.
How it works
The package is deliberately small and has no runtime dependencies beyond the framework and the two PHP extensions:
bin/gecole-profile standalone capture + analysis binary
resources/prepend.php auto_prepend entry (starts before bootstrap)
resources/http-runner.php in-process HTTP request runner
src/Profiler.php Excimer wrapper
src/Capture/Prepend.php shutdown capture and metadata
src/Capture/ProfileRequests.php optional HTTP middleware
src/Analysis/SpeedscopeBuilder.php merge logs/runs into name-addressed stacks
src/Analysis/Profile.php self/inclusive, subsystems, phases, edges
src/Analysis/Differ.php normalised-share regression detection
src/Analysis/Merger.php repeated-run merge
src/Report/Flamegraph.php folded stacks → SVG
src/Report/HtmlReport.php self-contained HTML report
src/Report/TextReport.php terminal summaries
src/Storage/ProfileStore.php profile files, metadata, list/clean
src/Support/… autoload, duration, formatting, git, opener
The profiler never writes to the application database. A run produces:
storage/framework/profiles/<timestamp>-<name>.speedscope.json
storage/framework/profiles/<timestamp>-<name>.meta.json
storage/framework/profiles/<timestamp>-<name>.folded
storage/framework/profiles/<timestamp>-<name>.report.html
The .speedscope.json file is portable and can be opened directly in
https://www.speedscope.app; the HTML report is self-contained and works
offline.