diff --git a/.docs/README.md b/.docs/README.md
index 26ef805..cb1aa28 100644
--- a/.docs/README.md
+++ b/.docs/README.md
@@ -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
@@ -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"
diff --git a/src/BlueScreen/SanitizedMarkdownBlueScreen.php b/src/BlueScreen/SanitizedMarkdownBlueScreen.php
new file mode 100644
index 0000000..07a774a
--- /dev/null
+++ b/src/BlueScreen/SanitizedMarkdownBlueScreen.php
@@ -0,0 +1,32 @@
+ 'Sanitized Markdown',
+ 'panel' => sprintf('
%s
', Helpers::escapeHtml($this->renderer->render($e))),
+ 'collapsed' => true,
+ ];
+ }
+
+}
diff --git a/src/DI/SanitizedMarkdownExtension.php b/src/DI/SanitizedMarkdownExtension.php
new file mode 100644
index 0000000..58bc33e
--- /dev/null
+++ b/src/DI/SanitizedMarkdownExtension.php
@@ -0,0 +1,35 @@
+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')]);
+ }
+
+}
diff --git a/src/SanitizedDumper.php b/src/SanitizedDumper.php
new file mode 100644
index 0000000..5101440
--- /dev/null
+++ b/src/SanitizedDumper.php
@@ -0,0 +1,114 @@
+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 $seenArrayReferences
+ * @param list> $seenArrays
+ * @return string|array
+ */
+ 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;
+ }
+ }
+ }
+
+ $referenceId = is_array($itemValue)
+ ? ReflectionReference::fromArrayElement($value, $itemKey)?->getId()
+ : null;
+
+ if ($referenceId !== null && isset($seenArrayReferences[$referenceId])) {
+ $sanitized[$itemKey] = '*RECURSION* array';
+ continue;
+ }
+
+ $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)]));
+ }
+
+}
diff --git a/src/SanitizedMarkdownRenderer.php b/src/SanitizedMarkdownRenderer.php
new file mode 100644
index 0000000..00a7dbb
--- /dev/null
+++ b/src/SanitizedMarkdownRenderer.php
@@ -0,0 +1,319 @@
+dumper->summarize($throwable->getMessage())),
+ sprintf('File: %s:%d', $this->formatFile($throwable->getFile()), $throwable->getLine()),
+ ];
+
+ $previous = array_slice(Helpers::getExceptionChain($throwable), 1);
+ if ($previous !== []) {
+ $lines[] = '';
+ $lines[] = '## Previous Exceptions';
+
+ foreach ($previous as $index => $item) {
+ $lines[] = sprintf('%d. %s', $index + 1, get_debug_type($item));
+ $lines[] = sprintf(' Message: %s', $this->dumper->summarize($item->getMessage()));
+ $lines[] = sprintf(' File: %s:%d', $this->formatFile($item->getFile()), $item->getLine());
+ }
+ }
+
+ $lines[] = '';
+ $lines[] = '## Stack Trace';
+
+ foreach ($this->createFrames($throwable) as $index => $frame) {
+ $lines[] = sprintf('#%d %s', $index, $this->formatLocation($frame['file'], $frame['line']));
+ $lines[] = sprintf(' %s(%s)', $frame['call'], $this->formatArguments($frame['args']));
+ }
+
+ $lines[] = '';
+ $lines[] = '## Request Context';
+
+ foreach ($this->renderRequestContext() as $line) {
+ $lines[] = $line;
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * @return list}>
+ */
+ private function createFrames(Throwable $throwable): array
+ {
+ $trace = $throwable->getTrace();
+
+ if ($trace === []) {
+ return [[
+ 'file' => $throwable->getFile(),
+ 'line' => $throwable->getLine(),
+ 'call' => '{main}',
+ 'args' => [],
+ ]];
+ }
+
+ $frames = [];
+
+ foreach ($trace as $index => $row) {
+ $args = isset($row['args'])
+ ? array_values($row['args'])
+ : [];
+
+ $frames[] = [
+ 'file' => $index === 0 ? $throwable->getFile() : ($trace[$index - 1]['file'] ?? null),
+ 'line' => $index === 0 ? $throwable->getLine() : ($trace[$index - 1]['line'] ?? null),
+ 'call' => $this->formatCall($row),
+ 'args' => $args,
+ ];
+ }
+
+ return $frames;
+ }
+
+ /**
+ * @param array $frame
+ */
+ private function formatCall(array $frame): string
+ {
+ $function = $frame['function'] ?? null;
+ if (is_string($function) && str_starts_with($function, '{closure')) {
+ return 'Closure';
+ }
+
+ $class = $frame['class'] ?? null;
+ if (is_string($class) && is_string($function)) {
+ return $class . (is_string($frame['type'] ?? null) ? $frame['type'] : '::') . $function;
+ }
+
+ return is_string($function) ? $function : '{main}';
+ }
+
+ /**
+ * @param list $arguments
+ */
+ private function formatArguments(array $arguments): string
+ {
+ if ($arguments === []) {
+ return '';
+ }
+
+ return implode(', ', array_map(fn (mixed $argument): string => $this->dumper->summarize($argument), $arguments));
+ }
+
+ /**
+ * @return list
+ */
+ private function renderRequestContext(): array
+ {
+ $lines = [];
+
+ if ($this->isHttpRequest()) {
+ $url = $this->createHttpUrl();
+ if ($url !== null) {
+ $lines[] = sprintf('URL: %s', $this->dumper->summarize($url));
+ }
+
+ if (isset($_SERVER['REQUEST_METHOD'])) {
+ $lines[] = sprintf('Method: %s', $this->dumper->summarize($_SERVER['REQUEST_METHOD']));
+ }
+
+ $this->appendSection($lines, 'Headers', $this->dumper->sanitize($this->extractHeaders()));
+ $this->appendSection($lines, 'Query', $this->dumper->sanitize($_GET));
+ $this->appendSection($lines, 'POST', $this->extractRequestBody());
+
+ return $lines;
+ }
+
+ $command = $this->createCliCommand();
+ if ($command !== null) {
+ $lines[] = sprintf('Command: %s', $this->dumper->summarize($command));
+ }
+
+ $this->appendSection($lines, 'Arguments', $this->dumper->sanitize($_SERVER['argv'] ?? []));
+
+ return $lines;
+ }
+
+ /**
+ * @param list $lines
+ * @param string|array $value
+ */
+ private function appendSection(array &$lines, string $label, string|array $value): void
+ {
+ if (is_string($value)) {
+ $lines[] = sprintf('%s: %s', $label, $value);
+ return;
+ }
+
+ if ($value === []) {
+ $lines[] = sprintf('%s: array(0)', $label);
+ return;
+ }
+
+ $lines[] = sprintf('%s:', $label);
+
+ foreach ($this->renderMapping($value) as $line) {
+ $lines[] = $line;
+ }
+ }
+
+ /**
+ * @param array $values
+ * @return list
+ */
+ private function renderMapping(array $values, int $depth = 1): array
+ {
+ $lines = [];
+ $indent = str_repeat(' ', $depth);
+
+ foreach ($values as $key => $value) {
+ if (is_array($value)) {
+ if ($value === []) {
+ $lines[] = sprintf('%s%s: array(0)', $indent, (string) $key);
+ continue;
+ }
+
+ $lines[] = sprintf('%s%s:', $indent, (string) $key);
+ array_push($lines, ...$this->renderMapping($value, $depth + 1));
+ continue;
+ }
+
+ if (!is_string($value)) {
+ $value = $this->dumper->summarize($value);
+ }
+
+ $lines[] = sprintf('%s%s: %s', $indent, (string) $key, $value);
+ }
+
+ return $lines;
+ }
+
+ /** @return array */
+ private function extractHeaders(): array
+ {
+ if (function_exists('apache_request_headers')) {
+ $headers = apache_request_headers();
+ ksort($headers);
+ return $headers;
+ }
+
+ $headers = [];
+
+ foreach ($_SERVER as $key => $value) {
+ if (!is_string($value)) {
+ continue;
+ }
+
+ if (str_starts_with($key, 'HTTP_')) {
+ $headers[$this->normalizeHeaderName(substr($key, 5))] = $value;
+ continue;
+ }
+
+ if (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5', 'AUTH_TYPE'], true)) {
+ $headers[$this->normalizeHeaderName($key)] = $value;
+ }
+ }
+
+ ksort($headers);
+
+ return $headers;
+ }
+
+ /** @return string|array */
+ private function extractRequestBody(): string|array
+ {
+ if ($_POST !== []) {
+ return $this->dumper->sanitize($_POST);
+ }
+
+ if (!in_array($_SERVER['REQUEST_METHOD'] ?? null, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
+ return [];
+ }
+
+ $rawBody = file_get_contents('php://input');
+ if (!is_string($rawBody) || $rawBody === '') {
+ return [];
+ }
+
+ $decoded = json_decode($rawBody, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ return is_array($decoded)
+ ? $this->dumper->sanitize($decoded)
+ : $this->dumper->summarize($decoded);
+ }
+
+ return $this->dumper->summarize($rawBody);
+ }
+
+ private function isHttpRequest(): bool
+ {
+ return isset($_SERVER['REQUEST_METHOD']) || isset($_SERVER['REQUEST_URI']) || isset($_SERVER['HTTP_HOST']);
+ }
+
+ private function createHttpUrl(): ?string
+ {
+ if (!isset($_SERVER['REQUEST_URI']) && !isset($_SERVER['HTTP_HOST']) && !isset($_SERVER['SERVER_NAME'])) {
+ return null;
+ }
+
+ $scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && strcasecmp((string) $_SERVER['HTTPS'], 'off') !== 0
+ ? 'https://'
+ : 'http://';
+ $host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
+
+ return $scheme . $host . ($_SERVER['REQUEST_URI'] ?? '');
+ }
+
+ private function createCliCommand(): ?string
+ {
+ if (!isset($_SERVER['argv']) || !is_array($_SERVER['argv'])) {
+ return null;
+ }
+
+ return implode(' ', array_map(static fn (mixed $value): string => is_scalar($value) ? (string) $value : get_debug_type($value), $_SERVER['argv']));
+ }
+
+ private function normalizeHeaderName(string $key): string
+ {
+ return str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $key))));
+ }
+
+ private function formatLocation(?string $file, ?int $line): string
+ {
+ if ($file === null) {
+ return 'inner-code';
+ }
+
+ return sprintf('%s:%s', $this->formatFile($file), $line ?? '?');
+ }
+
+ private function formatFile(string $file): string
+ {
+ $normalizedFile = str_replace('\\', '/', $file);
+ $cwd = getcwd();
+ $normalizedCwd = $cwd !== false
+ ? str_replace('\\', '/', $cwd)
+ : '';
+
+ if ($normalizedCwd !== '' && str_starts_with($normalizedFile, $normalizedCwd . '/')) {
+ return substr($normalizedFile, strlen($normalizedCwd) + 1);
+ }
+
+ return $normalizedFile;
+ }
+
+}
diff --git a/tests/Cases/DI/SanitizedMarkdownExtension.phpt b/tests/Cases/DI/SanitizedMarkdownExtension.phpt
new file mode 100644
index 0000000..5d37cd3
--- /dev/null
+++ b/tests/Cases/DI/SanitizedMarkdownExtension.phpt
@@ -0,0 +1,38 @@
+getProperty('panels');
+
+ Assert::count(0, $panelsrf->getValue(Debugger::getBlueScreen()));
+
+ $container = ContainerBuilder::of()
+ ->withCompiler(static function (Compiler $compiler): void {
+ $compiler->addExtension('sanitizedMarkdown', new SanitizedMarkdownExtension());
+ })
+ ->build();
+
+ Assert::count(0, $panelsrf->getValue(Debugger::getBlueScreen()));
+
+ $container->initialize();
+
+ Assert::count(1, $panelsrf->getValue(Debugger::getBlueScreen()));
+ Assert::type(SanitizedMarkdownRenderer::class, $container->getByType(SanitizedMarkdownRenderer::class));
+
+ $panel = $panelsrf->getValue(Debugger::getBlueScreen())[0];
+ $output = $panel(new RuntimeException('super-secret-message'));
+
+ Assert::same('Sanitized Markdown', $output['tab']);
+ Assert::contains('Message: string(20)', $output['panel']);
+ Assert::notContains('super-secret-message', $output['panel']);
+});
diff --git a/tests/Cases/Sanitized/SanitizedDumper.phpt b/tests/Cases/Sanitized/SanitizedDumper.phpt
new file mode 100644
index 0000000..f2b023d
--- /dev/null
+++ b/tests/Cases/Sanitized/SanitizedDumper.phpt
@@ -0,0 +1,44 @@
+keysToHide[] = 'token';
+
+ $payload = [
+ 'secret_tree' => ['apiKey' => 'super-secret-key'],
+ 'nested' => [
+ 'token' => ['value' => 'abc-123'],
+ 'profile' => [
+ 'email' => 'customer@example.com',
+ 'active' => true,
+ 'flags' => [1, 2, 3],
+ ],
+ ],
+ 'nothing' => null,
+ 'price' => 1250,
+ 'callback' => static fn (): string => 'secret',
+ 'request' => new stdClass(),
+ ];
+ $payload['self'] = [];
+ $payload['self']['parent'] = &$payload;
+
+ $sanitized = $dumper->sanitize($payload);
+
+ Assert::same('array(1)', $sanitized['secret_tree']);
+ Assert::same('array(1)', $sanitized['nested']['token']);
+ Assert::same('string(20)', $sanitized['nested']['profile']['email']);
+ Assert::same('bool', $sanitized['nested']['profile']['active']);
+ Assert::same('int(4)', $sanitized['price']);
+ Assert::same('null', $sanitized['nothing']);
+ Assert::same('Closure', $sanitized['callback']);
+ Assert::same('object(stdClass)', $sanitized['request']);
+ Assert::same('*RECURSION* array', $sanitized['self']['parent']);
+});
diff --git a/tests/Cases/Sanitized/SanitizedMarkdownRenderer.phpt b/tests/Cases/Sanitized/SanitizedMarkdownRenderer.phpt
new file mode 100644
index 0000000..6fa814a
--- /dev/null
+++ b/tests/Cases/Sanitized/SanitizedMarkdownRenderer.phpt
@@ -0,0 +1,85 @@
+ 'card',
+ ];
+ $_POST = [
+ 'order_id' => 123,
+ 'payload' => [
+ 'customer' => [
+ 'email' => 'customer@example.com',
+ ],
+ 'items' => [
+ 1,
+ 2,
+ ],
+ ],
+ ];
+
+ ini_set('zend.exception_ignore_args', '0');
+
+ $renderer = new SanitizedMarkdownRenderer(new SanitizedDumper());
+
+ try {
+ checkout($_POST);
+ Assert::fail('Exception was not thrown.');
+ } catch (RuntimeException $e) {
+ $markdown = $renderer->render($e);
+
+ Assert::contains('## Error: RuntimeException', $markdown);
+ Assert::contains('Message: string(40)', $markdown);
+ Assert::contains('Method: string(4)', $markdown);
+ Assert::contains('URL: string(47)', $markdown);
+ Assert::contains('Authorization: string(23)', $markdown);
+ Assert::contains('Content-Type: string(16)', $markdown);
+ Assert::contains('Host: string(17)', $markdown);
+ Assert::contains('gateway: string(4)', $markdown);
+ Assert::contains('order_id: int(3)', $markdown);
+ Assert::contains('email: string(20)', $markdown);
+ Assert::contains('processPayment(int(3), string(22), array(2))', $markdown);
+ Assert::contains('checkout(array(2))', $markdown);
+ Assert::contains('Closure()', $markdown);
+ Assert::notContains('4111111111111111', $markdown);
+ Assert::notContains('tok_live_secret_123456', $markdown);
+ Assert::notContains('Bearer top-secret-token', $markdown);
+ Assert::notContains('customer@example.com', $markdown);
+ Assert::notContains('/checkout?gateway=card', $markdown);
+ }
+
+ $_SERVER = $originalServer;
+ $_GET = $originalGet;
+ $_POST = $originalPost;
+
+ if ($originalIgnoreArgs !== false) {
+ ini_set('zend.exception_ignore_args', (string) $originalIgnoreArgs);
+ }
+});