Skip to content

Commit d18cee3

Browse files
committed
Capture request body on unhandled exception
1 parent f6458b2 commit d18cee3

File tree

6 files changed

+191
-0
lines changed

6 files changed

+191
-0
lines changed

config/nightwatch.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
'deployment' => env('NIGHTWATCH_DEPLOY'),
77
'server' => env('NIGHTWATCH_SERVER', (string) gethostname()),
88
'capture_exception_source_code' => env('NIGHTWATCH_CAPTURE_EXCEPTION_SOURCE_CODE', true),
9+
'capture_request_body' => env('NIGHTWATCH_CAPTURE_REQUEST_BODY', false),
10+
'redact_keys' => explode(',', env('NIGHTWATCH_REDACT_KEYS', 'password,password_confirmation')),
911
'redact_headers' => explode(',', env('NIGHTWATCH_REDACT_HEADERS', 'Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN')),
1012

1113
'sampling' => [

src/NightwatchServiceProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ final class NightwatchServiceProvider extends ServiceProvider
121121
* server?: string,
122122
* ingest?: array{ uri?: string, timeout?: float|int, connection_timeout?: float|int, event_buffer?: int },
123123
* capture_exception_source_code?: bool,
124+
* capture_request_body?: bool,
125+
* redact_keys?: string[],
124126
* redact_headers?: string[],
125127
* }
126128
*/
@@ -252,6 +254,8 @@ private function buildAndRegisterCore(): void
252254
publicPath: $this->app->publicPath(),
253255
),
254256
captureExceptionSourceCode: (bool) ($this->nightwatchConfig['capture_exception_source_code'] ?? true),
257+
captureRequestBody: (bool) ($this->nightwatchConfig['capture_request_body'] ?? false),
258+
redactKeys: $this->nightwatchConfig['redact_keys'] ?? ['password', 'password_confirmation'],
255259
redactHeaders: $this->nightwatchConfig['redact_headers'] ?? ['Authorization', 'Cookie', 'Proxy-Authorization', 'X-XSRF-TOKEN'],
256260
config: $this->config,
257261
),

src/Records/Request.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace Laravel\Nightwatch\Records;
44

5+
use Symfony\Component\HttpFoundation\FileBag;
56
use Symfony\Component\HttpFoundation\HeaderBag;
7+
use Symfony\Component\HttpFoundation\InputBag;
68

79
final class Request
810
{
@@ -23,6 +25,8 @@ public function __construct(
2325
public readonly int $requestSize,
2426
public readonly int $responseSize,
2527
public HeaderBag $headers,
28+
public ?InputBag $payload,
29+
public FileBag $files,
2630
) {
2731
//
2832
}

src/SensorManager.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,16 @@ final class SensorManager
128128
public $commandSensor;
129129

130130
/**
131+
* @param list<string> $redactKeys
131132
* @param list<string> $redactHeaders
132133
*/
133134
public function __construct(
134135
private RequestState|CommandState $executionState,
135136
private Clock $clock,
136137
public Location $location,
137138
private bool $captureExceptionSourceCode,
139+
private bool $captureRequestBody,
140+
private array $redactKeys,
138141
private array $redactHeaders,
139142
private Repository $config,
140143
) {
@@ -158,6 +161,8 @@ public function request(Request $request, Response $response): array
158161
{
159162
$sensor = $this->requestSensor ??= new RequestSensor(
160163
requestState: $this->executionState, // @phpstan-ignore argument.type
164+
captureBody: $this->captureRequestBody,
165+
redactKeys: $this->redactKeys,
161166
redactHeaders: $this->redactHeaders,
162167
);
163168

src/Sensors/RequestSensor.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,31 @@
33
namespace Laravel\Nightwatch\Sensors;
44

55
use Illuminate\Http\Request;
6+
use Illuminate\Http\UploadedFile;
67
use Illuminate\Routing\Route;
8+
use Illuminate\Support\Arr;
79
use Laravel\Nightwatch\Concerns\RecordsContext;
810
use Laravel\Nightwatch\Concerns\RedactsHeaders;
911
use Laravel\Nightwatch\ExecutionStage;
1012
use Laravel\Nightwatch\Records\Request as RequestRecord;
1113
use Laravel\Nightwatch\State\RequestState;
1214
use Laravel\Nightwatch\Types\Str;
1315
use Symfony\Component\HttpFoundation\BinaryFileResponse;
16+
use Symfony\Component\HttpFoundation\InputBag;
1417
use Symfony\Component\HttpFoundation\Response;
1518
use Throwable;
1619

20+
use function array_map;
1721
use function array_sum;
1822
use function hash;
1923
use function implode;
24+
use function in_array;
25+
use function is_array;
2026
use function is_int;
2127
use function is_numeric;
2228
use function is_string;
2329
use function json_encode;
30+
use function rescue;
2431
use function sort;
2532
use function strlen;
2633
use function tap;
@@ -34,10 +41,13 @@ final class RequestSensor
3441
use RedactsHeaders;
3542

3643
/**
44+
* @param list<string> $redactKeys
3745
* @param list<string> $redactHeaders
3846
*/
3947
public function __construct(
4048
private RequestState $requestState,
49+
private bool $captureBody,
50+
private array $redactKeys,
4151
private array $redactHeaders,
4252
) {
4353
//
@@ -91,6 +101,8 @@ public function __invoke(Request $request, Response $response): array
91101
$headers->remove('php-auth-pw');
92102
$headers->remove('php-auth-digest');
93103
}),
104+
payload: rescue(fn () => $request->getPayload(), report: false),
105+
files: clone $request->files,
94106
),
95107
function () use ($record) {
96108
return [
@@ -139,6 +151,18 @@ function () use ($record) {
139151
'exception_preview' => Str::tinyText($this->requestState->exceptionPreview),
140152
'context' => $this->serializedContext(),
141153
'headers' => Str::text(json_encode((object) $this->redactHeaders($record->headers, $this->redactHeaders)->all(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION)),
154+
'body' => $this->captureBody && $record->statusCode === 500
155+
? Str::text(json_encode([
156+
'payload' => $record->payload instanceof InputBag ? $this->redactRecursively($record->payload->all()) : null,
157+
'files' => array_map(fn (UploadedFile $file) => [
158+
'client_name' => $file->getClientOriginalName(),
159+
'client_mime_type' => $file->getClientMimeType(),
160+
'mime_type' => $file->getMimeType(),
161+
'size' => $file->getSize(),
162+
'path' => $file->getPathname(),
163+
], $record->files->all()),
164+
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))
165+
: '',
142166
];
143167
},
144168
];
@@ -169,4 +193,19 @@ private function parseResponseSize(Response $response): int
169193
// streamed response, e.g., echo Nightwatch::streaming($content);
170194
return 0;
171195
}
196+
197+
/**
198+
* @param array<mixed> $array
199+
* @return array<mixed>
200+
*/
201+
private function redactRecursively(array $array): array
202+
{
203+
return Arr::map($array, function ($value, $key) {
204+
if (is_array($value)) {
205+
return $this->redactRecursively($value);
206+
}
207+
208+
return ! in_array($key, $this->redactKeys, true) || ! is_string($value) ? $value : '['.strlen($value).' bytes redacted]';
209+
});
210+
}
172211
}

tests/Feature/Sensors/RequestSensorTest.php

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Illuminate\Contracts\Support\Arrayable;
1414
use Illuminate\Foundation\Http\Events\RequestHandled;
1515
use Illuminate\Http\Request;
16+
use Illuminate\Http\UploadedFile;
1617
use Illuminate\Support\Facades\App;
1718
use Illuminate\Support\Facades\Config;
1819
use Illuminate\Support\Facades\Context;
@@ -26,6 +27,7 @@
2627
use Orchestra\Testbench\Attributes\WithEnv;
2728
use Tests\TestCase;
2829

30+
use function array_keys;
2931
use function fseek;
3032
use function fwrite;
3133
use function hash;
@@ -112,6 +114,7 @@ public function test_it_can_ingest_requests(): void
112114
'exception_preview' => '',
113115
'context' => Compatibility::$contextExists ? '{}' : '',
114116
'headers' => '{"host":["localhost"],"user-agent":["Symfony"],"accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"accept-language":["en-us,en;q=0.5"],"accept-charset":["ISO-8859-1,utf-8;q=0.7,*;q=0.7"]}',
117+
'body' => '',
115118
],
116119
]);
117120
}
@@ -932,6 +935,140 @@ public function test_it_handles_unconventional_headers(): void
932935
});
933936
}
934937

938+
#[WithEnv('NIGHTWATCH_CAPTURE_REQUEST_BODY', 'true')]
939+
public function test_it_captures_a_form_request_body_on_unhandled_exceptions(): void
940+
{
941+
$ingest = $this->fakeIngest();
942+
Route::patch('/register', function () {
943+
throw new Exception('Whoops!');
944+
});
945+
946+
$response = $this
947+
->patch('/register?redirect=1', [
948+
'user' => [
949+
'username' => 'taylor',
950+
'password' => '$f4c4d3',
951+
],
952+
'avatar' => UploadedFile::fake()->create('avatar.jpg', 1, 'image/jpeg'),
953+
]);
954+
955+
$response->assertInternalServerError();
956+
$ingest->assertWrittenTimes(1);
957+
$ingest->assertLatestWrite('request:0.body', function ($body) {
958+
$body = json_decode($body, true);
959+
$this->assertNotNull($body);
960+
$this->assertSame([
961+
'user' => [
962+
'username' => 'taylor',
963+
'password' => '[7 bytes redacted]',
964+
],
965+
], $body['payload']);
966+
$this->assertCount(1, $body['files']);
967+
$this->assertEqualsCanonicalizing([
968+
'client_name',
969+
'client_mime_type',
970+
'mime_type',
971+
'size',
972+
'path',
973+
], array_keys($body['files']['avatar']));
974+
$this->assertSame('avatar.jpg', $body['files']['avatar']['client_name']);
975+
$this->assertSame('image/jpeg', $body['files']['avatar']['client_mime_type']);
976+
$this->assertSame('image/jpeg', $body['files']['avatar']['mime_type']);
977+
$this->assertSame(1024, $body['files']['avatar']['size']);
978+
$this->assertFileExists($body['files']['avatar']['path']);
979+
980+
return true;
981+
});
982+
}
983+
984+
#[WithEnv('NIGHTWATCH_CAPTURE_REQUEST_BODY', 'true')]
985+
public function test_it_captures_a_json_body_on_unhandled_exceptions(): void
986+
{
987+
$ingest = $this->fakeIngest();
988+
Route::patch('/register', function () {
989+
throw new Exception('Whoops!');
990+
});
991+
992+
$response = $this
993+
->patchJson('/register?redirect=1', [
994+
'user' => [
995+
'username' => 'taylor',
996+
'password' => '$f4c4d3',
997+
],
998+
]);
999+
1000+
$response->assertInternalServerError();
1001+
$ingest->assertWrittenTimes(1);
1002+
$ingest->assertLatestWrite('request:0.body', function ($body) {
1003+
$body = json_decode($body, true);
1004+
$this->assertNotNull($body);
1005+
$this->assertSame([
1006+
'user' => [
1007+
'username' => 'taylor',
1008+
'password' => '[7 bytes redacted]',
1009+
],
1010+
], $body['payload']);
1011+
$this->assertCount(0, $body['files']);
1012+
1013+
return true;
1014+
});
1015+
}
1016+
1017+
#[WithEnv('NIGHTWATCH_CAPTURE_REQUEST_BODY', 'true')]
1018+
#[WithEnv('NIGHTWATCH_REDACT_KEYS', 'foo')]
1019+
public function test_the_redacted_keys_can_be_customized(): void
1020+
{
1021+
$ingest = $this->fakeIngest();
1022+
Route::patch('/register', function () {
1023+
throw new Exception('Whoops!');
1024+
});
1025+
1026+
$response = $this
1027+
->patch('/register?redirect=1', [
1028+
'user' => [
1029+
'username' => 'taylor',
1030+
'password' => '$f4c4d3',
1031+
],
1032+
'foo' => 'bar',
1033+
]);
1034+
1035+
$response->assertInternalServerError();
1036+
$ingest->assertWrittenTimes(1);
1037+
$ingest->assertLatestWrite('request:0.body', function ($body) {
1038+
$body = json_decode($body, true);
1039+
$this->assertNotNull($body);
1040+
$this->assertSame([
1041+
'user' => [
1042+
'username' => 'taylor',
1043+
'password' => '$f4c4d3',
1044+
],
1045+
'foo' => '[3 bytes redacted]',
1046+
], $body['payload']);
1047+
1048+
return true;
1049+
});
1050+
}
1051+
1052+
public function test_it_doesnt_capture_request_body_on_unhandled_exceptions_by_default(): void
1053+
{
1054+
$ingest = $this->fakeIngest();
1055+
Route::patch('/register', function () {
1056+
throw new Exception('Whoops!');
1057+
});
1058+
1059+
$response = $this
1060+
->patchJson('/register?redirect=1', [
1061+
'user' => [
1062+
'username' => 'taylor',
1063+
'password' => '$f4c4d3',
1064+
],
1065+
]);
1066+
1067+
$response->assertInternalServerError();
1068+
$ingest->assertWrittenTimes(1);
1069+
$ingest->assertLatestWrite('request:0.body', '');
1070+
}
1071+
9351072
public function test_livewire_2(): void
9361073
{
9371074
$this->markTestSkippedWhen(version_compare(InstalledVersions::getVersion('livewire/livewire'), '3.0.0', '>='), 'Requires Livewire 2');

0 commit comments

Comments
 (0)