feat(cache): decide key masking with a policy the whole library can reach - #165
Conversation
|
🔎 Maintainer heads-up: automated triage flagged this PR as potentially material, so it may need a signed CLA in addition to the DCO sign-off. Strong signals
Other signals
This is advisory only — the bot does not decide. Please judge against the CLA criteria (material, product-critical, patent-sensitive, corporate contributor, broad commercial use). Note that thresholds can be gamed by splitting PRs, so use your judgement.
|
There was a problem hiding this comment.
🟡 Changes recommended
Key masking/rendering has a confirmed leak edge case (only first occurrence masked) plus confirmed avoidable allocations/string conversions on disabled log levels that conflict with the stated performance goals.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Introduces a container-resolved key-masking policy (IKeyMaskingPolicy) and routes key rendering through LoggedKey so all log sites across caches and supporting components can consistently mask potentially-secret keys (including for IDistributedCache adapter scenarios).
Changes:
- Add
IKeyMaskingPolicy+ built-in implementations, plusAddKeyMasking(...)builder extensions and default DI registration. - Replace direct key logging with
LoggedKey/KeyMaskeracross multilayer caches, Redis caches, broadcast change tokens, event publisher, rehydration coordinator, and local-memory setters. - Add/adjust tests and documentation/changelog entries to validate and describe masking behavior and reach.
File summaries
| File | Description |
|---|---|
| tests/UiPath.Caching.Tests/Logging/MaskedLogSiteTests.cs | Verifies masking reaches non-cache components (multilayer + change token). |
| tests/UiPath.Caching.Tests/Logging/KeyMaskingTests.cs | Unit tests for masking rules, rendering, DI defaults, and policy behavior. |
| tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs | Updates adapter test to assert keys are masked in warning logs. |
| src/UiPath.Caching/RehydrationCoordinator.cs | Uses LoggedKey for error logs; accepts optional KeyMasker. |
| src/UiPath.Caching/Redis/RedisHashCache.cs | Switches Redis hash-cache log sites to LoggedKey and passes masking policy down. |
| src/UiPath.Caching/Redis/RedisCacheProvider.cs | Plumbs IKeyMaskingPolicy into Redis cache instances. |
| src/UiPath.Caching/Redis/RedisCacheBase.cs | Creates per-provider KeyMasker and helper Logged(...) methods. |
| src/UiPath.Caching/Redis/RedisCache.cs | Switches Redis cache log sites to LoggedKey and threads key info where needed. |
| src/UiPath.Caching/PublicAPI.Unshipped.txt | Captures new public API surface and updated constructor signatures. |
| src/UiPath.Caching/MultilayerHashCache.cs | Switches multilayer hash-cache log sites to LoggedKey and passes masker to setter. |
| src/UiPath.Caching/MultilayerCacheBase.cs | Creates per-cache KeyMasker; passes it into rehydrator/event publisher; adds Logged(...) helpers. |
| src/UiPath.Caching/MultilayerCache.cs | Switches multilayer cache log sites (including batch-key sites) to LoggedKey(s). |
| src/UiPath.Caching/MemoryCacheSetter.cs | Masks keys in local-memory warning logs; accepts optional KeyMasker. |
| src/UiPath.Caching/Logging/PrefixKeyMaskingPolicy.cs | Adds built-in prefix-based policy (with “mask all” semantics when no prefixes). |
| src/UiPath.Caching/Logging/LoggedKey.cs | Adds LoggedKey/LoggedKeys wrappers deferring rendering to logger formatting. |
| src/UiPath.Caching/Logging/KeyMasker.cs | Adds rendering + masking logic and identifier detection. |
| src/UiPath.Caching/Logging/IKeyMaskingPolicy.cs | Adds IKeyMaskingPolicy, MaskingContext, and built-in null/always-mask policies. |
| src/UiPath.Caching/LocalMemorySetter.cs | Plumbs optional KeyMasker into memory setter base. |
| src/UiPath.Caching/InMemoryRedisCacheProvider.cs | Plumbs IKeyMaskingPolicy into multilayer cache instances. |
| src/UiPath.Caching/InMemoryCacheProvider.cs | Plumbs IKeyMaskingPolicy into multilayer cache instances. |
| src/UiPath.Caching/HashLocalMemorySetter.cs | Plumbs optional KeyMasker into hash memory setter base. |
| src/UiPath.Caching/GlobalUsings.cs | Adds global using for logging namespace. |
| src/UiPath.Caching/Distributed/UiPathDistributedCache.cs | Forces adapter log sites to use LoggedKey.Secret(...). |
| src/UiPath.Caching/Config/KeyMaskingBuilderExtensions.cs | Adds AddKeyMasking(...) registration extensions. |
| src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs | Forces distributed-cache adapter backing tiers to always-mask keys. |
| src/UiPath.Caching/Config/CachingBuilder.cs | Registers default NullKeyMaskingPolicy in DI when none configured. |
| src/UiPath.Caching/CacheEventPublisher.cs | Masks cache keys in event-publisher log sites. |
| src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs | Masks token in factory trace log; threads policy into change token. |
| src/UiPath.Caching/Broadcast/ChangeToken.cs | Masks key/token usages in change-token log sites. |
| docs/reference/settings.md | Documents key masking as a registration seam. |
| docs/how-to/telemetry-and-strategies.md | Adds how-to documentation for masking configuration and semantics. |
| CHANGELOG.md | Adds changelog entry describing key masking feature and scope. |
Review details
Suppressed comments (1)
src/UiPath.Caching/Broadcast/ChangeToken.cs:123
- If cacheEvent.Data is null, this path currently logs an "ignored" message with an empty key. Using the keyless LogEventIgnored overload for the null-data case keeps logs accurate and avoids implying an empty-string key.
if (!string.Equals(data?.Key, _key, StringComparison.OrdinalIgnoreCase))
{
LogEventIgnoredWithKey(Logged(data?.Key), _topic, cacheEvent.Id, cacheEvent.Source);
return false;
}
- Files reviewed: 32/32 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
67ae97b to
6296565
Compare
There was a problem hiding this comment.
🟡 Changes recommended
ChangeToken feeds MaskingContext.CacheName with the topic key rather than the provider/tier name, which can break tier-aware masking policies.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 32/32 changed files
- Comments generated: 2
- Review effort level: Lite
6296565 to
d1c4c6c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
There are compilation-blocking namespace/import issues in newly added masking-related tests and policy code, plus a small log-message typo.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 32/32 changed files
- Comments generated: 4
- Review effort level: Lite
d1c4c6c to
ed60603
Compare
There was a problem hiding this comment.
🟡 Changes recommended
A few newly added/updated docs/comments state that masking always preserves the first three characters, but the implementation masks keys of length ≤3 entirely, so the documentation/changelog should be aligned with the actual behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 32/32 changed files
- Comments generated: 3
- Review effort level: Lite
ed60603 to
181ea8a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Several public/protected constructors were modified by adding parameters, which is binary-breaking; reintroducing the previous overloads to delegate to the new signatures would preserve runtime compatibility.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 32/32 changed files
- Comments generated: 5
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Several warning and broadcast paths discard caller-key or value-type context, allowing configured policies to leave secret keys unmasked.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/UiPath.Caching/MemoryCacheSetter.cs:94
- This refresh state retains
EntryType, but the log wrapper drops it. Consequently, value-type-based policies receiveValueType = nulland can expose the key on this warning path. ForwardmetadataState.EntryTypeto the masker.
logger.LogWarning(ex, "Unable to refresh cache cacheKey {CacheKey}", LoggedKey.For(_masker, metadataState.CacheKey));
- Files reviewed: 32/32 changed files
- Comments generated: 4
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Selective policies and distributed change-token paths can still expose keys, and several value-type-aware sites omit required context.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (6)
src/UiPath.Caching/Redis/RedisCache.cs:957
Logged(key)treats the physical Redis key as the policy input. WithAddKeyMasking("session:"), a normal key such asapp:s:session:secretdoes not start with the configured prefix, so this Warning still logs the complete secret. The audit path needs the caller key as well as the composed Redis key, or must fail closed when only a composed key is available.
LogLargeValueDetected(Logged(key), valueLen);
src/UiPath.Caching/Redis/RedisHashCache.cs:967
- This has the same composed-only leak as the string cache: the prefix policy evaluates a physical key like
app:h:session:secret, sosession:does not match and the Warning emits the raw key. Pass the logical key into the audit path or mask composed-only keys fail-closed.
LogLargeValueDetected(Logged(key), field, valueLen);
src/UiPath.Caching/MemoryCacheSetter.cs:52
- This warning knows
entryTypebut omits it fromMaskingContext. A documented policy such ascontext.ValueType == typeof(SessionData)therefore returns false and the failing key is logged verbatim.
logger.LogWarning(ex, "Unable to set local memory for {CacheKey}", LoggedKey.For(_masker, options.CacheKey));
src/UiPath.Caching/MemoryCacheSetter.cs:94
metadataState.EntryTypeis available here but is not passed to the masking policy. Value-type-based policies consequently fail to mask this warning.
logger.LogWarning(ex, "Unable to refresh cache cacheKey {CacheKey}", LoggedKey.For(_masker, metadataState.CacheKey));
src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs:42
entryTypeis available but discarded: the creation log omits it, and the constructedChangeTokenstores no value type, so all of its later logs passValueType = null. This breaks the documented value-type policy for the entire change-token path; pass the type to both the immediateLoggedKeyand the token.
LogCreateChangeToken(topic.TopicKey, LoggedKey.For(masker, token), _sourceUri);
}
var acceptedEvents = KnownCacheProviderNames.InMemory.Equals(cacheName, StringComparison.OrdinalIgnoreCase) ? MemoryAcceptedEvents : null;
return new ChangeToken<T>(token, topic, _sourceUri, _serializer, _loggerFactory.CreateLogger<ChangeToken<T>>(), _telemetryProvider, acceptedEvents, masker);
src/UiPath.Caching/Logging/KeyMasker.cs:64
- This still says three characters are always retained, but
Maskreturns only****for values of length three or less. The summary should include the short-key behavior already documented elsewhere.
/// <summary>First three characters then <c>****</c>: enough to correlate two lines, not to replay a key.</summary>
- Files reviewed: 32/32 changed files
- Comments generated: 6
- Review effort level: Balanced
…each A cache key is chosen by the caller, so it can be a session id or a token, and the library names keys in log lines throughout. Masking is now a seam rather than a setting. IKeyMaskingPolicy answers one question, whether a key is secret, and is resolved from the container, so every component that logs a key can consult it without carrying per-cache configuration. That is what makes this reach past the caches: the multilayer local setter, the rehydration coordinator, the broadcast change token and the cache event publisher all log keys, and none of them can see a per-tier option. - builder.AddKeyMasking(prefixes) installs the built-in policy; AddKeyMasking<TPolicy>() takes one of your own. Nothing registered resolves NullKeyMaskingPolicy, matching how every other seam here defaults. - The policy is given the caller's key, the value type when the site knows it, and the provider that is logging, so one policy can answer differently per tier. It returns a bool: rendering stays with the library, so a policy cannot leak the value it judged, and one that throws masks rather than taking the log call down. - Rendering splices the masked value into whatever the key strategy composed, found case-insensitively wherever it sits, so a prefix, a suffix or a cluster hash tag all work. A composed key the caller's key is not part of, from a strategy that hashes, is masked whole. - Identifiers stay readable. A row id or a correlation GUID in a log line is not a secret, and removing it makes the line useless. - Nothing is rendered, masked, or even converted to text unless the line is written. The generated log methods take LoggedKey, which does the work in ToString, so a disabled level costs one struct copy. - AddDistributedCache is not subject to the application's policy. Those keys belong to the consumer, so the adapter and the private tiers it configures always mask. Closes #129 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017fwLrS3Sbcen8v6iRkUaFB Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
The writer logs from its fetch loop while the test enumerates the same List, which failed CI with "Collection was modified; enumeration operation may not execute". A ConcurrentQueue behind a snapshot property leaves both call sites as they were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
561ef69 to
5fbe201
Compare
Four log paths were handing the policy something other than what it is
configured against, so AddKeyMasking("session:") answered false and the
line printed the key in full.
The caller key now reaches every site that judges one. CacheEntryOptions
and InternalHashCacheEntryOptions carry CallerKey next to the composed
CacheKey, falling back to it when a path has only the composed form, and
the multilayer Logged overloads take the options rather than the key, so
a PrefixCacheKeyStrategy no longer hides "session:" behind "v2:". The
Redis audit callbacks take a LoggedKey built from both keys instead of
the physical key alone, which meant threading the CacheKey through the
five read paths that had dropped it.
The value type reaches the sites that already had it: the change token
keeps the entryType its factory is given, and MemoryCacheSetter passes
it into both of its warnings. A policy that decides by value type was
being asked about null everywhere.
A tier built with its own policy now gets its own change tokens: the
private caches behind the distributed adapter mask unconditionally, but
their tokens were created through the application's policy, so with
masking off their Trace lines named the consumer's keys. The factory
also keeps one masker per cache name rather than allocating one per
token.
Docs say what the built-in policy does rather than more: no prefix masks
every key it does not read as a plain identifier, and the distributed
provider's note now separates masked logs from verbatim Redis storage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
There was a problem hiding this comment.
🟡 Changes recommended
Several paths still evaluate composed keys or omit value types, allowing configured masking policies to emit raw secrets.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (10)
src/UiPath.Caching/MultilayerCache.cs:808
- These are composed cache keys, so a prefix policy is evaluated against the strategy prefix rather than the caller key. For example,
AddKeyMasking("session:")does not matchv2:session:...and the raw keys are logged. Pass theCacheEntryOptionscollection to the existingLoggedoverload.
LogSettingLocalOnlyForCacheKeys(Logged(setEntries.Select(o => o.CacheEntry.CacheKey).ToArray(), typeof(T)));
src/UiPath.Caching/MultilayerCache.cs:815
cacheEntry.CacheKeyis already strategy-composed, so treating it as the caller key bypasses prefix policies when a custom cache-key strategy prepends text. Use the options overload to evaluate the originalCallerKeywhile rendering the composed key.
LogReplacingCachedKey(Logged(cacheEntry.CacheKey, typeof(T)));
src/UiPath.Caching/MultilayerCache.cs:951
- This converts the options back to composed keys before masking. A policy configured for a caller prefix such as
session:will not matchv2:session:..., so this warning can expose every key in the failed removal. Pass the options directly soLoggedKeysretains caller/composed pairs.
LogInnerCacheRemoveKeysError(ex, Logged(options.Select(o => o.CacheKey).ToArray(), typeof(T)));
src/UiPath.Caching/MultilayerCache.cs:1006
- The key returned by the inner cache is the composed key. Evaluating it as the caller key lets a prepended cache-key strategy defeat
AddKeyMasking("session:"). Use the corresponding options entry, which still carries bothCallerKeyandCacheKey.
LogFoundInnerCacheCopy(Logged(key, typeof(T)));
src/UiPath.Caching/MultilayerCache.cs:1101
- The fetched key is strategy-composed, so prefix-based masking can return false before this trace line renders it. The matching
CacheEntryOptionsretains the original caller key and should be used for the masking decision.
LogFoundInnerCacheCopy(Logged(key, typeof(T)));
src/UiPath.Caching/MultilayerCache.cs:1172
- These options are reduced to their composed keys before masking, causing caller-prefix policies to miss whenever the cache-key strategy prepends or otherwise transforms the key. Pass the
CacheEntryOptionsvalues so each key is judged byCallerKey.
LogSettingLocalOnlyForCacheKeys(Logged(cacheEntries.Select(o => o.CacheEntry.CacheKey).ToArray(), typeof(T)));
src/UiPath.Caching/MultilayerCache.cs:1189
- This warning evaluates each composed key as if it were the caller key. With a strategy prefix, a configured secret prefix no longer matches and the failed-write warning exposes the raw keys. Keep the
CacheEntryOptionsobjects when constructingLoggedKeys.
LogInnerCacheSetKeysError(ex, Logged(cacheEntries.Select(o => o.CacheEntry.CacheKey).ToArray(), typeof(T)));
src/UiPath.Caching/MemoryCacheSetter.cs:54
- This warning also passes only the composed
options.CacheKeytoLoggedKey. A custom prefixing strategy therefore prevents a caller-prefix policy from matching and can expose the raw key on the local-memory failure path. Build the logged value from bothCallerKeyand the composed key.
logger.LogWarning(ex, "Unable to set local memory for {CacheKey}", LoggedKey.For(_masker, options.CacheKey, entryType));
src/UiPath.Caching/MemoryCacheSetter.cs:96
RefreshMetadataState.CacheKeycontains only the composed key, so this warning cannot apply a policy configured for the original caller prefix. PreserveCallerKeyin the refresh state and pass caller/composed values separately when rendering.
logger.LogWarning(ex, "Unable to refresh cache cacheKey {CacheKey}", LoggedKey.For(_masker, metadataState.CacheKey, metadataState.EntryType));
src/UiPath.Caching/Broadcast/ChangeToken.cs:136
- This mismatch path logs the event's composed key as though it were the caller key. With a prefixing cache-key strategy, a policy for
session:seesv2:session:..., returns false, and the ignored key is emitted verbatim. Omit it or apply conservative whole-key masking when the original caller key is unavailable.
LogEventIgnoredWithKey(Logged(ignoredKey), _topic, cacheEvent.Id, cacheEvent.Source);
- Files reviewed: 38/38 changed files
- Comments generated: 6
- Review effort level: Balanced
RedisStreamNotifyChannel disabled its subscribe timer as soon as a subscribe succeeded. A reconnect arriving between that subscribe and the disable had already re-armed the timer, so the disable threw the reschedule away: the channel stayed subscribed against the connection that had just gone, with nothing scheduled to try again until the next reconnect. OnReconnected now records that it asked, and a successful attempt only idles the timer when no reconnect raced it. Reconnect_during_a_subscribe_is_not_lost_with_the_timer raises the event from inside the first subscribe, which is the interleaving that was lost; it times out without the fix. It also explains OnReconnected_reschedules_subscribe failing under load, where the same window is wide enough to hit by accident. CA1873 is off: every log site passes a LoggedKey or LoggedKeys, a struct that copies the key and leaves the policy and the rendering to ToString, which the logger calls only once the level is enabled. The rule counted 59 of those on this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
…rther A composed key with no caller key beside it was handed to the policy as if it were the caller's. A policy configured against caller prefixes answers false for it, so those lines printed the key in full. There is nothing to decide in that case: the render now masks the whole value whenever masking is on and leaves it alone when it is off, which is what the docs already claimed for the composed-only sites. A key arriving in an event from another node is judged the same way, since its caller key never crossed the wire. Where a caller key does exist it now reaches the site. ICacheEntryOptions gained CallerKey as a default member, so no implementation breaks and the publisher, the memory setter and the change token all judge the key the caller passed while still showing the composed one. The change token also keeps it, so its own five log sites stop treating its subscription key as a caller key. The value type reaches two more places: CacheEventPublisher takes it in new overloads that the multilayer caches call with typeof(T), and the rehydration coordinator takes it per call rather than per cache, because one cache serves many value types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
There was a problem hiding this comment.
🟡 Changes recommended
Some transformed batch and refresh keys can still leak, and reconnect scheduling retains a race that can lose resubscription.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/UiPath.Caching/MultilayerCache.cs:819
cacheEntryincludes both the caller and composed keys, but the composed-only wrapper never asks the policy whether this key should be masked. Consequently this batch path masks even keys that a selective policy explicitly allows; use the entry-aware overload.
LogReplacingCachedKey(LoggedComposed(cacheEntry.CacheKey, typeof(T)));
- Files reviewed: 42/42 changed files
- Comments generated: 6
- Review effort level: Balanced
LoggedComposed was applied too widely: CacheEntryValue holds a CacheEntryOptions, so those batch lines do have a caller key and a selective policy should get to decide on each one. They render the options again; only the sites with no caller key at all keep the conservative path. RefreshMetadataState carries CallerKey too, so the refresh warning and the entry the setters rebuild from it judge the caller's key rather than the composed one it was holding. The batch-miss array is built only when the Debug line will be written; the composite key the factory needs is built either way. RedisStreamNotifyChannel closes the rest of the reconnect window: a reconnect could set the flag and arm the timer between the read and the idle, and the idle then threw it away. Reading again after idling leaves the timer armed whichever side of the idle the reconnect lands on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
There was a problem hiding this comment.
🟡 Changes recommended
A reconnect retry can still collide with the active subscription callback and be delayed for the full timer period.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 43/43 changed files
- Comments generated: 1
- Review effort level: Balanced
Arming it from inside the attempt could schedule a callback that fires while _subscribing is still held, so it bailed at the contention check and the next one was a full timer period away — a minute on the stream path. The success path now just idles the timer, and the finally arms it after releasing the interlock when a reconnect asked for another attempt, which is also where a callback that bailed on the contention check gets its request honored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
There was a problem hiding this comment.
🟡 Changes recommended
Strategy-composed keys can lose their original caller key before reaching Redis L2 logs, allowing selective masking policies to expose secrets.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 43/43 changed files
- Comments generated: 2
- Review effort level: Balanced
A rewriting ICacheKeyStrategy is the one thing that changes what the tier below sees, so the prefixes have to be spelled the way the strategy leaves them. The default strategy returns the key unchanged, so this only concerns an installed one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
The Logged overloads were split by LoggedComposed, and the factory's own helper can name the token type it returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
|



Closes #129. Replaces #160, which is closed: review there converged on the design problem this fixes.
The problem with the previous shape
Masking was a per-tier option. Every component that logs a key then needs those options threaded to it, and several cannot get them: the multilayer local setter logs keys at warning, the rehydration coordinator at error, and the broadcast change token at trace and debug. Keys reached logs with masking on, and each new site meant more plumbing.
The seam
IKeyMaskingPolicyanswers one question, whether a key is secret, and is resolved from the container. Any component can consult it without carrying configuration.NullKeyMaskingPolicy, matching how every other seam here defaults.Rendering makes no assumption about layout
The caller's key is spliced out of whatever the key strategy composed, found case-insensitively wherever it sits, so a prefix, a suffix, or a cluster hash tag all work. This is why the previous probe machinery is gone: with both the caller's key and the composed key in hand there is nothing to discover.
A composed key that does not contain the caller's key, from a strategy that hashes or truncates, is masked whole rather than shown.
Identifiers stay readable. A row id or a correlation GUID is not a secret, and removing it makes the line useless.
Cost
Nothing on a disabled level. The generated log methods take
LoggedKey, which renders inToString, so an unwritten line never consults the policy, never masks, and never converts a Redis key to text.Reach
Both caches on every tier, the multilayer local setter, the rehydration coordinator, the broadcast change token and factory, and the cache event publisher: 41 log declarations and 61 call sites.
AddDistributedCacheis deliberately not subject to the application's policy. Those keys belong to the consumer, so the adapter and the private tiers it configures always mask.Verification
Solution builds with zero warnings. Full suite passes on both frameworks, 1688 tests on .NET 10 and 1667 on .NET 8, including a container test that a cache miss line follows the policy and one that the broadcast change token masks the key it waits on. One existing test changed meaning by design: the distributed adapter now masks the key in its failed-write line, and asserts that.
🤖 Generated with Claude Code
https://claude.ai/code/session_017fwLrS3Sbcen8v6iRkUaFB