diff --git a/src/Illuminate/Foundation/Cloud/FailedJobProvider.php b/src/Illuminate/Foundation/Cloud/FailedJobProvider.php index c28cd9d41f18..9b58714ac635 100644 --- a/src/Illuminate/Foundation/Cloud/FailedJobProvider.php +++ b/src/Illuminate/Foundation/Cloud/FailedJobProvider.php @@ -69,7 +69,16 @@ public function log($connection, $queue, $payload, $exception) 'started_at' => $processingJobDetails['started_at']->toDateTimeString('microsecond'), 'attempts' => $processingJobDetails['attempts'], 'payload' => $payload, - 'exception' => (string) mb_convert_encoding($exception, 'UTF-8'), + 'exception_preview' => mb_substr( + string: $exception->getMessage() + ? $exception::class.': '.$exception->getMessage().' in '.$exception->getFile().':'.$exception->getLine() + : $exception::class.' in '.$exception->getFile().':'.$exception->getLine(), + start: 0, + length: 1001, + encoding: 'UTF-8', + ), + 'job_name' => (json_decode($payload, associative: true) ?? [])['displayName'] ?? '', + 'exception' => (string) $exception, ]); $this->queue->finishProcessingJob(timestamp: $timestamp); diff --git a/tests/Foundation/Cloud/QueueTest.php b/tests/Foundation/Cloud/QueueTest.php index b596d8ba7234..b3277add2f26 100644 --- a/tests/Foundation/Cloud/QueueTest.php +++ b/tests/Foundation/Cloud/QueueTest.php @@ -477,7 +477,8 @@ public function testItEmitsFailedJobEvents() $job = $queue->pop(); $job->fail(); Str::createUuidsUsingSequence([Uuid::fromString('00dc709e-90c4-70c2-87c8-9b7127d20e8f')]); - $failedJobProvider->log('cloud', 'default', ['payload' => 'here'], new RuntimeException('Whoops!')); + $line = __LINE__ + 1; + $failedJobProvider->log('cloud', 'default', json_encode(['payload' => 'here', 'displayName' => 'App\\Jobs\\ProcessPodcast']), new RuntimeException('Whoops!')); Str::createUuidsNormally(); $queue->pop(); @@ -495,9 +496,9 @@ public function testItEmitsFailedJobEvents() 'queue' => 'default', 'started_at' => '2000-01-02 03:04:05.060708', 'attempts' => 1, - 'payload' => [ - 'payload' => 'here', - ], + 'payload' => json_encode(['payload' => 'here', 'displayName' => 'App\\Jobs\\ProcessPodcast']), + 'exception_preview' => 'RuntimeException: Whoops! in '.__FILE__.':'.$line, + 'job_name' => 'App\\Jobs\\ProcessPodcast', ], [ '_cloud_event' => 'queue', @@ -509,6 +510,170 @@ public function testItEmitsFailedJobEvents() ], $eventsFake->emitted); } + public function testItEmitsFailedJobEventsWithExceptionPreviewWithMessage() + { + $this->travelTo('2000-01-02 03:04:05.060708'); + $eventsFake = $this->fakeEvents(); + [$queue, $agent] = $this->fakeQueue(); + $failerFake = $this->fakeFailer(); + $failedJobProvider = new FailedJobProvider($failerFake, $eventsFake, $this->app['encrypter']); + $failedJobProvider->setQueue($queue); + $this->app[FailedJobProvider::class] = $failedJobProvider; + + $agent->pushJob(); + $job = $queue->pop(); + $job->fail(); + Str::createUuidsUsingSequence([Uuid::fromString('00dc709e-90c4-70c2-87c8-9b7127d20e8f')]); + $line = __LINE__ + 1; + $failedJobProvider->log('cloud', 'default', json_encode(['payload' => 'here']), new RuntimeException('Whoops!')); + Str::createUuidsNormally(); + $queue->pop(); + + $this->assertSame( + 'RuntimeException: Whoops! in '.__FILE__.':'.$line, + $eventsFake->emitted[1]['exception_preview'], + ); + } + + public function testItEmitsFailedJobEventsWithExceptionPreviewWithoutMessage() + { + $this->travelTo('2000-01-02 03:04:05.060708'); + $eventsFake = $this->fakeEvents(); + [$queue, $agent] = $this->fakeQueue(); + $failerFake = $this->fakeFailer(); + $failedJobProvider = new FailedJobProvider($failerFake, $eventsFake, $this->app['encrypter']); + $failedJobProvider->setQueue($queue); + $this->app[FailedJobProvider::class] = $failedJobProvider; + + $agent->pushJob(); + $job = $queue->pop(); + $job->fail(); + Str::createUuidsUsingSequence([Uuid::fromString('00dc709e-90c4-70c2-87c8-9b7127d20e8f')]); + $line = __LINE__ + 1; + $failedJobProvider->log('cloud', 'default', json_encode(['payload' => 'here']), new RuntimeException); + Str::createUuidsNormally(); + $queue->pop(); + + $this->assertSame( + 'RuntimeException in '.__FILE__.':'.$line, + $eventsFake->emitted[1]['exception_preview'], + ); + } + + public function testItTruncatesLongExceptionPreviews() + { + $this->travelTo('2000-01-02 03:04:05.060708'); + $eventsFake = $this->fakeEvents(); + [$queue, $agent] = $this->fakeQueue(); + $failerFake = $this->fakeFailer(); + $failedJobProvider = new FailedJobProvider($failerFake, $eventsFake, $this->app['encrypter']); + $failedJobProvider->setQueue($queue); + $this->app[FailedJobProvider::class] = $failedJobProvider; + + $agent->pushJob(); + $job = $queue->pop(); + $job->fail(); + $failedJobProvider->log('cloud', 'default', json_encode(['payload' => 'here']), new RuntimeException(str_repeat('a', 2000))); + $queue->pop(); + + $this->assertSame(1001, mb_strlen($eventsFake->emitted[1]['exception_preview'])); + $this->assertSame('RuntimeException: '.str_repeat('a', 1001 - strlen('RuntimeException: ')), $eventsFake->emitted[1]['exception_preview']); + } + + public function testItTruncatesExceptionPreviewsByWidthNotByteCountForMultibyteMessages() + { + $this->travelTo('2000-01-02 03:04:05.060708'); + $eventsFake = $this->fakeEvents(); + [$queue, $agent] = $this->fakeQueue(); + $failerFake = $this->fakeFailer(); + $failedJobProvider = new FailedJobProvider($failerFake, $eventsFake, $this->app['encrypter']); + $failedJobProvider->setQueue($queue); + $this->app[FailedJobProvider::class] = $failedJobProvider; + + $message = str_repeat('😎', 4).str_repeat('a', 2000); + + $agent->pushJob(); + $job = $queue->pop(); + $job->fail(); + $failedJobProvider->log('cloud', 'default', json_encode(['payload' => 'here']), new RuntimeException($message)); + $queue->pop(); + + $this->assertSame(1001, mb_strlen($eventsFake->emitted[1]['exception_preview'])); + $this->assertSame('RuntimeException: '.str_repeat('😎', 4).str_repeat('a', 1001 - 4 - strlen('RuntimeException: ')), $eventsFake->emitted[1]['exception_preview']); + } + + public function testItSanitizesInvalidUtf8InTheExceptionField() + { + $this->travelTo('2000-01-02 03:04:05.060708'); + $eventsFake = $this->fakeEvents(); + [$queue, $agent] = $this->fakeQueue(); + $failerFake = $this->fakeFailer(); + $failedJobProvider = new FailedJobProvider($failerFake, $eventsFake, $this->app['encrypter']); + $failedJobProvider->setQueue($queue); + $this->app[FailedJobProvider::class] = $failedJobProvider; + + $agent->pushJob(); + $job = $queue->pop(); + $job->fail(); + Str::createUuidsUsingSequence([Uuid::fromString('00dc709e-90c4-70c2-87c8-9b7127d20e8f')]); + $failedJobProvider->log('cloud', 'default', json_encode(['payload' => 'here']), new RuntimeException("Bad byte: \xFF")); + Str::createUuidsNormally(); + $queue->pop(); + + $this->assertTrue(mb_check_encoding($eventsFake->stream, 'UTF-8')); + $this->assertTrue(mb_check_encoding($eventsFake->emitted[1]['exception'], 'UTF-8')); + $this->assertStringContainsString('Bad byte: �', $eventsFake->emitted[1]['exception']); + $this->assertStringContainsString('Bad byte: ?', $eventsFake->emitted[1]['exception_preview']); + } + + public function testItEmitsFailedJobEventsWithJobDisplayName() + { + $this->travelTo('2000-01-02 03:04:05.060708'); + $eventsFake = $this->fakeEvents(); + [$queue, $agent] = $this->fakeQueue(); + $failerFake = $this->fakeFailer(); + $failedJobProvider = new FailedJobProvider($failerFake, $eventsFake, $this->app['encrypter']); + $failedJobProvider->setQueue($queue); + $this->app[FailedJobProvider::class] = $failedJobProvider; + + $agent->pushJob(); + $job = $queue->pop(); + $job->fail(); + Str::createUuidsUsingSequence([Uuid::fromString('00dc709e-90c4-70c2-87c8-9b7127d20e8f')]); + $failedJobProvider->log('cloud', 'default', json_encode(['displayName' => 'App\\Jobs\\ProcessPodcast']), new RuntimeException('Whoops!')); + Str::createUuidsNormally(); + $queue->pop(); + + $this->assertSame( + 'App\\Jobs\\ProcessPodcast', + $eventsFake->emitted[1]['job_name'], + ); + } + + public function testItEmitsFailedJobEventsWithoutJobDisplayName() + { + $this->travelTo('2000-01-02 03:04:05.060708'); + $eventsFake = $this->fakeEvents(); + [$queue, $agent] = $this->fakeQueue(); + $failerFake = $this->fakeFailer(); + $failedJobProvider = new FailedJobProvider($failerFake, $eventsFake, $this->app['encrypter']); + $failedJobProvider->setQueue($queue); + $this->app[FailedJobProvider::class] = $failedJobProvider; + + $agent->pushJob(); + $job = $queue->pop(); + $job->fail(); + Str::createUuidsUsingSequence([Uuid::fromString('00dc709e-90c4-70c2-87c8-9b7127d20e8f')]); + $failedJobProvider->log('cloud', 'default', json_encode(['payload' => 'here']), new RuntimeException('Whoops!')); + Str::createUuidsNormally(); + $queue->pop(); + + $this->assertSame( + '', + $eventsFake->emitted[1]['job_name'], + ); + } + public function testItEmitsReleasedJobEvents() { $this->travelTo('2000-01-02 03:04:05.060708'); @@ -1638,13 +1803,20 @@ private function fakeEvents() return $this->app->instance(Events::class, new class('test-socket') extends Events { public array $emitted = []; + public string $stream = ''; + + protected function connected(): bool + { + return true; + } - public function emitMany(array $payloads): void + protected function write(string $payload): void { - $this->emitted = [ - ...$this->emitted, - ...$payloads, - ]; + $this->stream .= $payload; + + foreach (explode("\n", rtrim($payload, "\n")) as $write) { + $this->emitted[] = json_decode($write, associative: true); + } } }); }