|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace Stancl\Tenancy\Bootstrappers; |
| 6 | + |
| 7 | +use Closure; |
| 8 | +use Illuminate\Contracts\Config\Repository; |
| 9 | +use Illuminate\Database\Eloquent\Model; |
| 10 | +use Illuminate\Log\LogManager; |
| 11 | +use Illuminate\Support\Str; |
| 12 | +use InvalidArgumentException; |
| 13 | +use Stancl\Tenancy\Contracts\TenancyBootstrapper; |
| 14 | +use Stancl\Tenancy\Contracts\Tenant; |
| 15 | + |
| 16 | +/** |
| 17 | + * Use tenant-specific logging channels. |
| 18 | + * |
| 19 | + * Channels included in the $storagePathChannels property will be configured |
| 20 | + * to write logs into the tenant's storage directory. The list includes |
| 21 | + * Laravel's 'single' and 'daily' channels by default. To customize it, |
| 22 | + * see the property's docblock. |
| 23 | + * |
| 24 | + * For the storage path channels to be scoped correctly: |
| 25 | + * - this bootstrapper must run *after* FilesystemTenancyBootstrapper, |
| 26 | + * since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant |
| 27 | + * - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path') |
| 28 | + * must be true), since the storage path suffix is what separates filesystem-based logs |
| 29 | + * |
| 30 | + * For logging channels that are not filesystem-based, see the $channelOverrides logic. |
| 31 | + * |
| 32 | + * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper |
| 33 | + */ |
| 34 | +class LogChannelBootstrapper implements TenancyBootstrapper |
| 35 | +{ |
| 36 | + protected array $defaultConfig = []; |
| 37 | + |
| 38 | + protected array $configuredChannels = []; |
| 39 | + |
| 40 | + /** |
| 41 | + * Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily'). |
| 42 | + * |
| 43 | + * Channels included here will be configured to use tenant-specific storage paths |
| 44 | + * created using storage_path() in the tenant context. Overrides in the $channelOverrides |
| 45 | + * property take precedence over $storagePathChannels when a channel is included in both. |
| 46 | + * |
| 47 | + * Requires FilesystemTenancyBootstrapper to run before this bootstrapper, |
| 48 | + * and storage path suffixing to be enabled. |
| 49 | + * |
| 50 | + * @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper |
| 51 | + */ |
| 52 | + public static array $storagePathChannels = ['single', 'daily']; |
| 53 | + |
| 54 | + /** |
| 55 | + * Custom channel configuration overrides. |
| 56 | + * |
| 57 | + * Channels included here will be configured using the provided override. |
| 58 | + * The overrides take precedence over the $storagePathChannels behavior |
| 59 | + * when both approaches are used for the same channel. |
| 60 | + * |
| 61 | + * You can either map tenant attributes to channel config keys using an array, |
| 62 | + * or provide a closure that returns the full channel config array. |
| 63 | + * |
| 64 | + * Examples: |
| 65 | + * - Array mapping: ['slack' => ['url' => 'webhookUrl']] |
| 66 | + * - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is null, the override is ignored) |
| 67 | + * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] |
| 68 | + * - this manually merges ['url' => $tenant->slackUrl] into the channel's config |
| 69 | + * - null is not ignored, the closure controls the override fully |
| 70 | + * |
| 71 | + * So the channel overrides can be arrays and closures that return arrays. |
| 72 | + */ |
| 73 | + public static array $channelOverrides = []; |
| 74 | + |
| 75 | + public function __construct( |
| 76 | + protected Repository $config, |
| 77 | + protected LogManager $logManager, |
| 78 | + ) {} |
| 79 | + |
| 80 | + public function bootstrap(Tenant $tenant): void |
| 81 | + { |
| 82 | + $this->defaultConfig = $this->config->get('logging.channels'); |
| 83 | + $this->configuredChannels = $this->getChannels(); |
| 84 | + |
| 85 | + try { |
| 86 | + $this->configureChannels($this->configuredChannels, $tenant); |
| 87 | + $this->forgetChannels($this->configuredChannels); |
| 88 | + } catch (\Throwable $exception) { |
| 89 | + // If an exception is thrown while updating the logging config, the logging config |
| 90 | + // could be left in a corrupt state, so we revert to the original config to |
| 91 | + // to avoid logging the exception in a tenant channel or a broken channel. |
| 92 | + $this->revert(); |
| 93 | + |
| 94 | + // We re-throw the exception after having reverted the logging config to central. |
| 95 | + throw $exception; |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + public function revert(): void |
| 100 | + { |
| 101 | + $this->config->set('logging.channels', $this->defaultConfig); |
| 102 | + |
| 103 | + $this->forgetChannels($this->configuredChannels); |
| 104 | + } |
| 105 | + |
| 106 | + /** |
| 107 | + * Channels to configure and forget from the log manager so they can be |
| 108 | + * re-resolved with the new, tenant-specific config on the next use. |
| 109 | + * |
| 110 | + * Includes: |
| 111 | + * - all channels in the $storagePathChannels array |
| 112 | + * - all channels that have custom overrides in the $channelOverrides property |
| 113 | + * - any 'stack' channel that includes one of the above as a member |
| 114 | + * |
| 115 | + * Stack channels are included because once a stack has been used, it keeps logging |
| 116 | + * to wherever its members pointed to at that moment. So a stack used in the central |
| 117 | + * context would keep writing to the central logs, even after tenancy is initialized |
| 118 | + * and its member channels are configured for the tenant. |
| 119 | + * Forgetting the stack forces it to be re-resolved with its members' updated (tenant) |
| 120 | + * config. |
| 121 | + * |
| 122 | + * Importantly, stacks are only inspected one level deep - they are not traversed recursively. |
| 123 | + */ |
| 124 | + protected function getChannels(): array |
| 125 | + { |
| 126 | + $configuredChannels = array_unique([ |
| 127 | + ...static::$storagePathChannels, |
| 128 | + ...array_keys(static::$channelOverrides), |
| 129 | + ]); |
| 130 | + |
| 131 | + $stackChannels = []; |
| 132 | + |
| 133 | + foreach ($this->config->get('logging.channels') as $channel => $config) { |
| 134 | + // Include stack channels that have at least one configured channel as a member |
| 135 | + if (($config['driver'] ?? null) === 'stack' && array_intersect($config['channels'] ?? [], $configuredChannels)) { |
| 136 | + $stackChannels[] = $channel; |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + return array_filter( |
| 141 | + array_unique([...$configuredChannels, ...$stackChannels]), |
| 142 | + fn (string $channel): bool => $this->config->has("logging.channels.{$channel}") |
| 143 | + ); |
| 144 | + } |
| 145 | + |
| 146 | + /** |
| 147 | + * Configure channels for the tenant context. |
| 148 | + * |
| 149 | + * This handles both $storagePathChannels and $channelOverrides. |
| 150 | + */ |
| 151 | + protected function configureChannels(array $channels, Tenant $tenant): void |
| 152 | + { |
| 153 | + foreach ($channels as $channel) { |
| 154 | + if (isset(static::$channelOverrides[$channel])) { |
| 155 | + $this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant); |
| 156 | + } elseif (in_array($channel, static::$storagePathChannels)) { |
| 157 | + // Set storage path channels to use a tenant-specific directory. |
| 158 | + // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log". |
| 159 | + $originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); |
| 160 | + $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); |
| 161 | + |
| 162 | + // The tenant log will inherit the segment that follows the storage path from the central channel path config. |
| 163 | + // For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log), |
| 164 | + // the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log). |
| 165 | + $this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath))); |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + /** |
| 171 | + * Update channel configurations per $channelOverrides. |
| 172 | + * |
| 173 | + * For overrides set in array format, update individual keys of the channel. |
| 174 | + * - This ignores cases where the value of the respective tenant attribute is null. |
| 175 | + * For overrides set as closures, replace the entire channel with the returned config override. |
| 176 | + * - This does not ignore cases where parts of the config may be null - the closure fully controls the override. |
| 177 | + */ |
| 178 | + protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void |
| 179 | + { |
| 180 | + if (is_array($override)) { |
| 181 | + // Map tenant attributes to channel config keys. |
| 182 | + foreach ($override as $configKey => $tenantAttributeName) { |
| 183 | + /** @var Tenant&Model $tenant */ |
| 184 | + $tenantAttribute = data_get($tenant, $tenantAttributeName); |
| 185 | + |
| 186 | + // If the tenant attribute is null, the override is ignored |
| 187 | + // and the channel config key's value remains unchanged. |
| 188 | + if ($tenantAttribute !== null) { |
| 189 | + $this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute); |
| 190 | + } |
| 191 | + } |
| 192 | + } elseif ($override instanceof Closure) { |
| 193 | + $channelConfigKey = "logging.channels.{$channel}"; |
| 194 | + |
| 195 | + $result = $override($tenant, $this->config->get($channelConfigKey)); |
| 196 | + |
| 197 | + if (! is_array($result)) { |
| 198 | + throw new InvalidArgumentException("Channel override closure for '{$channel}' must return an array."); |
| 199 | + } |
| 200 | + |
| 201 | + $this->config->set($channelConfigKey, $result); |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + /** |
| 206 | + * Forget all passed channels from the log manager so that they can be |
| 207 | + * re-resolved with the updated config on the next logging attempt. |
| 208 | + */ |
| 209 | + protected function forgetChannels(array $channels): void |
| 210 | + { |
| 211 | + foreach ($channels as $channel) { |
| 212 | + $this->logManager->forgetChannel($channel); |
| 213 | + } |
| 214 | + } |
| 215 | +} |
0 commit comments