mage-os / module-profiler-admin-ui
Admin viewer for the JSON profiler reports written by mage-os/module-profiler: a collapsible call tree, a sortable and filterable table, and a timeline of every call.
Package info
github.com/mage-os-lab/module-profiler-admin-ui
Type:magento2-module
pkg:composer/mage-os/module-profiler-admin-ui
Requires
- php: ^8.2
- mage-os/module-profiler: *
- magento/framework: ^103.0 || ^104.0
- magento/module-backend: ^102.0 || ^103.0
Requires (Dev)
- bitexpert/phpstan-magento: ^0.43
- dealerdirect/phpcodesniffer-composer-installer: *
- mage-os/magento-coding-standard: *
- mage-os/php-compatibility-fork: *
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Mage-OS Profiler Admin UI
Overview
Mage-OS Profiler Admin UI is the admin viewer for the json reports written by MageOS_Profiler.
System → Tools → 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 MageOS_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
MageOS_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:
Key Features
- Admin page at System → Tools → 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 withMAGE_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
- Searchable run picker — type to filter the run list on anything in the label (timestamp, SAPI, method, URL, duration, span count); matches are highlighted, and arrows / Enter / Escape pick one without the mouse
- 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
MageOS_Profilerwrites; 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.
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.
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.
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.
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 MageOS_Profiler for the full activation and security rules.
Retention
MageOS_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 MageOS_Profiler; Composer pulls it in automatically.
1 Using Composer (Preferred)
composer require mage-os/module-profiler-admin-ui
2 Using Modman
modman init
modman clone git@github.com:mage-os-lab/module-profiler-admin-ui.git
3 Using Zip File
- Download the Extension Zip File
- Extract & upload the module files to
/path/to/magento2/app/code/MageOS/ProfilerAdminUi/
After installation by either means, activate the extension with following steps
- Enable the module
php bin/magento module:enable MageOS_ProfilerAdminUi --clear-static-content
php bin/magento setup:upgrade
php bin/magento setup:di:compile
- Flush the store cache
php bin/magento cache:flush
- Deploy static content - in Production mode only
rm -rf pub/static/* var/view_preprocessed/*
php bin/magento setup:static-content:deploy
- Record a run, then open the viewer
MAGE_PROFILER=json php bin/magento cache:clean
System → Tools → 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 MageOS_Profiler recorded, and that module's own settings live at Stores > Configuration > Advanced > Developer > Profiler Settings.
Access is controlled by role, not config: System > Permissions > User Roles > Role Resources → Profiler Reports (MageOS_ProfilerAdminUi::viewer).
The MageOS_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, neverinnerHTML. 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-initpayload is encoded withJSON_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>, soescapeHtml()would have produced invalid JSON — this is the correct guard. - The
fileparameter isbasename()d and must appear inindex.jsonlbefore anything is opened, on top ofDirectory\Read's ownPathValidator. Verified against traversal, encoded dots, absolute paths, null bytes andsub/../index.jsonl. - All three controllers require
MageOS_ProfilerAdminUi::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 | mageos_profiler/report/index |
| ACL resource | MageOS_ProfilerAdminUi::viewer (under Magento_Backend::system) |
| Menu | Magento_Backend::system_tools → Profiler Reports |
etc/module.xml sequences after Magento_Backend and MageOS_Profiler.
Static analysis
# standalone, from this package's checkout — the same thing CI runs composer update vendor/bin/phpstan analyse vendor/bin/phpunit -c phpunit.xml.dist # or against an install, from the Magento root vendor/bin/phpcs --standard=Magento2 app/code/MageOS/ProfilerAdminUi/ vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/MageOS/ProfilerAdminUi/Test/Unit
Authors
- Raj KB — original author, who contributed this module to Mage-OS.
To Contribute
Any contribution to the development of Mage-OS Profiler Admin 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.
License
Dual-licensed under the Open Software License 3.0 and the Academic Free License 3.0, matching Mage-OS and Magento Open Source.




