Skip to content

feat: enhance suppression summary handling and event processing#5

Open
osamaalsahafi wants to merge 1 commit into
nootr:mainfrom
osamaalsahafi:main
Open

feat: enhance suppression summary handling and event processing#5
osamaalsahafi wants to merge 1 commit into
nootr:mainfrom
osamaalsahafi:main

Conversation

@osamaalsahafi

Copy link
Copy Markdown

Close #4

  • Introduced collect_unreported_summaries to retrieve and mark unreported suppressions.
  • Updated check_event methods to return suppression summaries when events are allowed.
  • Added support for episode-end summaries in rate limiting policies.
  • Enhanced SuppressionCounter to track reported suppressions and manage unreported claims.
  • Improved tests for summary emissions and suppression behavior.

- Introduced `collect_unreported_summaries` to retrieve and mark unreported suppressions.
- Updated `check_event` methods to return suppression summaries when events are allowed.
- Added support for episode-end summaries in rate limiting policies.
- Enhanced `SuppressionCounter` to track reported suppressions and manage unreported claims.
- Improved tests for summary emissions and suppression behavior.

@nootr nootr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@osamaalsahafi First of all, thank you a lot for putting this together! I really appreciate the time and thought that went into this, especially the analysis around policy-specific summary semantics. The added tests and the claim_unreported direction show a lot of care.

After reviewing it, I think this PR is trying to solve a bit too much at once. The core bug in #4 is that active emission repeatedly emits stale cumulative summaries. For that, I think the safest first step is to make the active emitter delta/unreported-based.

The episode-end summary behavior is interesting, but it feels like a separate feature/design change. It changes when summaries are emitted, affects should_allow, interacts with formatter execution, and changes behavior even outside active emission. I would prefer to split that out from the #4 fix.

I’d also like to see regression coverage for the main behavior boundaries before merging:

  • A test that enable_active_emission(false) does not emit summaries.
  • A storage/Redis-oriented test or design adjustment showing that claimed/reported state is actually persisted and stale summaries are not re-emitted with non-in-memory storage.

My preferred path would be to narrow this PR to the active-emitter fix only, then handle episode-end summaries and Redis persistence either in follow-ups or with a more explicit design. Again, thank you for the work here! I know this may be more involved than expected, and I’m happy to help split or pick up parts of it if that makes it easier.

/// that have not yet been reported is emitted through the summary formatter.
pub fn should_allow(&self, signature: EventSignature) -> bool {
matches!(self.limiter.check_event(signature), LimitDecision::Allow)
let (decision, episode) = self.limiter.check_event_with_summary(signature);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This now emits summaries even when enable_active_emission is false, which looks like a breaking behavior change: users who did not opt into active summaries may now get WARN summary events.

Also, putting this in should_allow changes it from an allow/suppress predicate into a reporting path with side effects: it can claim summaries and call the formatter. That feels like the wrong layer for this concern.

For fixing #4, I think we should keep should_allow* focused on rate-limit decisions and limit this PR to making the active emitter delta/unreported-based. If episode-end summaries are desirable, they should probably be a separate explicit opt-in (e.g. a dedicated builder option) and not implicitly tied to every Allow.

What do you think, @osamaalsahafi ?

return;
}

if let Some(unreported) = state.counter.claim_unreported() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think this does not work correctly with RedisStorage. collect_unreported_summaries() now mutates reporting state through state.counter.claim_unreported() while iterating with Storage::for_each(&EventState).

That works for in-memory storage because the EventState reference points at the live state. But RedisStorage::for_each deserializes each entry into a temporary EventState, passes a reference to the callback, and then drops it without writing it back. So the reported mark is never persisted, and active emission with Redis would likely keep re-emitting the same summaries.

The new reported_count and reported boundary state also are not included in the Redis serialization format, so deserialized counters always start with nothing reported.

I think this needs either a storage-level way to claim unreported summaries and persist that mutation, or the reported state has to be serialized and updated through a mutable storage path. Otherwise #4 remains unfixed for Redis-backed users.

Sorry if this is a bigger thing to ask than expected. If this makes the PR too large, let me know. I can also pick up the Redis/persistence part separately later, or we can split this PR so the in-memory active-emission fix lands first and Redis is handled in a follow-up.

Comment thread src/domain/summary.rs
/// Advances the reported mark to the current count and returns the newly
/// claimed range, or `None` if there is nothing new to report.
///
/// # Thread Safety

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nit, and assuming I understand this correctly: I think the thread-safety guarantee here is a bit stronger than what the code actually guarantees.

reported_count.fetch_max(count) makes the count claim mostly safe, but the timestamp boundary is updated separately through reported_boundary_nanos. If two reporters race, or if a suppression is recorded while a claim is happening, the count and boundary can observe different moments in time.

For example, record_suppression() increments the count before storing last_suppressed_nanos. A claim can see the new count but still read the old last timestamp, mark that suppression as reported, and produce a summary with an inaccurate range. With two concurrent claimers, the boundary can also be overwritten out of order.

This may be acceptable if the only intended reporter is a single active emitter task, but then I’d avoid documenting this as “exactly one reporter claims each suppression” with a fully correct range. Either the docs should describe the range as best-effort under concurrency, or the claim needs to be guarded by the same mutable state path so count and boundary are updated consistently.

What do you think?

matches!(self.limiter.check_event(signature), LimitDecision::Allow)
let (decision, episode) = self.limiter.check_event_with_summary(signature);
if let Some(summary) = episode {
(self.summary_formatter)(&summary);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

One more concern with emitting this from should_allow: the formatter now runs synchronously in the tracing filter path.

The periodic emitter catches panics from the emit function and runs outside the event decision path. This route does neither. A custom formatter that panics, blocks, takes a lock, or emits more tracing events can now directly affect whether normal log events are processed.

If we keep episode-end summaries, I think this path should at least have the same panic isolation as the active emitter. But my preference would be to keep formatter execution out of should_allow entirely and let the active emitter own summary emission.

let min_count = self.config.min_count;

self.registry.for_each(|signature, state| {
if state.counter.count() < min_count {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nitpick / semantics question: min_count is checked against the all-time counter, but the summary emitted from this method contains only the unreported delta.

That means once a signature has crossed the threshold, future single suppressions can be emitted as summaries with count = 1. For a setting documented as “minimum suppression count to include in summary”, I would probably expect the threshold to apply to the emitted count/delta instead.

Maybe the current behavior is intentional to match collect_summaries(), but then I think it should be documented very explicitly because it is easy to misread.

@nootr

nootr commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Hi @osamaalsahafi, hope you're doing well. Thank you again for your help, I really appreciate it!

This bug needed to be fixed ASAP, so I decided to make a new PR to fix it: #6 Is it OK if I close this PR?

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.

Suppression summaries are re-emitted every interval with stale cumulative counts

2 participants