Skip to content
Draft
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
14 changes: 14 additions & 0 deletions src/Illuminate/Log/Context/Contracts/Contextable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Illuminate\Log\Context\Contracts;

interface Contextable
{
/**
* The data to append to your log output.
*
* @param \Illuminate\Log\Context\Repository $repository
* @return array<string, mixed>|null
*/
public function context($repository);
}
99 changes: 86 additions & 13 deletions src/Illuminate/Log/Context/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -40,6 +42,11 @@ class Repository
*/
protected $hidden = [];

/**
* @var list<\Illuminate\Log\Context\Contracts\Contextable>
*/
protected $contextables = [];

/**
* The callback that should handle unserialize exceptions.
*
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -218,16 +231,20 @@ public function exceptHidden($keys)
/**
* Add a context value.
*
* @param string|array<string, mixed> $key
* @param string|array<string, mixed>|\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;
}
Expand Down Expand Up @@ -370,6 +387,58 @@ public function push($key, ...$values)
return $this;
}

/**
* Register a contextable.
*
* @param array<array-key, Contextable>|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.
*
Expand Down Expand Up @@ -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() === [];
}

/**
Expand All @@ -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)
Expand Down Expand Up @@ -623,6 +692,7 @@ public function flush()
{
$this->data = [];
$this->hidden = [];
$this->contextables = [];

return $this;
}
Expand All @@ -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()),
];
}

Expand Down Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions tests/Integration/Log/ContextIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand All @@ -37,6 +42,7 @@ public function test_it_handles_eloquent()
'number' => 'i:55;',
],
'hidden' => [],
'contextables' => [],
], $dehydrated);

Context::flush();
Expand Down Expand Up @@ -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,
];
}
}
Loading