Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@

## Unreleased

### Fixed

- Fixed failed requests never being captured while the application exception handler was active. `Illuminate\Routing\Pipeline` renders throwables into responses before they reach global middleware, so the middleware `catch` block was unreachable outside of `withoutExceptionHandling()`.
- Fixed `laratimecode.replay.restore_auth_user` being ignored: `timecode:replay` always passed an explicit `false` when `--auth` was absent.
- Fixed truncated strings being reported with a literal `\n` instead of a newline.
- Fixed multibyte strings being cut mid-character on truncation, which produced invalid UTF-8 and made the whole snapshot fail to encode.
- Fixed `timecode:list` failing outright when a single snapshot was unreadable, for example after an `APP_KEY` rotation. Unreadable snapshots are now skipped and reported to the log.
- Fixed snapshots being lost when request data contained invalid UTF-8 bytes.
- Fixed the service provider throwing when no HTTP kernel is bound.

### Changed

- `ignore_exceptions` now also lists `AuthorizationException`, `RecordsNotFoundException`, and `TokenMismatchException`. Capture sees the original throwable rather than the one Laravel maps it to, so 403, 404, and 419 responses were being recorded despite the intent of the shipped defaults.
- Added `ext-mbstring`, `illuminate/auth`, `illuminate/session`, `psr/log`, `symfony/http-foundation`, and `symfony/http-kernel` as explicit dependencies.

## 0.1.0-alpha.1 - 2026-08-17

- Added PHP 8.2–8.5 and Laravel 12–13 support.
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@

## Не выпущено

### Исправлено

- Исправлено: неудачные запросы вообще не сохранялись при активном обработчике исключений. `Illuminate\Routing\Pipeline` превращает исключение в ответ до возврата в глобальный middleware, поэтому блок `catch` был недостижим вне `withoutExceptionHandling()`.
- Исправлено: параметр `laratimecode.replay.restore_auth_user` не учитывался — команда `timecode:replay` всегда передавала явный `false` при отсутствии `--auth`.
- Исправлено: в обрезанных строках вместо перевода строки подставлялся литерал `\n`.
- Исправлено: обрезка многобайтовых строк разрывала символ, ломала UTF-8 и приводила к потере всего снимка при кодировании.
- Исправлено: команда `timecode:list` падала целиком из-за одного нечитаемого снимка, например после смены `APP_KEY`. Такие файлы теперь пропускаются с записью в журнал.
- Исправлено: снимок терялся, если данные запроса содержали недопустимые байты UTF-8.
- Исправлено: провайдер падал при отсутствии привязанного HTTP-ядра.

### Изменено

- В `ignore_exceptions` добавлены `AuthorizationException`, `RecordsNotFoundException` и `TokenMismatchException`. Захват видит исходное исключение, а не то, в которое его преобразует Laravel, поэтому ответы 403, 404 и 419 сохранялись вопреки замыслу поставляемых настроек.
- Явно объявлены зависимости `ext-mbstring`, `illuminate/auth`, `illuminate/session`, `psr/log`, `symfony/http-foundation` и `symfony/http-kernel`.

## 0.1.0-alpha.1 — 2026-08-17

- Добавлена поддержка PHP 8.2–8.5 и Laravel 12–13.
Expand Down
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,19 @@
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.2",
"illuminate/auth": "^12.0|^13.0",
"illuminate/console": "^12.0|^13.0",
"illuminate/contracts": "^12.0|^13.0",
"illuminate/database": "^12.0|^13.0",
"illuminate/filesystem": "^12.0|^13.0",
"illuminate/http": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0"
"illuminate/session": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"psr/log": "^2.0|^3.0",
"symfony/http-foundation": "^7.0|^8.0",
"symfony/http-kernel": "^7.0|^8.0"
},
"require-dev": {
"laravel/pint": "^1.24",
Expand Down
6 changes: 6 additions & 0 deletions config/laratimecode.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

declare(strict_types=1);

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\RecordsNotFoundException;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

Expand Down Expand Up @@ -51,7 +54,10 @@

'ignore_exceptions' => [
AuthenticationException::class,
AuthorizationException::class,
NotFoundHttpException::class,
RecordsNotFoundException::class,
TokenMismatchException::class,
ValidationException::class,
],
],
Expand Down
29 changes: 29 additions & 0 deletions src/Capture/FailureRecorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use LaraTimeCode\Contracts\SnapshotRepository;
use LaraTimeCode\Redaction\Redactor;
use LaraTimeCode\Support\SnapshotId;
use Psr\Log\LoggerInterface;
use Throwable;

final class FailureRecorder
Expand All @@ -22,8 +23,36 @@ public function __construct(
private readonly CaptureContext $context,
private readonly Config $config,
private readonly Application $app,
private readonly LoggerInterface $logger,
) {}

public function captureIfNeeded(Request $request, Throwable $exception): ?string
{
$recordedId = $request->attributes->get('_laratimecode_id');

if (is_string($recordedId)) {
return $recordedId;
}

if (! $this->shouldCapture($request, $exception)) {
return null;
}

try {
$id = $this->capture($request, $exception);
} catch (Throwable $captureException) {
$this->logger->warning('LaraTimeCode could not capture a failed request.', [
'exception' => $captureException,
]);

return null;
}

$request->attributes->set('_laratimecode_id', $id);

return $id;
}

public function shouldCapture(Request $request, Throwable $exception): bool
{
if (! (bool) $this->config->get('laratimecode.enabled', false)) {
Expand Down
2 changes: 1 addition & 1 deletion src/Commands/ReplayTimeCodeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function handle(SnapshotRepository $snapshots, SnapshotReplayer $replayer
return self::FAILURE;
}

$result = $replayer->replay($snapshot, (bool) $this->option('auth'));
$result = $replayer->replay($snapshot, $this->option('auth') ? true : null);
$this->components->twoColumnDetail('Duration', number_format($result->durationMs, 2).' ms');
$this->components->twoColumnDetail('Expected exception', $result->expectedException ?? 'none');
$this->components->twoColumnDetail(
Expand Down
22 changes: 9 additions & 13 deletions src/Http/Middleware/CaptureFailures.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Illuminate\Http\Request;
use LaraTimeCode\Capture\CaptureContext;
use LaraTimeCode\Capture\FailureRecorder;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

Expand All @@ -19,7 +18,6 @@ public function __construct(
private readonly FailureRecorder $recorder,
private readonly CaptureContext $context,
private readonly Config $config,
private readonly LoggerInterface $logger,
) {}

public function handle(Request $request, Closure $next): Response
Expand All @@ -43,19 +41,17 @@ public function handle(Request $request, Closure $next): Response
$this->context->start();

try {
return $next($request);
} catch (Throwable $exception) {
if ($this->recorder->shouldCapture($request, $exception)) {
try {
$id = $this->recorder->capture($request, $exception);
$request->attributes->set('_laratimecode_id', $id);
} catch (Throwable $captureException) {
$this->logger->warning('LaraTimeCode could not capture a failed request.', [
'exception' => $captureException,
]);
}
$response = $next($request);
$rendered = $response->exception ?? null;

if ($rendered instanceof Throwable) {
$this->recorder->captureIfNeeded($request, $rendered);
}

return $response;
} catch (Throwable $exception) {
$this->recorder->captureIfNeeded($request, $exception);

throw $exception;
} finally {
$this->context->stop();
Expand Down
42 changes: 38 additions & 4 deletions src/LaraTimeCodeServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

namespace LaraTimeCode;

use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Client\Events\ConnectionFailed;
use Illuminate\Http\Client\Events\ResponseReceived;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider;
use LaraTimeCode\Capture\CaptureContext;
use LaraTimeCode\Capture\FailureRecorder;
use LaraTimeCode\Capture\FrameworkEventRecorder;
use LaraTimeCode\Commands\DeleteTimeCodeCommand;
use LaraTimeCode\Commands\ListTimeCodesCommand;
Expand All @@ -21,6 +24,8 @@
use LaraTimeCode\Http\Middleware\CaptureFailures;
use LaraTimeCode\Redaction\Redactor;
use LaraTimeCode\Storage\FileSnapshotRepository;
use Psr\Log\LoggerInterface;
use Throwable;

final class LaraTimeCodeServiceProvider extends ServiceProvider
{
Expand All @@ -29,6 +34,7 @@ public function register(): void
$this->mergeConfigFrom(__DIR__.'/../config/laratimecode.php', 'laratimecode');

$this->app->scoped(CaptureContext::class, static fn (): CaptureContext => new CaptureContext);
$this->app->scoped(FrameworkEventRecorder::class);

$this->app->singleton(Redactor::class, function (): Redactor {
return new Redactor(
Expand All @@ -44,6 +50,7 @@ public function register(): void
encrypter: $app->make('encrypter'),
directory: (string) config('laratimecode.storage.path'),
encrypt: (bool) config('laratimecode.storage.encrypt', true),
logger: $app->make(LoggerInterface::class),
);
});
}
Expand Down Expand Up @@ -83,11 +90,38 @@ public function boot(Dispatcher $events): void
}

$this->app->booted(function (): void {
$kernel = $this->app->make(Kernel::class);
$this->registerMiddleware();
$this->registerExceptionHook();
});
}

if (method_exists($kernel, 'prependMiddleware')) {
$kernel->prependMiddleware(CaptureFailures::class);
}
private function registerMiddleware(): void
{
if (! $this->app->bound(Kernel::class)) {
return;
}

$kernel = $this->app->make(Kernel::class);

if (method_exists($kernel, 'prependMiddleware')) {
$kernel->prependMiddleware(CaptureFailures::class);
}
}

private function registerExceptionHook(): void
{
if (! $this->app->bound(ExceptionHandler::class)) {
return;
}

$handler = $this->app->make(ExceptionHandler::class);

if (! method_exists($handler, 'renderable')) {
return;
}

$handler->renderable(function (Throwable $exception, Request $request): void {
$this->app->make(FailureRecorder::class)->captureIfNeeded($request, $exception);
});
}
}
8 changes: 5 additions & 3 deletions src/Redaction/Redactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,11 @@ private function truncate(string $value): string
return $value;
}

return substr($value, 0, $this->maxStringLength).sprintf(
'\n[TRUNCATED %d BYTES]',
strlen($value) - $this->maxStringLength,
$truncated = mb_strcut($value, 0, $this->maxStringLength, 'UTF-8');

return $truncated.sprintf(
"\n[TRUNCATED %d BYTES]",
strlen($value) - strlen($truncated),
);
}
}
17 changes: 15 additions & 2 deletions src/Storage/FileSnapshotRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
namespace LaraTimeCode\Storage;

use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Filesystem\Filesystem;
use JsonException;
use LaraTimeCode\Contracts\SnapshotRepository;
use LaraTimeCode\Support\SnapshotId;
use Psr\Log\LoggerInterface;
use RuntimeException;

final class FileSnapshotRepository implements SnapshotRepository
Expand All @@ -20,6 +22,7 @@ public function __construct(
private readonly Encrypter $encrypter,
private readonly string $directory,
private readonly bool $encrypt = true,
private readonly ?LoggerInterface $logger = null,
) {}

public function save(array $snapshot): string
Expand All @@ -35,7 +38,8 @@ public function save(array $snapshot): string
try {
$json = json_encode(
$snapshot,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
| JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR,
);
} catch (JsonException $exception) {
throw new RuntimeException('Unable to encode the LaraTimeCode snapshot.', 0, $exception);
Expand Down Expand Up @@ -94,7 +98,16 @@ public function all(): array
$snapshots = [];

foreach ($this->files->glob($this->directory.'/*.repro') ?: [] as $path) {
$snapshot = $this->find(pathinfo($path, PATHINFO_FILENAME));
try {
$snapshot = $this->find(pathinfo($path, PATHINFO_FILENAME));
} catch (RuntimeException|FileNotFoundException $exception) {
$this->logger?->warning('LaraTimeCode skipped an unreadable snapshot.', [
'path' => $path,
'exception' => $exception,
]);

continue;
}

if ($snapshot !== null) {
$snapshots[] = $snapshot;
Expand Down
Loading