Search by

gecole / profiler

ecoleplus

An optional Excimer sampling profiler with a flamegraph visualizer and framework-focused analyzer for the Gecole framework.

Package info

gitlab.com/gecole/profiler

Issues

pkg:composer/gecole/profiler

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

dev-master 2026-11-12 22:48 UTC

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/telescope and gecole/pulse answer “what did the application do?” (requests, queries, jobs, exceptions).
  • gecole/profiler answers “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-excimer
  • ext-mbstring
  • gecole/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 --wall mode).
  • 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:

  1. Gecole\Framework\<Subsystem>\… → <Subsystem>
  2. Gecole\Profiler\… → Profiler
  3. PDO/mysqli/SQLite3 → PHP database
  4. {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, other src/<Segment>/ → <Segment>, otherwise Entry
  5. Namespaced or Class::method symbols → App unless their file maps
  6. 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:

KeyDefaultPurpose
enablednon-productionEnable capture middleware/commands
storagestorage/framework/profilesProfile directory
period1msSampling period (1ms, 500us, 0.5ms, 1s; plain number = milliseconds)
event_typecpucpu or wall
max_depth512Excimer stack depth
max_samples0Flush every N samples to bound memory; 0 keeps the full log
httpfalseRegister the opt-in HTTP middleware
http_alwaysfalseProfile every request, not only X-Profile
ignoreprofiler, health, favicon.icoPath 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=wall measures 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 opcache is often disabled. For realistic framework comparisons, run with the same opcache.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_file applies to PHP processes, not to composer, bun or shell scripts. Run the PHP command explicitly.
  • HTTP middleware profiles are marked partial because 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.