magepsycho/magento2-profiler-ui

Admin viewer for MagePsycho_Profiler JSON reports

Maintainers

Package info

github.com/MagePsycho/magento2-profiler-ui

Language:CSS

Type:magento2-module

pkg:composer/magepsycho/magento2-profiler-ui

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

1.0.4 2026-08-19 11:36 UTC

This package is auto-updated.

Last update: 2026-08-19 13:44:29 UTC


README

Magento 2 Enhanced Profiler UI

Magento 2 Enhanced Profiler UI

Packagist Version Packagist Downloads Supported Magento Versions License

Overview

Magento 2 Enhanced Profiler UI is the admin viewer for the json reports written by MagePsycho_Profiler.

System → Tools → Enhanced Profiler Reports

Pick a recorded run from the dropdown and get an interactive view of it: a collapsible tree you can sort and filter without leaving, or a timeline of every individual call — with the SQL behind any query timer one click away. Admin-side on purpose — the storefront may be headless, and profiler output is not something to expose there in any case.

It only reads what MagePsycho_Profiler writes. It adds nothing to the recording side, registers no plugins and touches no request path other than its own, so it can be left uninstalled in production without changing what gets profiled.

Why a separate module

MagePsycho_Profiler is meant to be safe to enable anywhere; it is an output type plus instrumentation and has no HTTP surface of its own. An admin UI is a different proposition — controllers, ACL, an attack surface — so it installs separately and can be left off in production.

Demo

Tree and Timeline views of a recorded run:

Enhanced Profiler UI — tree and timeline views

Key Features

  • Admin page at System → Tools → Enhanced Profiler Reports, gated behind its own ACL resource
  • Two views of the same run — Tree and Timeline — switched from one radio group
  • Self column heat-shaded by magnitude, so time that belongs to no child timer is visible at a glance
  • Sort on any column and filter by timer id without leaving the tree: siblings reorder under their own parent, and hiding keeps a match's parent chain as dimmed context
  • Show all rows: filter by highlighting instead of hiding, so matches stand out in place
  • Reset puts every control back to how the run first loaded, without losing which run you were looking at
  • Click a SQL: row to see the query — syntax-highlighted, with its bind params and a Copy button, for runs recorded with MAGE_PROFILER_SQL=query
  • A match count under the meta strip — 34 matches highlighted of 273 timers | filter "SQL:" — so a filter's reach is visible without scrolling
  • Timeline of every recorded call, ordered by start, indented by depth, coloured by subsystem with a legend built from the run
  • Every timeline row ends with its own duration, heat-shaded, so a bar too small to see still tells you what it cost
  • Run picker shows the span count per report, so you know what you are opening
  • Clear all reports empties the report directory and the index in one action
  • Every cell rendered with textContent — a report containing <script> stays text
  • Reads only what MagePsycho_Profiler writes; zero recording overhead, safe to leave uninstalled in production

Feature Highlights

The Report Table

Column Meaning
Timer Id Leaf name, indented by depth; the full a->b->c path is the row tooltip
Cnt Times the timer was started
Time Total wall time, inclusive of children
Self Time minus the sum of direct children — where work with no timer of its own shows up
Avg Time / Cnt
Emalloc / RealMem Memory deltas
% Share of the root timer

Self is the column worth learning. On a real POST /V1/integration/customer/token, magento measured 96.2ms while its children summed to 45.6ms — 53% of the request had no timer on it at all. That gap is invisible in the Time column and obvious in Self.

Tree And Timeline

Two modes, picked from the View switch. Both are filterable, and the tree is sortable — there is no flat fallback to fall out of.

Tree (the default) is structural browsing. Real HTML indentation with an expand/collapse control on every row that has children — no ├─ box-drawing, which is a terminal constraint the ASCII log has to live with and a web page does not. Expand all / Collapse all are in the toolbar; Collapse all keeps the roots open so the top level stays readable. Parent rows are bold, and hovering a row shows its full a->b->c path as a tooltip.

Tree view — collapsible hierarchy with the Self column heat-shaded

Show all rows (ticked by default) decides what a filter does. Ticked, nothing is removed: matching rows are tinted and flagged in the margin, everything else stays exactly where it was, and the count line reads 34 matches highlighted of 273 timers. The Self column keeps its heat colour inside a highlighted row; you never trade the "where did the time go" signal for the "what did I search for" one.

Untick it and non-matching rows go away — but a match's parents stay, dimmed, so the hierarchy above every hit is still there. That is what makes hiding safe in a tree: prune the ancestors too and a surviving child appears to hang off nothing. Paths to a match open automatically; the collapse state you had comes back when you clear the filter. The same rule applies to the timeline, whose bars are indented by depth for the same reason.

Tree view filtered to SQL — matches highlighted, their parent chain kept as dimmed context

Sorting stays in the tree as well. A column sorts siblings under their own parent, then the tree is re-flattened, so indentation still means what it says and the answer to "what is the slowest child of this timer" survives. Sorting by Time from the root down is the fastest way to walk the hot path of a request.

Reset — next to Expand all / Collapse all — puts the filter, both sliders, the sort, the match toggle, the collapse state and the view back to how the run first loaded. It leaves the Run selector alone: resetting the view you are looking at should not throw away which run you chose to look at.

The sliders are seeded from the loaded report, so their range always matches the data in front of you.

Seeing The Statement

A report recorded with MAGE_PROFILER_SQL=query carries the statement behind every SQL: timer. Those rows pick up a dotted underline; clicking one opens the query, syntax-highlighted, with its bind params. A tree row is an aggregate, so its popup groups identical statements - ×18 with the summed and slowest duration - while a timeline bar is a single call and shows just that one. Copy puts the raw statement on the clipboard, and Esc, the backdrop or × closes the panel.

SQL statement popup — the query behind a timer, syntax-highlighted with its bind params

Above: 1 filters to SQL:, 2 unticks Show all rows so only matches and their dimmed parents survive, 3 is the clicked timer — SQL:SELECT (catalog_product_entity +3), 1.916 ms — and 4 is the statement it stood for, joins, IN (…) list and all.

Nothing appears for a report recorded without capture, which is every report by default. Highlighting is a small built-in tokenizer rather than a bundled library, so the viewer still ships no JavaScript dependencies.

Timeline is one bar per recorded call, ordered by start, indented by depth, coloured by subsystem (SQL:, CACHE:, EVENT:, …) with a legend built from whatever the run actually contains. This is where you see when something happened rather than just how long it took in total — a burst of queries mid-request, a cache stampede, one slow call versus four hundred fast ones.

Timeline view — one bar per call, coloured by subsystem

Every capture records spans, so Timeline works on any recent report — MAGE_PROFILER=json and MAGE_PROFILER=timeline are the same thing. The radio only disables for reports recorded with MAGE_PROFILER_MAX_SPANS=0, or from before spans were always captured. The Timer Id filter and the min-time slider both narrow the chart.

The x-axis uses wall_ms (first start to last end), not total_ms (the sum of root timers that the % column is a share of). On a CLI run those differ by ~40% because the roots are sequential.

Each row ends with its own duration, right-aligned into one column and thousands-separated, so a bar too small to see still tells you what it cost. The number is heat-shaded on the same pale-amber-to-deep-red ramp as the Self column, with one difference in how it is scaled: a span's duration is inclusive, so scaling against the largest would paint the root red and leave everything below it uniformly pale — colour encoding depth rather than cost. The ceiling is the 95th percentile instead, and never below a tenth of the wall clock, so a 2ms cache read on a quiet run does not come out as red as a 700ms request.

Recording Something To Look At

The viewer reads the json output, so a run has to exist before there is anything in the dropdown:

MAGE_PROFILER=json bin/magento indexer:reindex          # CLI
MAGE_PROFILER=tabular,json bin/magento cache:clean      # terminal table + report
document.cookie = 'MAGE_PROFILER=json; path=/';         // web + API, in the browser console

The cookie accepts the same values as the environment variable — tabular,json and timeline both work there too. json is what this viewer reads; tabular writes a plain-text log instead, which is worth adding on CLI (it prints to STDERR as the command finishes) and rarely worth it on a web request, where there is no terminal to print to.

Cookie activation is gated — developer mode, or a :<secret> suffix matching MAGE_PROFILER_SECRET. See MagePsycho_Profiler for the full activation and security rules.

Retention

MagePsycho_Profiler prunes as it writes: newest MAGE_PROFILER_KEEP runs (default 100), nothing older than MAGE_PROFILER_KEEP_DAYS (default 7). A browsing session with the cookie set writes one report per request, AJAX included, so this matters. Clear all reports empties the directory and the index.

🛠️ Installation

This module requires MagePsycho_Profiler; Composer pulls it in automatically.

1 Using Composer (Preferred)

composer require magepsycho/magento2-profiler-ui

2 Using Modman

modman init
modman clone git@github.com:MagePsycho/magento2-profiler-ui.git

3 Using Zip File

  • Download the Extension Zip File
  • Extract & upload the files to /path/to/magento2/app/code/MagePsycho/ProfilerUi/

After installation by either means, activate the extension with following steps

  1. Enable the module
php bin/magento module:enable MagePsycho_ProfilerUi --clear-static-content
php bin/magento setup:upgrade
php bin/magento setup:di:compile
  1. Flush the store cache
php bin/magento cache:flush
  1. Deploy static content - in Production mode only
rm -rf pub/static/* var/view_preprocessed/*
php bin/magento setup:static-content:deploy
  1. Record a run, then open the viewer
MAGE_PROFILER=json php bin/magento cache:clean

System → Tools → Enhanced Profiler Reports

The extension creates no tables of its own.

Configuration

There is no store configuration. The viewer has nothing to configure — what it shows is decided by what MagePsycho_Profiler recorded, and that module's own settings live at Stores > Configuration > MagePsycho > Enhanced Profiler.

Access is controlled by role, not config: System > Permissions > User Roles > Role ResourcesEnhanced Profiler Reports (MagePsycho_ProfilerUi::viewer).

The MagePsycho_Profiler environment variables that change what the viewer sees:

Variable Effect here Default
MAGE_PROFILER_REPORT_DIR Directory the run picker lists var/log/profiler
MAGE_PROFILER_MAX_SPANS 0 records no spans, which disables the Timeline radio 5000
MAGE_PROFILER_KEEP Newest N runs retained 100
MAGE_PROFILER_KEEP_DAYS Maximum age retained 7

Security

The viewer renders data derived from untrusted input, so a few things are deliberate rather than incidental:

  • Every cell is written with textContent, never innerHTML. Timer ids carry client-controlled strings — GraphQL operation names and unmatched REST paths both end up in them — so a report can legitimately contain <script>. Rendering it as markup would turn a profiling tool into stored XSS in the admin panel.
  • The x-magento-init payload is encoded with JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT, so no literal < survives and a </script> breakout is impossible. HTML entities are not decoded inside <script>, so escapeHtml() would have produced invalid JSON — this is the correct guard.
  • The file parameter is basename()d and must appear in index.jsonl before anything is opened, on top of Directory\Read's own PathValidator. Verified against traversal, encoded dots, absolute paths, null bytes and sub/../index.jsonl.
  • All three controllers require MagePsycho_ProfilerUi::viewer; Clear all is POST + form key.
  • Reports live in var/log/profiler/, which is outside the document root (document_root_is_pub), so they are reachable only through this ACL-gated controller.
  • Requests to the viewer's own route are not profiled — otherwise browsing reports would bury the run you opened the viewer to look at.

Developer Notes

Files

Path Role
Model/ReportRepository.php Lists, reads and clears reports; owns the path guards
Controller/Adminhtml/Report/{Index,Data,Clear}.php Page, JSON endpoint, clear action
Block/Adminhtml/Report.php Run list + endpoints for x-magento-init
view/adminhtml/templates/report.phtml Markup; all output escaped
view/adminhtml/web/js/report.js Tree/timeline rendering, sibling sort, filter closure, sliders

Admin route and ACL

Piece Value
Route magepsycho_profiler/report/index
ACL resource MagePsycho_ProfilerUi::viewer (under Magento_Backend::system)
Menu Magento_Backend::system_tools → Enhanced Profiler Reports

etc/module.xml sequences after Magento_Backend and MagePsycho_Profiler.

Static analysis

vendor/bin/phpstan analyse -c app/code/MagePsycho/ProfilerUi/phpstan.neon --memory-limit=1G
vendor/bin/phpcs --standard=Magento2 --extensions=php,phtml app/code/MagePsycho/ProfilerUi/

Changelog

Version 1.0.4 (2026-08-18)

  • Click a SQL: row - in the tree or on the timeline - to see the statement behind it, syntax-highlighted, with its bind params and a Copy button. Requires a report recorded with MAGE_PROFILER_SQL=query; without one the viewer is unchanged.

Version 1.0.3 (2026-08-18)

  • Filtering and sorting now work inside the Tree and the Timeline, and the Flat view is gone. Hiding keeps each match's ancestor chain, dimmed, so nothing is ever orphaned; sorting reorders siblings under their own parent instead of flattening the hierarchy.
  • Reset button beside Expand all / Collapse all: every control back to its freshly-loaded state, keeping the selected run.
  • Timeline spans are linked to their parent by id path with a clock check, not by depth alone — a run whose outermost timer never closed used to indent the whole page render under an unrelated cache warm-up.

Version 1.0.2 (2026-08-12)

  • Timeline rows carry their own duration in a right-aligned column, thousands-separated and heat-shaded on the same ramp as the Self column — scaled to the 95th percentile, floored at a tenth of the wall clock, so inclusive parent spans do not paint the whole column red.

Version 1.0.1 (2026-08-11)

  • Match count under the meta strip: how many entries survived the filter, out of the run total, and which filters are active. Counts timers in the table and spans in the Timeline.
  • Show all rows toggle (on by default): filters highlight matches in place rather than hiding the rest, which keeps the tree available while filtering. Untick for the previous hide-and-flatten behaviour.

Version 1.0.0 (2026-08-08)

  • Initial Release.

Authors

  • Raj KB Twitter Follow

Contributors

Contributors

To Contribute

Any contribution to the development of Magento 2 Enhanced Profiler UI is highly welcome.
The best possibility to provide any code is to open a pull request on GitHub.

Need Support?

If you encounter any problems or bugs, please create an issue on GitHub.

Please visit our store for more FREE / paid extensions OR contact us for customization / development services.