Skip to content

[4.x] Add LogTenancyBootstrapper#1381

Open
lukinovec wants to merge 61 commits into
masterfrom
add-log-bootstrapper
Open

[4.x] Add LogTenancyBootstrapper#1381
lukinovec wants to merge 61 commits into
masterfrom
add-log-bootstrapper

Conversation

@lukinovec

@lukinovec lukinovec commented Jul 28, 2025

Copy link
Copy Markdown
Contributor

This PR adds the LogTenancyBootstrapper 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).

Summary by CodeRabbit

  • New Features
    • Added tenant-aware logging bootstrapping that snapshots central logging channels, rewrites log file paths to tenant storage (including single/daily and stack members), and supports configurable per-tenant channel overrides with validation.
    • Tenant logging settings are automatically restored to the central configuration when tenancy ends, including rollback when initialization fails.
  • Tests
    • Added coverage for lifecycle behavior, stack channel updates, override precedence/validation, revert correctness, handler re-resolution, tenant log isolation (incl. daily files), and Slack webhook URL rewriting.
  • Chores
    • Updated the test harness to register the new logging bootstrapper.

@codecov

codecov Bot commented Jul 28, 2025

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.57%. Comparing base (3156c87) to head (f2b7c7d).

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1381      +/-   ##
============================================
+ Coverage     86.37%   86.57%   +0.19%     
- Complexity     1200     1221      +21     
============================================
  Files           186      187       +1     
  Lines          3524     3575      +51     
============================================
+ Hits           3044     3095      +51     
  Misses          480      480              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lukinovec lukinovec requested a review from Copilot July 29, 2025 11:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds a new LogTenancyBootstrapper to provide tenant-specific logging configuration. The bootstrapper automatically configures storage path channels to use tenant-specific directories and supports custom channel overrides for more complex logging scenarios.

Key changes:

  • Implements LogTenancyBootstrapper with automatic storage path channel configuration and custom override support
  • Adds comprehensive test coverage for default behavior, custom overrides, and real-world usage scenarios
  • Registers the new bootstrapper in the test environment

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/Bootstrappers/LogTenancyBootstrapper.php Main implementation of the log tenancy bootstrapper with storage path handling and channel override functionality
tests/Bootstrappers/LogTenancyBootstrapperTest.php Comprehensive test suite covering default behavior, custom overrides, stack channels, and real logging scenarios
tests/TestCase.php Adds import and singleton registration for the new bootstrapper in test environment
Comments suppressed due to low confidence (1)

tests/Bootstrappers/LogTenancyBootstrapperTest.php:345

  • The test relies on catching exceptions to verify webhook URLs, but this approach is fragile and may not work reliably across different environments or Laravel versions. Consider mocking the HTTP client or using a more deterministic testing approach.
    try {

} elseif (in_array($channel, static::$storagePathChannels)) {
// Set storage path channels to use tenant-specific directory (default behavior)
// The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log"
$this->config->set("logging.channels.{$channel}.path", storage_path('logs/laravel.log'));

Copilot AI Jul 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded filename 'laravel.log' should be made configurable or use the original filename from the channel configuration. This prevents customization of log filenames and could overwrite existing configurations.

Suggested change
$this->config->set("logging.channels.{$channel}.path", storage_path('logs/laravel.log'));
$logFilename = $this->getTenantLogFilename($tenant);
$this->config->set("logging.channels.{$channel}.path", storage_path("logs/{$logFilename}"));

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, this is fine for the default behavior, it can still be customized. Resolving this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of the box, if no customization is used, $storagePathChannels includes daily which does not use laravel.log names, but day-specific names. Seems like something that should be checked and tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is checked and tested already, I'd just add a short comment with an explanation of how daily works.

daily driver uses RotatingFileHandler that parses the file name. The current code (= storage_path('logs/laravel.log')) corresponds to the daily log channel config. It is correct, so I'd just clarify this since this can indeed be quite confusing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify further, 'logging.channels.daily.path' => storage_path('logs/laravel.log') is correct, consistent with Laravel's default daily channel path. When using a config like this, the log will be created e.g. at storage/tenantfoo/logs/laravel-2026-04-14.log (this is tested in stack logs are written to all configured channels with tenant-specific paths)

@lukinovec lukinovec marked this pull request as ready for review July 29, 2025 11:36
@lukinovec lukinovec requested a review from stancl July 29, 2025 11:36
Comment thread src/Bootstrappers/LogTenancyBootstrapper.php

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/Bootstrappers/LogTenancyBootstrapper.php (1)

136-138: ⚠️ Potential issue | 🟠 Major

Preserve the relative log subpath, not just the filename.

basename($path) keeps only the last segment. If two tenantized channels are configured as storage/logs/api/app.log and storage/logs/web/app.log, they both collapse to storage/.../logs/app.log and their output mixes. Rebase the path relative to storage_path() when the original path already lives under storage, and only fall back to basename() for non-storage paths.

💡 Suggested fix
-                $path = $this->config->get("logging.channels.{$channel}.path");
-
-                $this->config->set("logging.channels.{$channel}.path", storage_path('logs/' . ($path ? basename($path) : 'laravel.log')));
+                $path = (string) $this->config->get(
+                    "logging.channels.{$channel}.path",
+                    storage_path('logs/laravel.log')
+                );
+                $storageRoot = rtrim(storage_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+
+                $relativePath = str_starts_with($path, $storageRoot)
+                    ? ltrim(substr($path, strlen($storageRoot)), DIRECTORY_SEPARATOR)
+                    : 'logs/' . basename($path);
+
+                $this->config->set("logging.channels.{$channel}.path", storage_path($relativePath));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Bootstrappers/LogTenancyBootstrapper.php` around lines 136 - 138, The
current logic uses basename($path) which loses intermediate directories and
causes different tenant channels (e.g., storage/logs/api/app.log vs
storage/logs/web/app.log) to collide; update the code in LogTenancyBootstrapper
where you fetch and set "logging.channels.{$channel}.path" to preserve a
relative subpath when the original path resides under storage_path(): if $path
starts with storage_path() compute the relative path by stripping storage_path()
and trimming leading slashes and join that under storage_path('logs/' .
$relative), otherwise fall back to using basename($path) (and default to
'laravel.log' when $path is empty); use existing symbols $this->config->get,
$this->config->set, storage_path() and basename() to locate and change the
logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/Bootstrappers/LogTenancyBootstrapperTest.php`:
- Around line 32-34: In beforeEach capture the current static values of
LogTenancyBootstrapper::$channelOverrides and
LogTenancyBootstrapper::$storagePathChannels into local variables (e.g.
$originalChannelOverrides, $originalStoragePathChannels) and then in afterEach
restore those exact captured values back to
LogTenancyBootstrapper::$channelOverrides and
LogTenancyBootstrapper::$storagePathChannels instead of hard-coding arrays so
the tests revert to the true originals; apply the same pattern for the other
reset points that currently hard-code values.

---

Duplicate comments:
In `@src/Bootstrappers/LogTenancyBootstrapper.php`:
- Around line 136-138: The current logic uses basename($path) which loses
intermediate directories and causes different tenant channels (e.g.,
storage/logs/api/app.log vs storage/logs/web/app.log) to collide; update the
code in LogTenancyBootstrapper where you fetch and set
"logging.channels.{$channel}.path" to preserve a relative subpath when the
original path resides under storage_path(): if $path starts with storage_path()
compute the relative path by stripping storage_path() and trimming leading
slashes and join that under storage_path('logs/' . $relative), otherwise fall
back to using basename($path) (and default to 'laravel.log' when $path is
empty); use existing symbols $this->config->get, $this->config->set,
storage_path() and basename() to locate and change the logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8006ceab-0634-4428-a3b7-6ac00a203f75

📥 Commits

Reviewing files that changed from the base of the PR and between 06472d5 and 23ae15a.

📒 Files selected for processing (2)
  • src/Bootstrappers/LogTenancyBootstrapper.php
  • tests/Bootstrappers/LogTenancyBootstrapperTest.php

Comment thread tests/Bootstrappers/LogTenancyBootstrapperTest.php Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Bootstrappers/LogTenancyBootstrapper.php`:
- Around line 133-141: The code currently resolves and bakes an absolute central
storage path into tenant channels; add a runtime guard in LogTenancyBootstrapper
(the branch handling static::$storagePathChannels where you call
$this->config->get("logging.channels.{$channel}.path") and set
storage_path(...)) that detects if the existing channel path is already an
absolute/central storage path (e.g., starts with storage_path() or is an
absolute filesystem path) and immediately throw a clear RuntimeException
instructing the user to enable/position FilesystemTenancyBootstrapper earlier in
the bootstrappers list; do this check before calling storage_path(...) and avoid
mutating the config if the guard triggers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6db5d9fe-f993-4381-b44e-a9709a300325

📥 Commits

Reviewing files that changed from the base of the PR and between 23ae15a and b234308.

📒 Files selected for processing (1)
  • src/Bootstrappers/LogTenancyBootstrapper.php

Comment thread src/Bootstrappers/LogTenancyBootstrapper.php Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/Bootstrappers/LogTenancyBootstrapper.php (1)

145-155: ⚠️ Potential issue | 🟠 Major

Skip storage-path rewrites when storage suffixing is disabled.

This branch still rewrites logging.channels.{channel}.path even when tenancy.filesystem.suffix_storage_path is false. In that mode storage_path() stays central, so these channels keep writing to shared storage instead of being tenant-isolated. Please gate the default path-rewrite logic on that flag and silently skip it when suffixing is off.

💡 Suggested fix
         foreach ($channels as $channel) {
             if (isset(static::$channelOverrides[$channel])) {
                 $this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant);
-            } elseif (in_array($channel, static::$storagePathChannels)) {
+            } elseif (in_array($channel, static::$storagePathChannels, true)) {
+                if ($this->config->get('tenancy.filesystem.suffix_storage_path') === false) {
+                    continue;
+                }
+
                 // Set storage path channels to use tenant-specific directory (default behavior).
                 // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log"
                 // (assuming FilesystemTenancyBootstrapper is used before this bootstrapper).
                 $originalChannelPath = $this->config->get("logging.channels.{$channel}.path");
                 $centralStoragePath = Str::before(storage_path(), $this->config->get('tenancy.filesystem.suffix_base') . $tenant->getTenantKey());

Based on learnings, in archtechx/tenancy LogTenancyBootstrapper, the $storagePathChannels logic depends on both FilesystemTenancyBootstrapper running first and tenancy.filesystem.suffix_storage_path being true; when suffix_storage_path is false, storage-path channels should be silently skipped.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Bootstrappers/LogTenancyBootstrapper.php` around lines 145 - 155, The
branch in LogTenancyBootstrapper that rewrites logging.channels.{channel}.path
for entries in $storagePathChannels must be skipped when
tenancy.filesystem.suffix_storage_path is false; update the conditional around
the in_array($channel, static::$storagePathChannels) block to also check
config('tenancy.filesystem.suffix_storage_path') (or $this->config->get(...))
and return/continue silently when suffixing is disabled so no storage_path()
rewrite occurs and channels remain on central storage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/Bootstrappers/LogTenancyBootstrapperTest.php`:
- Around line 23-26: The cleanup only globs one-level *.log files into $logFiles
so nested files like storage/logs/single/*.log, storage/logs/daily/*.log and
tenantized storage/tenant*/logs/*/*.log are missed; update the globbing used
where $logFiles is constructed (and the similar block at the other occurrence)
to include nested directories (e.g. add patterns for logs/*/*.log and
tenant*/logs/*/*.log or switch to a recursive iterator/RegexIterator using
storage_path()) so all nested .log files are collected and removed during
teardown.

---

Duplicate comments:
In `@src/Bootstrappers/LogTenancyBootstrapper.php`:
- Around line 145-155: The branch in LogTenancyBootstrapper that rewrites
logging.channels.{channel}.path for entries in $storagePathChannels must be
skipped when tenancy.filesystem.suffix_storage_path is false; update the
conditional around the in_array($channel, static::$storagePathChannels) block to
also check config('tenancy.filesystem.suffix_storage_path') (or
$this->config->get(...)) and return/continue silently when suffixing is disabled
so no storage_path() rewrite occurs and channels remain on central storage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fd32f838-0fe9-4557-bf08-fe6b4594b276

📥 Commits

Reviewing files that changed from the base of the PR and between b234308 and c2a80c2.

📒 Files selected for processing (2)
  • src/Bootstrappers/LogTenancyBootstrapper.php
  • tests/Bootstrappers/LogTenancyBootstrapperTest.php

Comment thread tests/Bootstrappers/LogTenancyBootstrapperTest.php
@lukinovec

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Reviews resumed.

Comment thread src/Bootstrappers/LogTenancyBootstrapper.php Outdated
Comment thread src/Bootstrappers/LogTenancyBootstrapper.php Outdated
Comment thread src/Bootstrappers/LogTenancyBootstrapper.php Outdated
@stancl

stancl commented Jun 11, 2026

Copy link
Copy Markdown
Member

Seems some of the minor logic changes here b51d5ca (the getAttribute thing perhaps?) made a test start failing.

The bootstrapper should be able to map nested tenant attributes to config, and `getAttribute()` doesn't support dot notation, so e.g. `$tenant->getAttribute('logging.slackUrl')` would return `null` instead of the actual attribute, so the test failed.

`data_get()` supports dot notation, so we'll use that.
…nels

Reverting the LogTenancyBootstrapper changes will make the new "stack channels that include any configured channel are re-resolved" test fali
@lukinovec

lukinovec commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Seems some of the minor logic changes here b51d5ca (the getAttribute thing perhaps?) made a test start failing.

Yes, the test started failing because in the test, $tenant->logging['slackUrl'] (nested attribute) should be mapped to the config:

// 'slack channel uses correct webhook urls'
LogTenancyBootstrapper::$channelOverrides = [
    'slack' => ['url' => 'logging.slackUrl'],
]

getAttributes() doesn't support dot notation, so the case of logging.slackUrl, it returned null and the config's value stayed the same as it was in the central context

Fixed by using data_get() instead of getAttributes() (dc331bf)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/Bootstrappers/LogTenancyBootstrapperTest.php`:
- Around line 373-390: The test currently verifies `custom_stack` only while
tenancy is active, so it does not cover the channel reset path after
`tenancy()->end()`. After the existing tenant log assertions in
`LogTenancyBootstrapperTest`, exercise `Log::channel('custom_stack')` again once
tenancy has ended and add an assertion that the post-revert log goes back to the
central log (using the existing `centralLogPath` and `tenantLogPath` checks).
This will validate the `forgetChannel()` behavior triggered by `TenancyEnded`
and ensure the stack channel is re-resolved back to the non-tenant
configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f50ddf7a-bc8b-43b4-a5a0-df590bf44372

📥 Commits

Reviewing files that changed from the base of the PR and between 8107ed4 and 68e6fd6.

📒 Files selected for processing (2)
  • src/Bootstrappers/LogTenancyBootstrapper.php
  • tests/Bootstrappers/LogTenancyBootstrapperTest.php

Comment thread tests/Bootstrappers/LogTenancyBootstrapperTest.php
lukinovec and others added 4 commits June 29, 2026 18:01
we use FilesystemTenancyBootstrapper::getBoundCentralStoragePath() in
LogTenancyBootstrapper to simplify the logic for extracting a path
inside the central storage directory

we could use this method in more places, such as DeleteTenantStorage:
  $centralStoragePath = tenancy()->central(fn () => storage_path());
  $tenantStoragePath = tenancy()->run($this->tenant, fn () => storage_path());

however for consistency (we're getting both the central and tenant
paths there) we're keeping it simple as it is now

using the newly added method would also introduce more coupling to
FilesystemTenancyBootstrapper - its specific implementation. this isn't
a big issue in LogTenancyBootstrapper which already has several
dependencies on the FS bootstrapper, and we do use app(static::class)
in the added method so extending the bootstrapper should work
the main change here is adding similar assertions related to
*re-resolving* channels to the 'stack channels that include any
configured channel are re-resolved' test, similar to the 'channels are
forgotten and re-resolved during bootstrap and revert' test
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants