Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions .docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Tuned Tracy Bars/Panels/BlueScreens for easy-developing.
- [Setup](#setup)
- [TracyBlueScreens - better BlueScreen panels](#tracybluescreen)
- [Logger - register additional Tracy loggers](#logger)
- [Sanitized Markdown - safe AI-friendly exception export](#sanitized-markdown)

## Setup

Expand Down Expand Up @@ -37,5 +38,20 @@ extensions:

Extra loggers can then be wired in your app and added to the multi logger.

## Sanitized Markdown

`SanitizedMarkdownExtension` registers a BlueScreen panel that renders a sanitized Markdown document for the current exception. Values are replaced by type descriptors like `string(40)` or `array(3)`, so the output can be shared with an AI agent without leaking request or payload data.

```neon
extensions:
tracy.sanitizedMarkdown: Contributte\Tracy\DI\SanitizedMarkdownExtension
```

You can also inject `Contributte\Tracy\SanitizedMarkdownRenderer` and render the Markdown string directly:

```php
$markdown = $sanitizedMarkdownRenderer->render($throwable);
```

[container-builder-parameters]: https://raw.githubusercontent.com/contributte/tracy/master/.docs/assets/container-builder-parameters.png "Container Builder - parameters"
[container-builder-definitions]: https://raw.githubusercontent.com/contributte/tracy/master/.docs/assets/container-builder-definitions.png "Container Builder - definitions"
32 changes: 32 additions & 0 deletions src/BlueScreen/SanitizedMarkdownBlueScreen.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);

namespace Contributte\Tracy\BlueScreen;

use Contributte\Tracy\SanitizedMarkdownRenderer;
use Throwable;
use Tracy\Helpers;

class SanitizedMarkdownBlueScreen
{

public function __construct(private SanitizedMarkdownRenderer $renderer)
{
}

/**
* @return array{tab: string, panel: string, collapsed: bool}|null
*/
public function __invoke(?Throwable $e): ?array
{
if ($e === null) {
return null;
}

return [
'tab' => 'Sanitized Markdown',
'panel' => sprintf('<pre>%s</pre>', Helpers::escapeHtml($this->renderer->render($e))),
'collapsed' => true,
];
}

}
35 changes: 35 additions & 0 deletions src/DI/SanitizedMarkdownExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php declare(strict_types = 1);

namespace Contributte\Tracy\DI;

use Contributte\Tracy\BlueScreen\SanitizedMarkdownBlueScreen;
use Contributte\Tracy\SanitizedDumper;
use Contributte\Tracy\SanitizedMarkdownRenderer;
use Nette\DI\CompilerExtension;
use Nette\PhpGenerator\ClassType;
use Tracy\Debugger;

class SanitizedMarkdownExtension extends CompilerExtension
{

public function loadConfiguration(): void
{
$builder = $this->getContainerBuilder();

$builder->addDefinition($this->prefix('dumper'))
->setFactory(SanitizedDumper::class);

$builder->addDefinition($this->prefix('renderer'))
->setFactory(SanitizedMarkdownRenderer::class);

$builder->addDefinition($this->prefix('blueScreen'))
->setFactory(SanitizedMarkdownBlueScreen::class);
}

public function afterCompile(ClassType $class): void
{
$class->getMethod('initialize')
->addBody('?::getBlueScreen()->addPanel($this->getService(?));', [Debugger::class, $this->prefix('blueScreen')]);
}

}
114 changes: 114 additions & 0 deletions src/SanitizedDumper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php declare(strict_types = 1);

namespace Contributte\Tracy;

use ReflectionReference;
use Tracy\Debugger;

class SanitizedDumper
{

public function summarize(mixed $value, ?string $key = null, ?string $class = null): string
{
if ($value instanceof \SensitiveParameterValue) {
$value = $value->getValue();
}

return match (true) {
$value === null => 'null',
is_bool($value) => 'bool',
is_int($value) => sprintf('int(%d)', strlen(ltrim((string) $value, '-'))),
is_float($value) => sprintf('float(%d)', strlen((string) $value)),
is_string($value) => sprintf('string(%d)', strlen($value)),
is_array($value) => sprintf('array(%d)', count($value)),
$value instanceof \Closure => 'Closure',
is_object($value) => sprintf('object(%s)', $value::class),
is_resource($value) => sprintf('resource(%s)', get_resource_type($value)),
default => get_debug_type($value),
};
}

/**
* @param array<int|string, bool> $seenArrayReferences
* @param list<array<int|string, mixed>> $seenArrays
* @return string|array<int|string, mixed>
*/
public function sanitize(mixed $value, ?string $key = null, ?string $class = null, int $depth = 0, array $seenArrayReferences = [], array $seenArrays = []): string|array
{
if ($this->isSensitive($key, $value, $class)) {
return $this->summarize($value, $key, $class);
}

if (!is_array($value)) {
return $this->summarize($value, $key, $class);
}

if ($depth >= Debugger::getBlueScreen()->maxDepth) {
return $this->summarize($value, $key, $class);
}

$sanitized = [];
$maxItems = Debugger::getBlueScreen()->maxItems;
$seenArrays[] = $value;

foreach ($value as $itemKey => $itemValue) {
if ($maxItems > 0 && count($sanitized) >= $maxItems) {
$sanitized['...'] = sprintf('omitted(%d)', count($value) - $maxItems);
break;
}

if (is_array($itemValue)) {
foreach ($seenArrays as $seenArray) {
if ($itemValue === $seenArray) {
$sanitized[$itemKey] = '*RECURSION* array';
continue 2;

Check failure on line 64 in src/SanitizedDumper.php

View workflow job for this annotation

GitHub Actions / Codesniffer / Codesniffer (8.4)

Expected 1 line before "continue", found 0.
}
}
}

$referenceId = is_array($itemValue)
? ReflectionReference::fromArrayElement($value, $itemKey)?->getId()
: null;

if ($referenceId !== null && isset($seenArrayReferences[$referenceId])) {
$sanitized[$itemKey] = '*RECURSION* array';
continue;

Check failure on line 75 in src/SanitizedDumper.php

View workflow job for this annotation

GitHub Actions / Codesniffer / Codesniffer (8.4)

Expected 1 line before "continue", found 0.
}

$nextSeenArrayReferences = $seenArrayReferences;
if ($referenceId !== null) {
$nextSeenArrayReferences[$referenceId] = true;
}

$sanitized[$itemKey] = $this->sanitize(
$itemValue,
is_int($itemKey) ? (string) $itemKey : $itemKey,
null,
$depth + 1,
$nextSeenArrayReferences,
$seenArrays,
);
}

return $sanitized;
}

private function isSensitive(?string $key, mixed $value, ?string $class = null): bool
{
if ($value instanceof \SensitiveParameterValue) {
return true;
}

if ($key === null) {
return false;
}

$blueScreen = Debugger::getBlueScreen();
$keysToHide = array_flip(array_map(strtolower(...), array_merge(Debugger::$keysToHide, $blueScreen->keysToHide)));

return ($blueScreen->scrubber !== null && ($blueScreen->scrubber)($key, $value, $class))
|| isset($keysToHide[strtolower($key)])
|| ($class !== null && isset($keysToHide[strtolower($class . '::$' . $key)]));
}

}
Loading
Loading