Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
42d145e
cs
dg May 11, 2026
c915ed0
phpstan.neon: narrow ignore
dg May 27, 2026
038935d
phpstan: added stubs for ext-ds 2.0 classes unknown to phpstorm-stubs
dg Jul 17, 2026
3abad89
TracyExtension: added @property for config
dg May 19, 2026
8b436a2
tools: added latte-convert template compiler
dg May 19, 2026
da1ecbf
readme: added Yii3 Integration (#599)
beastbytes Apr 12, 2026
b6973d1
added AGENTS.md & DOCS
dg Feb 20, 2026
199abfa
Debugger: dump() with return: true respects $keysToHide and passes op…
dg Jul 11, 2026
85d212f
Debugger: extracted dumpOptions() and agentDumpOptions(), removes tri…
dg Jul 11, 2026
f43d646
Dumper: $keysToHide and scrubber are applied to properties returned b…
dg Jul 11, 2026
99791a9
Dumper: support for modern PHP constructs
dg Jul 18, 2026
4a28ef5
FileSession: hardening (session file chmod 0600, unserialize without …
dg Jul 11, 2026
b316e94
DevelopmentStrategy: exception fallback output is HTML-escaped in HTM…
dg Jul 11, 2026
d6456b3
BlueScreen: formatMessage() does not trigger autoloading of class nam…
dg Jul 11, 2026
fe326dc
Bar: fetch() monkey-patch no longer drops options argument and does n…
dg Jul 11, 2026
51a3244
Logger: sendEmail() validates $emailSnooze, snooze check is atomic an…
dg Jul 11, 2026
8697536
assets: corrupt localStorage/sessionStorage entry no longer breaks Ba…
dg Jul 11, 2026
f6d5437
TracyToPsrLoggerAdapter: exception is not logged twice when message a…
dg Jul 11, 2026
9ed8781
TracyExtension: 'editor: false' in config disables editor links
dg Jul 11, 2026
a4931b8
DeferredContent: repeated enable() is a no-op
dg Jul 18, 2026
38213af
Describer: exposer ordering comparator is a valid total order, descri…
dg Jul 18, 2026
2b6ae99
assets: removed legacy browser workarounds
dg Jul 11, 2026
852f586
agent detection: X-Tracy-Agent request header
dg Jul 11, 2026
b3517f0
assets: hardened AJAX capture and dump initialization
dg Jul 11, 2026
88c82f9
opened 2.13-dev
dg Mar 26, 2026
d30e70b
assets: real event target is obtained via Tracy.retarget()
dg Jul 18, 2026
a5cd51b
assets: added Tracy.css registry and adoptedStyleSheets helpers
dg Jul 18, 2026
7e0230d
added Shadow DOM for CSS isolation of Bar, BlueScreen and Dumper
dg Jul 18, 2026
3a63c43
removed CSS scoping superseded by Shadow DOM
dg Jul 18, 2026
d0ec154
CSS: added @layer for cascade priority control
dg Jul 18, 2026
4b8cf4d
opened 3.0-dev
dg Dec 10, 2021
3f9a499
open in editor: added support for columns
dg Nov 24, 2025
3636bee
uses PascalCase constants
dg Dec 5, 2022
c1f741d
Logger: added typehints WIP
dg Feb 3, 2022
3b76b91
error.log changed to warning.log
dg Apr 3, 2024
efbf873
used attribute Deprecated
dg Nov 23, 2024
813e97c
fix: inspect raw property values without executing hooks
mildabre Aug 17, 2026
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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
.gitattributes export-ignore
.github/ export-ignore
.gitignore export-ignore
AGENTS.md export-ignore
ncs.* export-ignore
phpstan*.neon export-ignore
src/**/*.latte export-ignore
docs/ export-ignore
tests/ export-ignore
tools/latte-convert/ export-ignore

*.php* diff=php
*.sh text eol=lf
tools/latte-convert/tests/fixtures/*.latte text eol=lf
tools/latte-convert/tests/fixtures/*.phtml text eol=lf
82 changes: 82 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# To My Agents!

It is my fervent wish that this file guide every AI coding agent working with code in this repository.

## Documentation

Any distilled, agent-facing documentation for this package - how it works
internally and the rationale behind key design decisions - lives in `docs/`.
Consult it before non-trivial changes; it is the source of truth from which the
public manual is distilled.

Tracy is several independent mechanisms that share little context (error handling,
deferred content, the dumper, BlueScreen, the Bar, the logger). Read the relevant
`docs/internals/` seam before editing one - especially `deferred-content.md`, the
most counterintuitive part.

## Project Overview

Tracy is a debugging and error-visualization library for PHP: BlueScreen error
pages, the floating debug Bar with an extensible panel system, an advanced variable
Dumper, and a production error Logger. It auto-detects development vs production and
emits **markdown to the JS console for automated browsers** (`navigator.webdriver`).

- **PHP Version**: 8.2 - 8.5
- **Package**: `tracy/tracy` (currently v2.12)

## Essential Commands

```bash
# Run all tests - HTML tests only run under php-cgi, so pass -p php-cgi
vendor/bin/tester tests -p php-cgi -s
vendor/bin/tester tests/Dumper/ -s

# Static analysis (PHPStan level 8)
composer phpstan

# JavaScript assets
npm run lint # and lint:fix

# Rebuild templates: .latte/*.agent.latte assets -> .phtml in dist/
composer compile-templates
```

## Conventions

- Every PHP file starts with `declare(strict_types=1);`; **tabs**; return type and
opening brace on separate lines; Nette Coding Standard (`ncs.php`). JS is linted
with `@nette/eslint-plugin`.
- Tests are Nette Tester `.phpt` using `test()` and `getTempDir()`. **CI runs both
`php` and `php-cgi`**; UI-rendering tests need `php-cgi`.
- Templates are `.latte` (HTML-escaping) / `*.agent.latte` (text/markdown, no
escaping) compiled to committed `.phtml` in `dist/` via
`composer compile-templates` - edit the source, rebuild.

## Working in this repo

- **`enable()` does NOT start an output buffer.** It records `$obLevel` and strips
buffers *above* it (`removeOutputBuffers`). Handler registration order is
shutdown -> exception -> error; strategy/dispatch run before registration;
`$reserved` is the double-render guard. See `docs/internals/error-handling.md`.
- **`DeferredContent` is the counterintuitive core.** The Bar/BlueScreen survive a
redirect and ride AJAX responses **through the session**: content is written by
reference, then the browser fetches `?_tracy_bar=content.<id>` and consumes it
**once**. `FileSession` holds `LOCK_EX` for the whole request and writes only in
`__destruct` (a crash loses it).
- **The Dumper is two-phase: describe -> render, over a snapshot.** Cycles are
broken at describe time (`TypeRef` depth guard) and labelled at render time.
Bar/BlueScreen share one live snapshot per page.
- **BlueScreen panels are called repeatedly** (once per exception in the chain,
plus once with `null`).
- **The Logger dedups by an `xxh128` hash** (same exception -> same file, no
overwrite) and throttles email via an email-sent mtime (`emailSnooze`).
- **CSS isolation uses a `<tracy-div>` host element plus an aggressive
`reset.css`** (no Shadow DOM) - the Bar and BlueScreen (`<tracy-div id="tracy-bs">`)
both live inside `<tracy-div>` wrappers in the regular DOM.
- **The PHP <-> JS boundary is coupled purely by strings** (function names,
element ids, attribute and storage keys) with no static checking - before
renaming anything on either side, see `docs/internals/js-contract.md`.
- Agent detection is `Helpers::isAgent()` reading the `tracy-webdriver` cookie set by
`bar.js`; that path feeds the console-markdown output.
- User-facing how-to (configuration, custom panels/loggers/scrubbers, CSP, editor
integration, session/nginx recipes) is manual material and lives in the web docs.
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@
"minimum-stability": "dev",
"scripts": {
"phpstan": "phpstan analyse",
"tester": "tester tests"
"tester": "tester tests",
"compile-templates": "@php tools/latte-convert/compile.php src/Tracy"
},
"extra": {
"branch-alias": {
"dev-master": "2.12-dev"
"dev-master": "3.0-dev"
}
},
"config": {
Expand Down
45 changes: 45 additions & 0 deletions docs/internals/bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Bar

The debug toolbar is a collection of `IBarPanel`s rendered **after the response
body**, through `DeferredContent` (see deferred-content.md).

## Panels

`addPanel(IBarPanel $panel, ?string $id = null)` stores the panel under an id
auto-derived from its class (suffixed `-2`, `-3`… on collision). Note the
**panel**, not the Bar, carries `getTab()`/`getPanel()` — `Bar` exposes only
`getPanel($id)`. `renderPanels()` calls each panel's `getTab()` and, only if the tab
is non-empty, `getPanel()`; it wraps rendering in a temporary error handler
(errors become `ErrorException`s) and unwinds output buffers on a throw — a
throwing panel is caught and replaced with an "Error in `<id>`" panel.
`renderAgent()` produces the markdown line `Tracy Bar | <ms> | <MB>` plus each
panel's *optional* `getAgentInfo()` (probed via `method_exists`; it is not part of
the `IBarPanel` contract).

The built-in panels are `DefaultBarPanel`s backed by `.phtml` templates: `info` and
`warnings` (registered when the Bar is created; `warnings` is filled by
`errorHandler`), and `dumps` (registered lazily on the first `barDump()`).
**The ids `Tracy:info` and `Tracy:warnings` are load-bearing strings**:
`DevelopmentStrategy` fetches them by exact id and writes their public/dynamic
properties (`cpuUsage`, `$data`) from outside — `getPanel()` returns `null` for an
unknown id, so renaming a registration is a runtime fatal, not a graceful
degradation. `DefaultBarPanel` needs `#[\AllowDynamicProperties]` for the same
reason.

## Rendering is deferred and mode-dependent

`render(DeferredContent $defer)` branches:

- **AJAX/deferred** → `addSetup('Tracy.Debug.loadAjax', renderPartial('ajax'))`.
- **Redirect** → push the partial onto the session `redirect` queue.
- **Normal HTML** → render the `main` partial, **drain the redirect queue** (reverse
order, then set to `null` — the queue is a by-reference session item, so draining
is a persistent session mutation) so Bars from prior redirects appear now, then
either `addSetup('Tracy.Debug.init', …)` if the loader already ran, or `require`
`loader.phtml` directly. If a `Content-Length` header was already sent (the
injected markup would corrupt it), it only logs a `LogicException` — rendering
proceeds unchanged.

`renderLoader()` requires an available session (else "Start session before Tracy is
enabled.") and emits the loader early so the toolbar can appear even when the rest
of the page is slow.
52 changes: 52 additions & 0 deletions docs/internals/bluescreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# BlueScreen

`render()` builds the HTML error page from `page.phtml`; `renderToAjax` defers it
(`addSetup('Tracy.BlueScreen.loadAjax', …)`), `renderToFile` writes it with
`fopen(…, 'x')` (so an existing file is never overwritten) plus a `.md` companion,
and `renderAgent` produces the markdown variant. `renderTemplate` is the shared core
that assembles headers, CSS/JS assets, the dumpers, and a **live shared snapshot**
(`$this->snapshot = []; $snapshot = &$this->snapshot[0]`) before `require`-ing the
template.

## Panels are callbacks, called repeatedly

`addPanel(callable)` stores a `Closure(?Throwable): ?array{tab, panel}`. It is
invoked **multiple times with different arguments** during a render:

- once **per exception in the chain** (`section-exception.phtml` is re-`require`d
for every `getPrevious()` link, each time calling `renderPanels($ex)`),
- plus once **with `null`** (below the call stack, `content.phtml`).

So a chain of N exceptions means N+1 invocations. A panel wanting to appear at the
very bottom returns `bottom: true`, which defers it to `$bottomPanels`. A panel
callback must tolerate both a `Throwable` and `null`, and is responsible for
rendering the right thing in each pass. Empty tab/panel results are skipped; a
throwing panel becomes an "Error in panel" block. (Separate from panels:
`addAction`, `addFileGenerator`, `addFiber`.)

## Stack, highlighting, and the two dumpers

`prepareStack` strips Tracy's own frames (`DevelopmentStrategy`/`ProductionStrategy`,
`Debugger::shutdownHandler`/`errorHandler`) from the trace and returns
`[$stack, $expanded]` — the index of the single frame to auto-expand, computed from
`Debugger::$transparentPaths` (the deprecated `$collapsePaths` is still merged in,
so it remains functionally live); the `tracy-collapsed` class itself is applied in
the template by comparing against that index. `CodeHighlighter` tokenizes with
`\PhpToken`, maps tokens to CSS classes, shows ~15 lines around the error,
highlights the line and column, and replaces `/*sensitive{*/…/*}*/` regions with
`*****` (`Describer::HiddenValue`) before highlighting (PHP path only, not the
plain-text one).

Two dumpers exist: `getDumper()` renders **HTML** (`maxDepth` — default 5,
`maxLength`/`maxItems`, `LOCATION_CLASS`, the shared `SNAPSHOT`, scrubber,
`keysToHide`) and feeds the page templates; `getAgentDumper()` renders
**text/markdown** (hardcoded depth 3, no snapshot, no location) and feeds only the
`agent.phtml` markdown variant. Note `keysToHide` includes `BlueScreen::$snapshot`
itself, so the internal snapshot never leaks into a dump.

**Ordering invariant:** the shared snapshot is populated by reference *while* the
template renders each dump; its serialized form is written only at the very end of
`content.phtml` into `<meta itemprop=tracy-snapshot …>`. Moving that meta tag
before the dumps (or dumping after it) silently breaks collapsed-dump expansion on
the client. `renderTemplate` resets `$this->snapshot` both before and after the
`require`, so dumps outside that window are not captured.
57 changes: 57 additions & 0 deletions docs/internals/deferred-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Deferred content: surviving redirects & riding AJAX

The most counterintuitive mechanism in Tracy. The Bar and BlueScreen cannot always
render into the current response (a redirect has no body; an AJAX response is not
the page). `DeferredContent` bridges that gap through the **session**, and the
content is delivered by a **second HTTP request the browser makes for it**.

## The three defer paths (from `Bar::render`)

- **AJAX** → `addSetup('Tracy.Debug.loadAjax', <partial>)`. (The AJAX/deferred flag
is decided once in the `DeferredContent` constructor — `X-Tracy-Ajax` header
matching `^\w{10,15}$` — `Bar::render` only checks `isDeferred()`.)
- **Redirect** (a `Location:` header is present) → nothing is emitted; the content is
pushed onto a `redirect` queue in the session.
- **Normal HTML** → the main partial is rendered *and the redirect queue is drained*
(reversed, appended, then `null`ed), so content accumulated during prior redirects
finally appears on the next real page. BlueScreen's AJAX path is analogous:
`addSetup('Tracy.BlueScreen.loadAjax', <html>)`.

## `addSetup` writes JS into the session; the browser fetches it back

`addSetup($method, $arg)` appends `"$method($arg);\n"` to
`getItems('setup')[$requestId]['code']` — and `getItems` returns a **reference into
the session data**, so the write lands directly in the session. The request that
*produces* debug output stores it under its own `requestId`; the browser then makes
a separate `GET ?_tracy_bar=content.<requestId>` (or `content-ajax.<id>`), which
`dispatch()`/`sendAssets()` answers by pulling the stored `code` out of the session,
**`unset`ting it (one-time consumption)**, and returning it as JavaScript. That is
how a redirect's Bar shows up after the redirect completes.

`?_tracy_bar=js` serves the merged static assets once with a long `Cache-Control`
(the CSS is minified, the JS only IIFE-wrapped and concatenated). `clean()` keeps
only the last 10 items per key and only those younger than 60 seconds — and it runs
inside `sendAssets()` *before* the content fetch is answered, so a payload older
than 60 s is gone by the time the browser asks for it. **Every item stored in the
session must carry a `time` key**, or `clean()` silently discards it (`addSetup`
and the redirect push both stamp `time()`).

**Ordering invariant:** `isAvailable()` is `$useSession && sessionStorage->isAvailable()`,
and `$useSession` is set **only inside `sendAssets()`**. So all deferral works only
because `dispatch()` → `sendAssets()` runs early in `enable()`; drop that call and
every `isAvailable()` gate in Bar/BlueScreen goes false, silently disabling deferral.

## `FileSession` locking is coarse — and that is a trap

The default `FileSession` (cookie `tracy-session`, file `tracy-<id>`) takes a
**blocking `flock(LOCK_EX)`** on first access and **holds it for the entire
request**, writing and unlocking only in `__destruct`. Consequences to respect:

- concurrent requests sharing the cookie (an AJAX call plus the main page)
**serialize** — they block each other;
- a crash without a clean shutdown **loses** the pending writes (no truncate/write);
- `isAvailable()` is **not** a read-only probe — it opens and locks the file.

`FileSession` also has its own file GC, unrelated to `clean()`: session files older
than a week are deleted with probability 0.03 on open. `NativeSession` stores under
`$_SESSION['_tracy']` and is available only when a PHP session is active.
70 changes: 70 additions & 0 deletions docs/internals/dumper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Dumper

Dumping is **two phases** and rendering (in the default `lazy = null` mode) is
**not single-pass**.

## Describe → render

`Dumper` is a facade over a `Describer` and a `Renderer`; `asHtml`/`asTerminal` run
`describe($var)` (phase 1) then `render($model)` (phase 2).

- **`Describer`** produces a model `{value, snapshot, location}`. A scalar stays a
**native PHP value** only when the JSON round-trip is lossless — ints within the
JS-safe range, finite non-integer-valued floats, strings that `encodeString`
leaves unchanged; everything else (a short binary string, `5.0`, `NAN`, structures)
becomes a `Value` object — so the "tree" is a mix of native values and `Value`s.
`maxLength` truncation applies only at `depth > 0`; a top-level string is never
truncated.
- **`Exposer`** extracts object properties by reflection, including private/protected
(via mangled keys `"\x00Class\x00name"` / `"\x00*\x00name"`) and marks dynamic
properties. Exposer/exporter dispatch is **not insertion order**: `describe()`
`uksort`s `objectExposers` most-derived-first and the first match wins (`''`
matches everything).
- **`Renderer`** `match`-dispatches on `Value::Type*`.

## The snapshot: objects/refs are stored once, referenced by placeholder

Objects, resources, and referenced arrays are **not serialized inline**. Each is
put into a shared `snapshot` array keyed by `spl_object_id` / `r<id>` / `p<refId>`,
and at the point of use a `Value` of type **`TypeRef`** is emitted. The renderer
dereferences a `TypeRef` back through the snapshot. This is why an object appearing
in many places is expanded once. Two invariants hang off this:

- **`Value->holder` pins the live object** so GC cannot recycle its
`spl_object_id` — the snapshot key. Dropping `holder` allows key collisions in a
shared/live snapshot.
- **Infinite recursion is broken at describe time**: re-encountering an
object/array at equal-or-greater depth yields a `TypeRef` instead of descending.

Three lazy modes drive how much goes to the client:

- **`lazy = false`** — pure server-side HTML, no snapshot.
- **`lazy = true`** — the whole value goes into `data-tracy-dump` + the snapshot into
`data-tracy-snapshot`; the JS renders it. Only for non-empty arrays and objects —
a scalar falls through to the collapsed-parts branch and renders server-side.
- **`lazy = null`** (default, "collapsed parts") — HTML is rendered, but collapsed
nodes are serialized as refs and **only the reachable slice** of the snapshot
(`copySnapshot` → `snapshotSelection`) is emitted, so clicking a collapsed node
expands it from client-side data.

For the Bar and BlueScreen the snapshot is **shared/live** across all dumps on the
page (`Dumper::$liveSnapshot` or a passed `SNAPSHOT` array + `collectingMode`) and
is written **once** for the whole page, by the templates themselves: they read
`$liveSnapshot[0]` / `BlueScreen::$snapshot[0]` directly into a
`<meta itemprop=tracy-snapshot>` tag and then reset it. (The public
`formatSnapshotAttribute()` helper is for third-party integrations — nothing in
`src` calls it.)
In collecting mode `copySnapshot` is a **no-op** — the reachable-slice mechanism
applies only to standalone dumps; the live snapshot is emitted whole.

## Depth, hiding, and cycles

Defaults: `maxDepth = 7`, `maxLength = 150`, `maxItems = 100`. Sensitive values
(`SensitiveParameterValue`, the `scrubber`, or a key/`Class::$key` in `keysToHide`)
render as `***** (type)`. **Cycles are broken at describe time (the `TypeRef`
depth guard above) but classified at render time:** the renderer tracks `parents`
(open on the current path) and `above` (already rendered) by id, labelling a ref
`RECURSION` for a true cycle and `see above` / `see below` for a non-cyclic repeat.

(There is no `Dumper::addExporter()` — object exporters are added to the static
`$objectExporters` / the `OBJECT_EXPORTERS` option.)
Loading