Skip to content

feat(cache): decide key masking with a policy the whole library can reach - #165

Merged
cosmin-staicu merged 9 commits into
mainfrom
feat/key-masking-policy
Sep 10, 2026
Merged

feat(cache): decide key masking with a policy the whole library can reach#165
cosmin-staicu merged 9 commits into
mainfrom
feat/key-masking-policy

Conversation

@cosmin-staicu

Copy link
Copy Markdown
Member

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

IKeyMaskingPolicy answers one question, whether a key is secret, and is resolved from the container. Any component can consult it without carrying configuration.

b.AddKeyMasking("session:", "user:");   // built-in prefix policy
b.AddKeyMasking<ByValueTypeMasking>();  // or your own
  • Off by default. Nothing registered resolves NullKeyMaskingPolicy, matching how every other seam here defaults.
  • The context carries what a decision needs: the caller's key, the value type when the site knows it, and which provider 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 was asked to judge. A policy that throws masks rather than taking the log call down.

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 in ToString, 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.

AddDistributedCache is 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

@cosmin-staicu
cosmin-staicu requested a lite review from Copilot September 9, 2026 18:00
@github-actions github-actions Bot added the needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

🔎 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

  • adds public API surface (PublicAPI.Unshipped.txt in src/UiPath.Caching)

Other signals

  • large production change (+564 lines under src/)

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.

  • If a CLA is needed → add the cla-required label (a contributor comment with signing steps is posted automatically).
  • If it is not needed → replace needs-cla-review with cla-not-required so later pushes don't re-flag it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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, plus AddKeyMasking(...) builder extensions and default DI registration.
  • Replace direct key logging with LoggedKey / KeyMasker across 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.

Comment thread src/UiPath.Caching/Logging/KeyMasker.cs Outdated
Comment thread src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs Outdated
Comment thread src/UiPath.Caching/Logging/LoggedKey.cs Outdated
Comment thread src/UiPath.Caching/Broadcast/ChangeToken.cs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread src/UiPath.Caching/Broadcast/ChangeToken.cs Outdated
Comment thread src/UiPath.Caching/Logging/LoggedKey.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread src/UiPath.Caching/Logging/PrefixKeyMaskingPolicy.cs
Comment thread tests/UiPath.Caching.Tests/Logging/KeyMaskingTests.cs
Comment thread tests/UiPath.Caching.Tests/Logging/MaskedLogSiteTests.cs
Comment thread src/UiPath.Caching/Broadcast/ChangeToken.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread CHANGELOG.md Outdated
Comment thread docs/how-to/telemetry-and-strategies.md Outdated
Comment thread src/UiPath.Caching/Logging/KeyMasker.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread src/UiPath.Caching/InMemoryCacheProvider.cs
Comment thread src/UiPath.Caching/InMemoryRedisCacheProvider.cs
Comment thread src/UiPath.Caching/MultilayerCacheBase.cs
Comment thread src/UiPath.Caching/Redis/RedisCacheBase.cs
Comment thread src/UiPath.Caching/Redis/RedisCacheProvider.cs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 receive ValueType = null and can expose the key on this warning path. Forward metadataState.EntryType to 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

Comment thread src/UiPath.Caching/Redis/RedisCache.cs Outdated
Comment thread src/UiPath.Caching/Redis/RedisHashCache.cs Outdated
Comment thread src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs Outdated
Comment thread src/UiPath.Caching/MemoryCacheSetter.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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. With AddKeyMasking("session:"), a normal key such as app:s:session:secret does 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, so session: 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 entryType but omits it from MaskingContext. A documented policy such as context.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.EntryType is 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

  • entryType is available but discarded: the creation log omits it, and the constructed ChangeToken stores no value type, so all of its later logs pass ValueType = null. This breaks the documented value-type policy for the entire change-token path; pass the type to both the immediate LoggedKey and 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 Mask returns 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

Comment thread src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCacheBase.cs
Comment thread src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs Outdated
Comment thread docs/how-to/telemetry-and-strategies.md Outdated
Comment thread docs/reference/settings.md
Comment thread src/UiPath.Caching/Config/KeyMaskingBuilderExtensions.cs Outdated
cosmin-staicu and others added 2 commits September 9, 2026 22:10
…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>
@cosmin-staicu
cosmin-staicu force-pushed the feat/key-masking-policy branch from 561ef69 to 5fbe201 Compare September 9, 2026 19:11
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 match v2:session:... and the raw keys are logged. Pass the CacheEntryOptions collection to the existing Logged overload.
                LogSettingLocalOnlyForCacheKeys(Logged(setEntries.Select(o => o.CacheEntry.CacheKey).ToArray(), typeof(T)));

src/UiPath.Caching/MultilayerCache.cs:815

  • cacheEntry.CacheKey is 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 original CallerKey while 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 match v2:session:..., so this warning can expose every key in the failed removal. Pass the options directly so LoggedKeys retains 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 both CallerKey and CacheKey.
            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 CacheEntryOptions retains 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 CacheEntryOptions values so each key is judged by CallerKey.
                    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 CacheEntryOptions objects when constructing LoggedKeys.
                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.CacheKey to LoggedKey. 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 both CallerKey and 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.CacheKey contains only the composed key, so this warning cannot apply a policy configured for the original caller prefix. Preserve CallerKey in 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: sees v2: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

Comment thread src/UiPath.Caching/Broadcast/ChangeToken.cs Outdated
Comment thread src/UiPath.Caching/CacheEventPublisher.cs Outdated
Comment thread src/UiPath.Caching/MemoryCacheSetter.cs
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread src/UiPath.Caching/RehydrationCoordinator.cs
Comment thread docs/how-to/telemetry-and-strategies.md Outdated
cosmin-staicu and others added 2 commits September 9, 2026 23:01
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

  • cacheEntry includes 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

Comment thread src/UiPath.Caching/MemoryCacheSetter.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread src/UiPath.Caching/Broadcast/Redis/RedisStreamNotifyChannel.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread src/UiPath.Caching/Broadcast/Redis/RedisStreamNotifyChannel.cs Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread src/UiPath.Caching/Redis/RedisCache.cs
Comment thread src/UiPath.Caching/Redis/RedisHashCache.cs
cosmin-staicu and others added 2 commits September 9, 2026 23:40
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>
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

@cosmin-staicu
cosmin-staicu merged commit e0c04ac into main Sep 10, 2026
10 checks passed
@cosmin-staicu
cosmin-staicu deleted the feat/key-masking-policy branch September 10, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cache keys are logged at Warning level; distributed-cache keys can be secrets (session ids)

3 participants