diff --git a/src/Illuminate/Log/Context/Contracts/Contextable.php b/src/Illuminate/Log/Context/Contracts/Contextable.php new file mode 100644 index 000000000000..e88e2a1105f0 --- /dev/null +++ b/src/Illuminate/Log/Context/Contracts/Contextable.php @@ -0,0 +1,14 @@ +|null + */ + public function context($repository); +} diff --git a/src/Illuminate/Log/Context/Repository.php b/src/Illuminate/Log/Context/Repository.php index d0a4b98dec9d..ca77c37344a4 100644 --- a/src/Illuminate/Log/Context/Repository.php +++ b/src/Illuminate/Log/Context/Repository.php @@ -6,12 +6,14 @@ use Closure; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Log\Context\Contracts\Contextable; use Illuminate\Log\Context\Events\ContextDehydrating as Dehydrating; use Illuminate\Log\Context\Events\ContextHydrated as Hydrated; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use RuntimeException; use Throwable; @@ -40,6 +42,11 @@ class Repository */ protected $hidden = []; + /** + * @var list<\Illuminate\Log\Context\Contracts\Contextable> + */ + protected $contextables = []; + /** * The callback that should handle unserialize exceptions. * @@ -106,7 +113,13 @@ public function missingHidden($key) */ public function all() { - return $this->data; + $data = $this->data; + + foreach($this->contextables as $contextable) { + $data = array_merge($data, $contextable->context($this) ?? []); + } + + return $data; } /** @@ -218,16 +231,20 @@ public function exceptHidden($keys) /** * Add a context value. * - * @param string|array $key + * @param string|array|\Illuminate\Log\Context\Contracts\Contextable $key * @param mixed $value * @return $this */ public function add($key, $value = null) { - $this->data = array_merge( - $this->data, - is_array($key) ? $key : [$key => $value] - ); + if ($key instanceof Contextable) { + $this->contextables[] = $key; + } else { + $this->data = array_merge( + $this->data, + is_array($key) ? $key : [$key => $value] + ); + } return $this; } @@ -370,6 +387,58 @@ public function push($key, ...$values) return $this; } + /** + * Register a contextable. + * + * @param array|Contextable $contextable + * @return $this + * + * @throws \InvalidArgumentException + */ + public function contextable($contextable) + { + $contextables = is_array($contextable) ? $contextable : [$contextable]; + + foreach($contextables as $contextable) { + if (! $contextable instanceof Contextable) { + throw new InvalidArgumentException('Only Contextable classes can be registered.'); + } + + $this->contextables[] = $contextable; + } + + return $this; + } + + /** + * Retrieve all registered contextables. + * + * @return list<\Illuminate\Log\Context\Contracts\Contextable> + */ + public function getContextables() + { + return $this->contextables; + } + + /** + * Remove a Contextable. + * + * @param class-string<\Illuminate\Log\Context\Contracts\Contextable>|\Illuminate\Log\Context\Contracts\Contextable $contextableToRemove + * @return $this + */ + public function forgetContextable($contextableToRemove) + { + foreach($this->contextables as $i => $contextable) { + if ((is_string($contextableToRemove) && is_a($contextable, $contextableToRemove, true)) || ($contextableToRemove === $contextable)) { + unset($this->contextables[$i]); + } + } + + $this->contextables = array_values($this->contextables); + + return $this; + } + /** * Pop the latest value from the key's stack. * @@ -572,7 +641,7 @@ public function scope(callable $callback, array $data = [], array $hidden = []) */ public function isEmpty() { - return $this->all() === [] && $this->allHidden() === []; + return $this->all() === [] && $this->allHidden() === [] && $this->getContextables() === []; } /** @@ -591,7 +660,7 @@ public function dehydrating($callback) /** * Execute the given callback when context has been hydrated. * - * @param callable $callback + * @param callable(\Illuminate\Log\Context\Repository): mixed $callback * @return $this */ public function hydrated($callback) @@ -623,6 +692,7 @@ public function flush() { $this->data = []; $this->hidden = []; + $this->contextables = []; return $this; } @@ -637,16 +707,18 @@ public function flush() public function dehydrate() { $instance = (new static($this->events)) - ->add($this->all()) - ->addHidden($this->allHidden()); + ->add($this->data) + ->addHidden($this->allHidden()) + ->contextable($this->getContextables()); $instance->events->dispatch(new Dehydrating($instance)); $serialize = fn ($value) => serialize($instance->getSerializedPropertyValue($value, withRelations: false)); return $instance->isEmpty() ? null : [ - 'data' => array_map($serialize, $instance->all()), + 'data' => array_map($serialize, $instance->data), 'hidden' => array_map($serialize, $instance->allHidden()), + 'contextables' => array_map($serialize, $instance->getContextables()), ]; } @@ -686,13 +758,14 @@ public function hydrate($context) } }; - [$data, $hidden] = [ + [$data, $hidden, $contextable] = [ (new Collection($context['data'] ?? []))->map(fn ($value, $key) => $unserialize($value, $key, false))->all(), (new Collection($context['hidden'] ?? []))->map(fn ($value, $key) => $unserialize($value, $key, true))->all(), + (new Collection($context['contextables'] ?? []))->map(fn ($value, $key) => $unserialize($value, $key, false))->all(), ]; $this->events->dispatch(new Hydrated( - $this->flush()->add($data)->addHidden($hidden) + $this->flush()->add($data)->addHidden($hidden)->contextable($contextable) )); return $this; diff --git a/tests/Integration/Log/ContextIntegrationTest.php b/tests/Integration/Log/ContextIntegrationTest.php index 2d7fe074f0e5..1c232f10c74b 100644 --- a/tests/Integration/Log/ContextIntegrationTest.php +++ b/tests/Integration/Log/ContextIntegrationTest.php @@ -6,6 +6,10 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Auth\User; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; +use Illuminate\Log\Context\Contracts\Contextable; +use Illuminate\Log\Context\Repository; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Context; use Orchestra\Testbench\Attributes\WithMigration; use Orchestra\Testbench\Factories\UserFactory; @@ -21,6 +25,7 @@ public function test_it_can_hydrate_null() { Context::hydrate(null); $this->assertEquals([], Context::all()); + $this->assertEquals([], Context::getContextables()); } public function test_it_handles_eloquent() @@ -37,6 +42,7 @@ public function test_it_handles_eloquent() 'number' => 'i:55;', ], 'hidden' => [], + 'contextables' => [], ], $dehydrated); Context::flush(); @@ -149,4 +155,51 @@ public function test_it_can_handle_unserialize_exceptions_manually() Context::handleUnserializeExceptionsUsing(null); } + + public function test_it_can_serialize_a_contextable_object() + { + $user = UserFactory::new()->create(['id' => 99, 'name' => 'Luke']); + Context::add(new MyContextableClass($user, 'you have been replaced')); + + $dehydrated = Context::dehydrate(); + + $this->assertEquals([ + 'data' => [], + 'hidden' => [], + 'contextables' => [ + 'O:51:"Illuminate\Tests\Integration\Log\MyContextableClass":2:{s:4:"user";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:31:"Illuminate\Foundation\Auth\User";s:2:"id";i:99;s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";s:15:"collectionClass";N;}s:5:"other";s:22:"you have been replaced";}', + ], + ], $dehydrated); + + $this->assertEquals(['user_id' => 99, 'other' => 'you have been replaced'], Context::all()); + + Context::hydrated(function (Repository $context) { + App::instance(MyContextableClass::class, $context->getContextables()[0]); + }); + + Context::hydrate($dehydrated); + + $this->assertSame(resolve(MyContextableClass::class), $contextable = Context::getContextables()[0]); + $this->assertTrue($user->is($contextable->user)); + } +} + +class MyContextableClass implements Contextable +{ + use SerializesModels; + + public function __construct( + public readonly User $user, + public readonly string $other = 'replace me', + ) { + } + + #[\Override] + public function context($repository) + { + return [ + 'user_id' => $this->user->id, + 'other' => $this->other, + ]; + } } diff --git a/tests/Log/ContextTest.php b/tests/Log/ContextTest.php index 23d54e0d6951..38e9d79c522d 100644 --- a/tests/Log/ContextTest.php +++ b/tests/Log/ContextTest.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Log\ContextLogProcessor; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; +use Illuminate\Log\Context\Contracts\Contextable; use Illuminate\Log\Context\Events\ContextDehydrating as Dehydrating; use Illuminate\Log\Context\Events\ContextHydrated as Hydrated; use Illuminate\Log\Context\Repository; @@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use InvalidArgumentException; use Monolog\LogRecord; use Orchestra\Testbench\TestCase; use RuntimeException; @@ -138,6 +140,8 @@ public function test_it_can_serialize_values() 'backed_enum' => StringBackedSuit::Clubs, ]); Context::addHidden('number', 55); + $contextable = new TestContextable('serialization test', 'this is public'); + Context::contextable($contextable); $dehydrated = Context::dehydrate(); @@ -157,10 +161,14 @@ public function test_it_can_serialize_values() 'hidden' => [ 'number' => 'i:55;', ], + 'contextables' => [ + 'O:36:"Illuminate\Tests\Log\TestContextable":2:{s:5:"value";s:18:"serialization test";s:15:"somePublicValue";s:14:"this is public";}', + ], ], $dehydrated); Context::flush(); $this->assertNull(Context::get('string')); + $this->assertEquals([], Context::getContextables()); Context::hydrate($dehydrated); @@ -175,6 +183,10 @@ public function test_it_can_serialize_values() $this->assertSame(Context::get('enum'), Suit::Clubs); $this->assertSame(Context::get('backed_enum'), StringBackedSuit::Clubs); $this->assertSame(Context::getHidden('number'), 55); + + $dehydratedContextable = array_first(Context::getContextables()); + $this->assertEquals('serialization test', $dehydratedContextable->getValue()); + $this->assertEquals('this is public', $dehydratedContextable->somePublicValue); } public function test_it_can_push_to_list() @@ -402,6 +414,7 @@ public function test_it_adds_context_to_logging() Context::add('foo.bar', 123); Context::push('bar.baz', 456); Context::push('bar.baz', 789); + Context::contextable(new TestContextable('hello')); Log::channel('single')->info('My name is {name}', [ 'name' => 'Tim', @@ -409,7 +422,7 @@ public function test_it_adds_context_to_logging() ]); $log = Str::after(file_get_contents(storage_path('logs/laravel.log')), '] '); - $this->assertSame('testing.INFO: My name is Tim {"name":"Tim","framework":"Laravel"} {"trace_id":"expected-trace-id","foo.bar":123,"bar.baz":[456,789]}', trim($log)); + $this->assertSame('testing.INFO: My name is Tim {"name":"Tim","framework":"Laravel"} {"trace_id":"expected-trace-id","foo.bar":123,"bar.baz":[456,789],"value":"hello"}', trim($log)); file_put_contents($path, ''); Str::createUuidsNormally(); @@ -698,6 +711,29 @@ public function test_it_remembers_a_hidden_value() Context::rememberHidden('foo', $closure); $this->assertSame(1, $closureRunCount); } + + public function test_it_can_store_contextable() + { + $contextable = new TestContextable('hello'); + Context::contextable($contextable); + $this->assertSame($contextable, array_values(Context::getContextables())[0]); + Context::add('woody', 'guthrie'); + + $this->assertEquals(['value' => 'hello', 'woody' => 'guthrie'], Context::all()); + Context::forgetContextable($contextable::class); + $this->assertEmpty(Context::getContextables()); + $this->assertEquals(['woody' => 'guthrie'], Context::all()); + } + + public function test_registering_a_non_contextable_throws_exception() + { + try { + Context::contextable((object) ['foo' => 'bar']); + $this->fail('Did not throw expected exception'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Only Contextable classes can be registered.', $e->getMessage()); + } + } } enum Suit @@ -736,3 +772,23 @@ public function __invoke(LogRecord $record): LogRecord return $record->with(context: array_merge($record->context, ['inside of MyAddContextProcessor' => true])); } } + +class TestContextable implements Contextable +{ + public function __construct( + public $value, + public $somePublicValue = null + ) { + } + + public function getValue() + { + return $this->value; + } + public function context($repository): mixed + { + return [ + 'value' => $this->value, + ]; + } +}