mirror of
https://github.com/archtechx/tenancy.git
synced 2026-08-06 05:34:04 +00:00
Add LogChannelBootstrapper (#1381)
This PR adds the LogChannelBootstrapper to provide tenant-specific logging configuration. The bootstrapper automatically configures storage path channels to use tenant-specific directories (NOTE: for this to work correctly, the bootstrapper has to run AFTER FilesystemTenancyBootstrapper, otherwise, the logs still won't be separated, unless you use overrides) and supports custom channel overrides for custom logging scenarios -- mapping tenant properties to the channel config, or using custom closures an array with the logging config, e.g. for making the slack channel (that's not handled by the bootstrapper by default) tenant-specific. The bootstrapper first modifies the channel config, then forgets the channel from LogManager so that on the next logging attempt, the channel is re-resolved with the modified config. Otherwise, the channel would just use the initial config **if the channel was resolved before**. If the channel wasn't resolved before, it'll always be resolved with the updated (tenant) config, unless the configuration fails. In that case, the config will be reverted (the central config will be restored) and the error will be logged using the original channel. When using a channel stack, the `stack` channel itself also has to be forgotten, since the LogManager could retain e.g. the original `stack` channel's webhook URL, while the underlying `slack` channel would use the updated one, and while logging, the app would actually use the initial webhook URL instead of the updated one (encountered this issue while testing). Note that **all** channels in `$storagePathChannels` and `$channelOverrides` are affected. Also, adding `'attachment' => 'false'` to the slack channel's config makes the slack channel work with Discord webhooks (just a cool thing we figured out whlle testing the bootstrapper). --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Samuel Štancl <samuel@archte.ch>
This commit is contained in:
parent
869ad78454
commit
76e5f96559
5 changed files with 748 additions and 1 deletions
|
|
@ -185,6 +185,7 @@ return [
|
||||||
// Bootstrappers\RootUrlBootstrapper::class,
|
// Bootstrappers\RootUrlBootstrapper::class,
|
||||||
// Bootstrappers\UrlGeneratorBootstrapper::class,
|
// Bootstrappers\UrlGeneratorBootstrapper::class,
|
||||||
// Bootstrappers\MailConfigBootstrapper::class,
|
// Bootstrappers\MailConfigBootstrapper::class,
|
||||||
|
// Bootstrappers\LogChannelBootstrapper::class,
|
||||||
// Bootstrappers\BroadcastingConfigBootstrapper::class,
|
// Bootstrappers\BroadcastingConfigBootstrapper::class,
|
||||||
// Bootstrappers\BroadcastChannelPrefixBootstrapper::class,
|
// Bootstrappers\BroadcastChannelPrefixBootstrapper::class,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -255,4 +255,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
|
||||||
$sessionManager->getDrivers()['file']->setHandler($handler);
|
$sessionManager->getDrivers()['file']->setHandler($handler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the central storage path from the bound singleton instance of this class. */
|
||||||
|
public static function getBoundCentralStoragePath(): string
|
||||||
|
{
|
||||||
|
return app(static::class)->originalStoragePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
215
src/Bootstrappers/LogChannelBootstrapper.php
Normal file
215
src/Bootstrappers/LogChannelBootstrapper.php
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Bootstrappers;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Config\Repository;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Log\LogManager;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||||
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use tenant-specific logging channels.
|
||||||
|
*
|
||||||
|
* Channels included in the $storagePathChannels property will be configured
|
||||||
|
* to write logs into the tenant's storage directory. The list includes
|
||||||
|
* Laravel's 'single' and 'daily' channels by default. To customize it,
|
||||||
|
* see the property's docblock.
|
||||||
|
*
|
||||||
|
* For the storage path channels to be scoped correctly:
|
||||||
|
* - this bootstrapper must run *after* FilesystemTenancyBootstrapper,
|
||||||
|
* since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant
|
||||||
|
* - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path')
|
||||||
|
* must be true), since the storage path suffix is what separates filesystem-based logs
|
||||||
|
*
|
||||||
|
* For logging channels that are not filesystem-based, see the $channelOverrides logic.
|
||||||
|
*
|
||||||
|
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
|
||||||
|
*/
|
||||||
|
class LogChannelBootstrapper implements TenancyBootstrapper
|
||||||
|
{
|
||||||
|
protected array $defaultConfig = [];
|
||||||
|
|
||||||
|
protected array $configuredChannels = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily').
|
||||||
|
*
|
||||||
|
* Channels included here will be configured to use tenant-specific storage paths
|
||||||
|
* created using storage_path() in the tenant context. Overrides in the $channelOverrides
|
||||||
|
* property take precedence over $storagePathChannels when a channel is included in both.
|
||||||
|
*
|
||||||
|
* Requires FilesystemTenancyBootstrapper to run before this bootstrapper,
|
||||||
|
* and storage path suffixing to be enabled.
|
||||||
|
*
|
||||||
|
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
|
||||||
|
*/
|
||||||
|
public static array $storagePathChannels = ['single', 'daily'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom channel configuration overrides.
|
||||||
|
*
|
||||||
|
* Channels included here will be configured using the provided override.
|
||||||
|
* The overrides take precedence over the $storagePathChannels behavior
|
||||||
|
* when both approaches are used for the same channel.
|
||||||
|
*
|
||||||
|
* You can either map tenant attributes to channel config keys using an array,
|
||||||
|
* or provide a closure that returns the full channel config array.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - Array mapping: ['slack' => ['url' => 'webhookUrl']]
|
||||||
|
* - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is null, the override is ignored)
|
||||||
|
* - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])]
|
||||||
|
* - this manually merges ['url' => $tenant->slackUrl] into the channel's config
|
||||||
|
* - null is not ignored, the closure controls the override fully
|
||||||
|
*
|
||||||
|
* So the channel overrides can be arrays and closures that return arrays.
|
||||||
|
*/
|
||||||
|
public static array $channelOverrides = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected Repository $config,
|
||||||
|
protected LogManager $logManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function bootstrap(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
$this->defaultConfig = $this->config->get('logging.channels');
|
||||||
|
$this->configuredChannels = $this->getChannels();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->configureChannels($this->configuredChannels, $tenant);
|
||||||
|
$this->forgetChannels($this->configuredChannels);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
// If an exception is thrown while updating the logging config, the logging config
|
||||||
|
// could be left in a corrupt state, so we revert to the original config to
|
||||||
|
// to avoid logging the exception in a tenant channel or a broken channel.
|
||||||
|
$this->revert();
|
||||||
|
|
||||||
|
// We re-throw the exception after having reverted the logging config to central.
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revert(): void
|
||||||
|
{
|
||||||
|
$this->config->set('logging.channels', $this->defaultConfig);
|
||||||
|
|
||||||
|
$this->forgetChannels($this->configuredChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels to configure and forget from the log manager so they can be
|
||||||
|
* re-resolved with the new, tenant-specific config on the next use.
|
||||||
|
*
|
||||||
|
* Includes:
|
||||||
|
* - all channels in the $storagePathChannels array
|
||||||
|
* - all channels that have custom overrides in the $channelOverrides property
|
||||||
|
* - any 'stack' channel that includes one of the above as a member
|
||||||
|
*
|
||||||
|
* Stack channels are included because once a stack has been used, it keeps logging
|
||||||
|
* to wherever its members pointed to at that moment. So a stack used in the central
|
||||||
|
* context would keep writing to the central logs, even after tenancy is initialized
|
||||||
|
* and its member channels are configured for the tenant.
|
||||||
|
* Forgetting the stack forces it to be re-resolved with its members' updated (tenant)
|
||||||
|
* config.
|
||||||
|
*
|
||||||
|
* Importantly, stacks are only inspected one level deep - they are not traversed recursively.
|
||||||
|
*/
|
||||||
|
protected function getChannels(): array
|
||||||
|
{
|
||||||
|
$configuredChannels = array_unique([
|
||||||
|
...static::$storagePathChannels,
|
||||||
|
...array_keys(static::$channelOverrides),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stackChannels = [];
|
||||||
|
|
||||||
|
foreach ($this->config->get('logging.channels') as $channel => $config) {
|
||||||
|
// Include stack channels that have at least one configured channel as a member
|
||||||
|
if (($config['driver'] ?? null) === 'stack' && array_intersect($config['channels'] ?? [], $configuredChannels)) {
|
||||||
|
$stackChannels[] = $channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter(
|
||||||
|
array_unique([...$configuredChannels, ...$stackChannels]),
|
||||||
|
fn (string $channel): bool => $this->config->has("logging.channels.{$channel}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure channels for the tenant context.
|
||||||
|
*
|
||||||
|
* This handles both $storagePathChannels and $channelOverrides.
|
||||||
|
*/
|
||||||
|
protected function configureChannels(array $channels, Tenant $tenant): void
|
||||||
|
{
|
||||||
|
foreach ($channels as $channel) {
|
||||||
|
if (isset(static::$channelOverrides[$channel])) {
|
||||||
|
$this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant);
|
||||||
|
} elseif (in_array($channel, static::$storagePathChannels)) {
|
||||||
|
// Set storage path channels to use a tenant-specific directory.
|
||||||
|
// The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log".
|
||||||
|
$originalChannelPath = $this->config->get("logging.channels.{$channel}.path");
|
||||||
|
$centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath();
|
||||||
|
|
||||||
|
// The tenant log will inherit the segment that follows the storage path from the central channel path config.
|
||||||
|
// For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log),
|
||||||
|
// the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log).
|
||||||
|
$this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update channel configurations per $channelOverrides.
|
||||||
|
*
|
||||||
|
* For overrides set in array format, update individual keys of the channel.
|
||||||
|
* - This ignores cases where the value of the respective tenant attribute is null.
|
||||||
|
* For overrides set as closures, replace the entire channel with the returned config override.
|
||||||
|
* - This does not ignore cases where parts of the config may be null - the closure fully controls the override.
|
||||||
|
*/
|
||||||
|
protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void
|
||||||
|
{
|
||||||
|
if (is_array($override)) {
|
||||||
|
// Map tenant attributes to channel config keys.
|
||||||
|
foreach ($override as $configKey => $tenantAttributeName) {
|
||||||
|
/** @var Tenant&Model $tenant */
|
||||||
|
$tenantAttribute = data_get($tenant, $tenantAttributeName);
|
||||||
|
|
||||||
|
// If the tenant attribute is null, the override is ignored
|
||||||
|
// and the channel config key's value remains unchanged.
|
||||||
|
if ($tenantAttribute !== null) {
|
||||||
|
$this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($override instanceof Closure) {
|
||||||
|
$channelConfigKey = "logging.channels.{$channel}";
|
||||||
|
|
||||||
|
$result = $override($tenant, $this->config->get($channelConfigKey));
|
||||||
|
|
||||||
|
if (! is_array($result)) {
|
||||||
|
throw new InvalidArgumentException("Channel override closure for '{$channel}' must return an array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->config->set($channelConfigKey, $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget all passed channels from the log manager so that they can be
|
||||||
|
* re-resolved with the updated config on the next logging attempt.
|
||||||
|
*/
|
||||||
|
protected function forgetChannels(array $channels): void
|
||||||
|
{
|
||||||
|
foreach ($channels as $channel) {
|
||||||
|
$this->logManager->forgetChannel($channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
522
tests/Bootstrappers/LogChannelBootstrapperTest.php
Normal file
522
tests/Bootstrappers/LogChannelBootstrapperTest.php
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
|
use Stancl\Tenancy\Events\TenancyEnded;
|
||||||
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||||
|
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
afterEach($cleanup = function () {
|
||||||
|
LogChannelBootstrapper::$channelOverrides = [];
|
||||||
|
LogChannelBootstrapper::$storagePathChannels = ['single', 'daily'];
|
||||||
|
|
||||||
|
$logFiles = array_merge(
|
||||||
|
glob(storage_path('logs/*.log')) ?: [],
|
||||||
|
glob(storage_path('logs/*/*.log')) ?: [],
|
||||||
|
glob(storage_path('tenant*/logs/*.log')) ?: [],
|
||||||
|
glob(storage_path('tenant*/logs/*/*.log')) ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($logFiles as $path) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(function () use ($cleanup) {
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cleanup();
|
||||||
|
|
||||||
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage path channels get tenant-specific paths by default', function () {
|
||||||
|
// Note that for LogChannelBootstrapper to change the paths correctly by default,
|
||||||
|
// the bootstrapper MUST run after FilesystemTenancyBootstrapper.
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
FilesystemTenancyBootstrapper::class,
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$centralStoragePath = storage_path();
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
|
||||||
|
// Storage path channels are 'single' and 'daily' by default.
|
||||||
|
// This can be customized via LogChannelBootstrapper::$storagePathChannels.
|
||||||
|
foreach (LogChannelBootstrapper::$storagePathChannels as $channel) {
|
||||||
|
$originalPath = config("logging.channels.{$channel}.path");
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
// Path should now point to the log in the tenant's storage directory
|
||||||
|
$tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log";
|
||||||
|
expect(config("logging.channels.{$channel}.path"))
|
||||||
|
->not()->toBe($originalPath)
|
||||||
|
->toBe($tenantLogPath);
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
// Path should be reverted
|
||||||
|
expect(config("logging.channels.{$channel}.path"))->toBe($originalPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all channels included in a stack get processed correctly', function () {
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
FilesystemTenancyBootstrapper::class,
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
'logging.channels.stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => ['single', 'daily'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$centralStoragePath = storage_path();
|
||||||
|
$centralLogPath = $centralStoragePath . '/logs/laravel.log';
|
||||||
|
$originalSinglePath = config('logging.channels.single.path');
|
||||||
|
$originalDailyPath = config('logging.channels.daily.path');
|
||||||
|
|
||||||
|
// By default, both paths are the same in the config.
|
||||||
|
// Note that in actual usage, the daily log file name is parsed differently from the path in the config,
|
||||||
|
// e.g. if daily channel has 'path' => storage_path('logs/laravel.log') in config, the log will be
|
||||||
|
// located at storage_path('logs/laravel-2026-01-01.log'). But the paths *in the config* are the same.
|
||||||
|
expect($centralLogPath)
|
||||||
|
->toBe($centralStoragePath . '/logs/laravel.log')
|
||||||
|
->toBe($originalSinglePath)
|
||||||
|
->toBe($originalDailyPath);
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
// Both channels in the stack are updated correctly
|
||||||
|
expect("{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log")
|
||||||
|
->not()->toBe($originalSinglePath)
|
||||||
|
->not()->toBe($originalDailyPath)
|
||||||
|
->toBe(config('logging.channels.single.path'))
|
||||||
|
->toBe(config('logging.channels.daily.path'));
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
|
||||||
|
expect(config('logging.channels.daily.path'))->toBe($originalDailyPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel overrides work correctly with both arrays and closures', function () {
|
||||||
|
config([
|
||||||
|
'logging.channels.stack.channels' => ['slack', 'single'],
|
||||||
|
'logging.channels.slack' => [
|
||||||
|
'url' => $originalSlackUrl = 'default-webhook',
|
||||||
|
'username' => 'Default',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$centralStoragePath = storage_path();
|
||||||
|
$originalSinglePath = config('logging.channels.single.path');
|
||||||
|
|
||||||
|
$tenant = Tenant::create(['webhookUrl' => 'tenant-webhook']);
|
||||||
|
|
||||||
|
// Channel override closures must return an array, otherwise an exception is thrown
|
||||||
|
LogChannelBootstrapper::$channelOverrides['slack'] = fn (Tenant $tenant, array $channel) => 'invalid override';
|
||||||
|
|
||||||
|
expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
// Test both array mapping and closure-based overrides
|
||||||
|
LogChannelBootstrapper::$channelOverrides = [
|
||||||
|
'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl
|
||||||
|
'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) {
|
||||||
|
return array_merge($channel, ['path' => $centralStoragePath . "/logs/override-{$tenant->id}.log"]);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reinitialize tenancy to apply the new overrides
|
||||||
|
tenancy()->reinitialize();
|
||||||
|
|
||||||
|
// Array mapping overrides work
|
||||||
|
expect(config('logging.channels.slack.url'))->toBe($tenant->webhookUrl);
|
||||||
|
expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username, remains default unless overridden
|
||||||
|
|
||||||
|
// Closure overrides work
|
||||||
|
expect(config('logging.channels.single.path'))->toBe("{$centralStoragePath}/logs/override-{$tenant->id}.log");
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
// After tenancy ends, the original config should be restored
|
||||||
|
expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl);
|
||||||
|
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
|
||||||
|
expect(config('logging.channels.slack.username'))->toBe('Default'); // Unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel config keys remain unchanged if the specified tenant override attribute is null', function() {
|
||||||
|
config(['logging.channels.slack.username' => 'Default username']);
|
||||||
|
|
||||||
|
LogChannelBootstrapper::$channelOverrides = [
|
||||||
|
'slack' => ['username' => 'nonExistentAttribute'], // $tenant->nonExistentAttribute
|
||||||
|
];
|
||||||
|
|
||||||
|
tenancy()->initialize(Tenant::create());
|
||||||
|
|
||||||
|
// The username should remain unchanged since the tenant attribute is null
|
||||||
|
expect(config('logging.channels.slack.username'))->toBe('Default username');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel overrides take precedence over the default storage path channel updating logic', function () {
|
||||||
|
$tenant = Tenant::create(['id' => 'tenant1']);
|
||||||
|
|
||||||
|
LogChannelBootstrapper::$storagePathChannels = ['single'];
|
||||||
|
|
||||||
|
LogChannelBootstrapper::$channelOverrides = [
|
||||||
|
'single' => function (Tenant $tenant, array $channel) {
|
||||||
|
return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
// Should use channel override, not the storage path updating behavior
|
||||||
|
expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channels are forgotten and re-resolved during bootstrap and revert', function () {
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
FilesystemTenancyBootstrapper::class,
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$logManager = app('log');
|
||||||
|
$originalChannel = $logManager->channel('single');
|
||||||
|
$originalSinglePath = config('logging.channels.single.path');
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
// After bootstrap, the channel should be a new instance with the updated config
|
||||||
|
$tenantChannel = $logManager->channel('single');
|
||||||
|
$tenantSingleChannelPath = $tenantChannel->getLogger()->getHandlers()[0]->getUrl();
|
||||||
|
|
||||||
|
expect($tenantChannel)->not()->toBe($originalChannel);
|
||||||
|
expect($tenantSingleChannelPath)
|
||||||
|
->not()->toBe($originalSinglePath)
|
||||||
|
->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log");
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
// After revert, the channel should get re-resolved with the original config
|
||||||
|
$currentChannel = $logManager->channel('single');
|
||||||
|
$currentChannelPath = $currentChannel->getLogger()->getHandlers()[0]->getUrl();
|
||||||
|
|
||||||
|
expect($currentChannel)->not()->toBe($tenantChannel);
|
||||||
|
expect($currentChannelPath)->toBe($originalSinglePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test real usage
|
||||||
|
test('logs are written to tenant-specific files and do not leak between contexts', function () {
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
FilesystemTenancyBootstrapper::class,
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$centralLogPath = storage_path('logs/laravel.log');
|
||||||
|
|
||||||
|
Log::channel('single')->info('central');
|
||||||
|
|
||||||
|
expect(file_get_contents($centralLogPath))->toContain('central');
|
||||||
|
|
||||||
|
[$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])];
|
||||||
|
|
||||||
|
tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) {
|
||||||
|
Log::channel('single')->info($tenant->id);
|
||||||
|
|
||||||
|
$tenantLogPath = storage_path('logs/laravel.log');
|
||||||
|
|
||||||
|
// The log gets saved to the tenant's storage directory (default behavior)
|
||||||
|
expect($tenantLogPath)
|
||||||
|
->not()->toBe($centralLogPath)
|
||||||
|
->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log");
|
||||||
|
|
||||||
|
expect(file_get_contents($tenantLogPath))
|
||||||
|
->toContain($tenant->id)
|
||||||
|
->not()->toContain('central');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tenant log messages didn't leak into central log
|
||||||
|
expect(file_get_contents($centralLogPath))
|
||||||
|
->toContain('central')
|
||||||
|
->not()->toContain('tenant1')
|
||||||
|
->not()->toContain('tenant2');
|
||||||
|
|
||||||
|
// Tenant log messages didn't leak to logs of other tenants
|
||||||
|
tenancy()->initialize($tenant1);
|
||||||
|
|
||||||
|
expect(file_get_contents(storage_path('logs/laravel.log')))
|
||||||
|
->toContain('tenant1')
|
||||||
|
->not()->toContain('central')
|
||||||
|
->not()->toContain('tenant2');
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant2);
|
||||||
|
|
||||||
|
expect(file_get_contents(storage_path('logs/laravel.log')))
|
||||||
|
->toContain('tenant2')
|
||||||
|
->not()->toContain('central')
|
||||||
|
->not()->toContain('tenant1');
|
||||||
|
|
||||||
|
// Overriding the channels also works
|
||||||
|
// Channel overrides also override the default behavior for the storage path-based channels
|
||||||
|
$tenant = Tenant::create(['id' => 'override-tenant']);
|
||||||
|
|
||||||
|
LogChannelBootstrapper::$channelOverrides = [
|
||||||
|
'single' => function (Tenant $tenant, array $channel) {
|
||||||
|
// The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log
|
||||||
|
return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tenant context log (should use custom path due to override)
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
Log::channel('single')->info('tenant-override');
|
||||||
|
|
||||||
|
expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stack logs are written to all configured channels with tenant-specific paths', function () {
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
FilesystemTenancyBootstrapper::class,
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
'logging.channels.stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => ['single', 'daily'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::create(['id' => 'stack-tenant']);
|
||||||
|
$today = now()->format('Y-m-d');
|
||||||
|
|
||||||
|
// Central context stack log
|
||||||
|
Log::channel('stack')->info('central');
|
||||||
|
$centralSingleLogPath = storage_path('logs/laravel.log');
|
||||||
|
|
||||||
|
// The single and daily channels have the same path in the config, but the daily driver parses the file name so that the date is included in the file name
|
||||||
|
$centralDailyLogPath = storage_path("logs/laravel-{$today}.log");
|
||||||
|
|
||||||
|
expect(file_get_contents($centralSingleLogPath))->toContain('central');
|
||||||
|
expect(file_get_contents($centralDailyLogPath))->toContain('central');
|
||||||
|
|
||||||
|
// Tenant context stack log
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
Log::channel('stack')->info('tenant');
|
||||||
|
$tenantSingleLogPath = storage_path('logs/laravel.log');
|
||||||
|
$tenantDailyLogPath = storage_path("logs/laravel-{$today}.log");
|
||||||
|
|
||||||
|
expect(file_get_contents($tenantSingleLogPath))->toContain('tenant');
|
||||||
|
expect(file_get_contents($tenantDailyLogPath))->toContain('tenant');
|
||||||
|
|
||||||
|
// Verify tenant logs don't contain central messages
|
||||||
|
expect(file_get_contents($tenantSingleLogPath))->not()->toContain('central');
|
||||||
|
expect(file_get_contents($tenantDailyLogPath))->not()->toContain('central');
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
// Verify central logs still only contain the central messages
|
||||||
|
expect(file_get_contents($centralSingleLogPath))
|
||||||
|
->toContain('central')
|
||||||
|
->not()->toContain('tenant');
|
||||||
|
|
||||||
|
expect(file_get_contents($centralDailyLogPath))
|
||||||
|
->toContain('central')
|
||||||
|
->not()->toContain('tenant');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stack channels that include any configured channel are re-resolved', function () {
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
FilesystemTenancyBootstrapper::class,
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
'logging.channels.custom_stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => ['single'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::create(['id' => 'stack-tenant']);
|
||||||
|
$centralLogPath = storage_path('logs/laravel.log');
|
||||||
|
|
||||||
|
$logManager = app('log');
|
||||||
|
|
||||||
|
// Resolve the stack channel in the central context first
|
||||||
|
// (this caches the stack with its members still pointing at the central logs).
|
||||||
|
$originalStackChannel = $logManager->channel('custom_stack');
|
||||||
|
$originalStackChannel->info('central log message');
|
||||||
|
expect(file_get_contents($centralLogPath))->toContain('central log message');
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
// The stack channel should have been re-resolved with the
|
||||||
|
// updated (tenant) config for its member channels
|
||||||
|
$tenantStackChannel = $logManager->channel('custom_stack');
|
||||||
|
expect($tenantStackChannel)->not()->toBe($originalStackChannel);
|
||||||
|
|
||||||
|
$tenantStackChannel->info('tenant log message');
|
||||||
|
|
||||||
|
// so 'tenant log message' should be logged to the tenant log,
|
||||||
|
// not the central log.
|
||||||
|
expect(file_get_contents($centralLogPath))
|
||||||
|
->toContain('central log message')
|
||||||
|
->not()->toContain('tenant log message');
|
||||||
|
|
||||||
|
$tenantLogPath = storage_path('logs/laravel.log');
|
||||||
|
expect(file_exists($tenantLogPath))->toBeTrue();
|
||||||
|
expect(file_get_contents($tenantLogPath))
|
||||||
|
->toContain('tenant log message');
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
// After revert, the stack channel should get re-resolved again with the original config
|
||||||
|
$currentStackChannel = $logManager->channel('custom_stack');
|
||||||
|
expect($currentStackChannel)->not()->toBe($tenantStackChannel);
|
||||||
|
|
||||||
|
$currentStackChannel->info('central after revert');
|
||||||
|
|
||||||
|
expect(file_get_contents($centralLogPath))
|
||||||
|
->toContain('central log message')
|
||||||
|
->toContain('central after revert')
|
||||||
|
->not()->toContain('tenant log message');
|
||||||
|
|
||||||
|
expect(file_get_contents($tenantLogPath))
|
||||||
|
->toContain('tenant log message')
|
||||||
|
->not()->toContain('central after revert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slack channel uses correct webhook urls', function () {
|
||||||
|
config([
|
||||||
|
'logging.channels.slack.url' => 'central-webhook',
|
||||||
|
'logging.channels.slack.level' => 'debug', // Set level to debug to keep the tests simple, since the default level here is 'critical'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assertWebhook = function (string $expectedWebhook, string $message): void {
|
||||||
|
$thrown = false;
|
||||||
|
|
||||||
|
// Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here.
|
||||||
|
// Instead, we catch the exception and check the error message which contains the actual webhook URL
|
||||||
|
// (logging always throws "Curl error (code 6): Could not resolve host: {webhook_url}").
|
||||||
|
try {
|
||||||
|
Log::channel('slack')->info($message);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$thrown = true;
|
||||||
|
expect($e->getMessage())->toContain($expectedWebhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->toBeTrue();
|
||||||
|
};
|
||||||
|
|
||||||
|
$tenant1 = Tenant::create(['id' => 'tenant1', 'logging' => ['slackUrl' => 'tenant1-webhook']]);
|
||||||
|
$tenant2 = Tenant::create(['id' => 'tenant2', 'logging' => ['slackUrl' => 'tenant2-webhook']]);
|
||||||
|
|
||||||
|
// Attribute mapping using nested attributes (dot notation) works
|
||||||
|
LogChannelBootstrapper::$channelOverrides = [
|
||||||
|
'slack' => ['url' => 'logging.slackUrl'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Test central context - should attempt to use central webhook
|
||||||
|
$assertWebhook('central-webhook', 'central');
|
||||||
|
|
||||||
|
// Slack channel should attempt to use the tenant-specific webhooks
|
||||||
|
tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($assertWebhook) {
|
||||||
|
$assertWebhook($tenant->logging['slackUrl'], $tenant->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Central context, central webhook should be used again
|
||||||
|
$assertWebhook('central-webhook', 'central');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tenant logs inherit the path from the central log path config', function () {
|
||||||
|
config([
|
||||||
|
'tenancy.bootstrappers' => [
|
||||||
|
FilesystemTenancyBootstrapper::class,
|
||||||
|
LogChannelBootstrapper::class,
|
||||||
|
],
|
||||||
|
'logging.channels.stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => ['single', 'daily'],
|
||||||
|
],
|
||||||
|
'logging.channels.single.path' => storage_path('logs/single/custom-name.log'),
|
||||||
|
'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
$today = now()->format('Y-m-d');
|
||||||
|
|
||||||
|
Log::channel('stack')->info('central');
|
||||||
|
|
||||||
|
expect(file_get_contents(storage_path('logs/single/custom-name.log')))->toContain('central');
|
||||||
|
expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))->toContain('central');
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
// Tenant log is located at storage/tenantX/logs/custom-name.log
|
||||||
|
Log::channel('stack')->info($tenant->id);
|
||||||
|
|
||||||
|
// The filename from the central config is preserved in tenant context
|
||||||
|
expect(config('logging.channels.single.path'))->toEndWith('logs/single/custom-name.log');
|
||||||
|
expect(config('logging.channels.daily.path'))->toEndWith('logs/daily/custom-name.log');
|
||||||
|
|
||||||
|
expect(file_get_contents(storage_path('logs/single/custom-name.log')))
|
||||||
|
->toContain($tenant->id)
|
||||||
|
->not()->toContain('central');
|
||||||
|
|
||||||
|
expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))
|
||||||
|
->toContain($tenant->id)
|
||||||
|
->not()->toContain('central');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logging config is reverted to the original state if configuration fails', function() {
|
||||||
|
config([
|
||||||
|
'logging.channels.slack.url' => $originalSlackUrl = 'default-webhook',
|
||||||
|
'logging.channels.single.path' => $originalSinglePath = storage_path('logs/default-single-path.log'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::create(['loggingPath' => storage_path('logs/tenant-single-path.log')]);
|
||||||
|
|
||||||
|
// Valid override first, the config will be updated properly,
|
||||||
|
// then an invalid override that will cause the configuration to fail and throw an exception.
|
||||||
|
LogChannelBootstrapper::$channelOverrides = [
|
||||||
|
'single' => ['path' => 'loggingPath'], // Valid override
|
||||||
|
'slack' => fn () => 'invalid override',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
// Single channel config reverted to original state after the exception was thrown
|
||||||
|
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
|
||||||
|
|
||||||
|
// Exception thrown before slack config got changed
|
||||||
|
expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl);
|
||||||
|
|
||||||
|
// The single channel uses the original path for logging
|
||||||
|
Log::channel('single')->info('bootstrap failed');
|
||||||
|
expect(file_exists($originalSinglePath))->toBeTrue();
|
||||||
|
expect(file_get_contents($originalSinglePath))->toContain('bootstrap failed');
|
||||||
|
});
|
||||||
|
|
@ -23,10 +23,12 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||||
use function Stancl\Tenancy\Tests\pest;
|
use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper;
|
||||||
|
|
||||||
|
use function Stancl\Tenancy\Tests\pest;
|
||||||
|
|
||||||
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|
@ -191,6 +193,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||||
$app->singleton(RootUrlBootstrapper::class);
|
$app->singleton(RootUrlBootstrapper::class);
|
||||||
$app->singleton(UrlGeneratorBootstrapper::class);
|
$app->singleton(UrlGeneratorBootstrapper::class);
|
||||||
$app->singleton(FilesystemTenancyBootstrapper::class);
|
$app->singleton(FilesystemTenancyBootstrapper::class);
|
||||||
|
$app->singleton(LogChannelBootstrapper::class);
|
||||||
$app->singleton(TenantConfigBootstrapper::class);
|
$app->singleton(TenantConfigBootstrapper::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue