From 49763983151e071a650ff9895b4cb3fed4537c73 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 30 May 2026 23:30:24 -0500 Subject: [PATCH 01/18] Add OpenSpec proposal for personal rate notifications Create cut-down MVP spec for personal rate-based notifications informed by issue #177. Includes proposal, design, tasks, and spec with Given/When/Then scenarios covering: - Personal per-user rules (project and stack scoped) - Cache-backed 1-minute UTC bucket counters - Async evaluator job with distributed lock - Cooldown and snooze noise controls - Email delivery with full validation - Svelte UI for rule management Intentionally excludes digests, webhooks, Slack, anomaly detection, quiet hours, and other advanced alerting features. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../add-personal-rate-notifications/design.md | 393 ++++++++++++++++++ .../proposal.md | 117 ++++++ .../specs/rate-notifications/spec.md | 333 +++++++++++++++ .../add-personal-rate-notifications/tasks.md | 143 +++++++ 4 files changed, 986 insertions(+) create mode 100644 openspec/changes/add-personal-rate-notifications/design.md create mode 100644 openspec/changes/add-personal-rate-notifications/proposal.md create mode 100644 openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md create mode 100644 openspec/changes/add-personal-rate-notifications/tasks.md diff --git a/openspec/changes/add-personal-rate-notifications/design.md b/openspec/changes/add-personal-rate-notifications/design.md new file mode 100644 index 0000000000..71bd3d7031 --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/design.md @@ -0,0 +1,393 @@ +# Design: Add Personal Rate Notifications + +## Current Architecture Summary + +- `Project` stores `NotificationSettings` as a `Dictionary` keyed by user ID or integration key. +- Existing `NotificationSettings` are simple booleans: `ReportNewErrors`, `ReportCriticalErrors`, `ReportEventRegressions`, `ReportNewEvents`, `ReportCriticalEvents`, `SendDailySummary`. +- `QueueNotificationAction` (priority 70) enqueues `EventNotification` for existing per-event notifications based on project notification settings. +- `EventNotificationsJob` loads event/project/user and sends Slack/email with existing throttles. +- `Mailer` queues `MailMessage` and `MailMessageJob` sends it. +- `UsageService` already uses cache-backed 5-minute bucket counters for usage tracking via `ICacheClient`. +- Production insulation (`Exceptionless.Insulation`) replaces in-memory cache/queues with Redis/Azure/SQS. + +## Scope + +v1 is personal rate notifications only. No organization-level rules, no webhooks, no digests. + +## Data Model + +### RateNotificationRule + +```csharp +public class RateNotificationRule : IOwnedByOrganizationAndProjectWithIdentity +{ + public string Id { get; set; } + public string OrganizationId { get; set; } + public string ProjectId { get; set; } + public string UserId { get; set; } + public int Version { get; set; } + public string Name { get; set; } + public bool IsEnabled { get; set; } + public RateNotificationSignal Signal { get; set; } + public RateNotificationSubject Subject { get; set; } + public string? StackId { get; set; } + public int Threshold { get; set; } + public TimeSpan Window { get; set; } + public TimeSpan Cooldown { get; set; } + public DateTime? SnoozedUntilUtc { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } + public bool IsDeleted { get; set; } +} +``` + +### Enums + +```csharp +public enum RateNotificationSignal +{ + AllEvents, + Errors, + CriticalErrors, + NewErrors, + Regressions +} + +public enum RateNotificationSubject +{ + Project, + Stack +} +``` + +### Design decisions + +- Rules are stored separately from `Project.NotificationSettings`. +- Existing `NotificationSettings` should not be expanded because rate rules are richer and independently mutable. +- No tag/environment/time-of-day/external-recipient fields in v1. + +## Repository + +### IRateNotificationRuleRepository + +```csharp +public interface IRateNotificationRuleRepository : IRepositoryOwnedByOrganizationAndProject +{ + Task> GetByProjectIdAndUserIdAsync(string projectId, string userId, CommandOptionsDescriptor? options = null); + Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null); + Task CountByProjectIdAndUserIdAsync(string projectId, string userId); +} +``` + +### RateNotificationRuleRepository + +Elasticsearch-backed repository following existing patterns (e.g., `StackRepository`, `WebHookRepository`). + +## API + +### Routes + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/v2/users/{userId}/projects/{projectId}/rate-notifications` | List user's rules for project | +| POST | `/api/v2/users/{userId}/projects/{projectId}/rate-notifications` | Create rule | +| GET | `/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}` | Get rule | +| PUT | `/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}` | Update rule | +| DELETE | `/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}` | Delete rule | +| POST | `/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}/snooze` | Snooze rule | +| POST | `/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}/unsnooze` | Unsnooze rule | + +### Authorization + +- Current user can manage their own rules. +- Global admin can manage any user's rules. +- User must have access to the project's organization. +- External recipients are not supported in v1. + +### Validation + +- `threshold > 0` +- `window` must be one of: 1m, 5m, 10m, 15m, 30m, 1h +- `cooldown` must be at least the window duration +- Recommended default cooldown: 30m +- Project subject must not specify `stack_id` +- Stack subject must specify `stack_id` +- `stack_id` must belong to the same project +- User cannot exceed 20 rules per project +- Rule name must be non-empty and ≤ 100 characters + +## Rule Index + +### RateNotificationRuleIndex service + +Purpose: + +- Load enabled rules for a project. +- Cache compiled counter definitions briefly. +- Ensure event pipeline can cheaply determine which counters to increment. +- Invalidate project rule index on create/update/delete/snooze/unsnooze. + +**Important:** The event pipeline must not increment every possible counter. It must increment only counters required by enabled rules for that project. + +Cache key: `rate:v1:rules:project:{projectId}` + +## Counter Architecture + +### RateCounterService + +Uses 1-minute UTC buckets. + +### Cache keys + +``` +rate:v1:count:{epochMinute}:{counterKey} +rate:v1:active:{epochMinute} +rate:v1:cooldown:{ruleId}:{subjectKey} +rate:v1:rules:project:{projectId} +``` + +### Counter key examples + +``` +project:{projectId}:signal:Errors +project:{projectId}:signal:CriticalErrors +project:{projectId}:signal:NewErrors +project:{projectId}:signal:Regressions +project:{projectId}:signal:AllEvents +project:{projectId}:stack:{stackId}:signal:Errors +project:{projectId}:stack:{stackId}:signal:CriticalErrors +project:{projectId}:stack:{stackId}:signal:NewErrors +project:{projectId}:stack:{stackId}:signal:Regressions +project:{projectId}:stack:{stackId}:signal:AllEvents +``` + +### TTL guidance + +- Counter bucket TTL: 3 hours +- Active bucket TTL: 3 hours +- Cooldown TTL: cooldown duration + 10 minutes + +### Signal matching + +| Signal | Matches when | +|--------|-------------| +| AllEvents | Any event | +| Errors | `ev.IsError()` | +| CriticalErrors | `ev.IsError() && ev.IsCritical()` | +| NewErrors | `ctx.IsNew && ev.IsError()` | +| Regressions | `ctx.IsRegression` | + +## Event Pipeline Action + +### UpdateRateCountersAction + +- Runs after stack assignment (priority > 70, e.g., 75) +- Loads `RateNotificationRuleIndex` for project +- Exits fast if no enabled rules +- Matches event against compiled counter definitions +- Increments matching counters via `RateCounterService` +- Adds counter key to active bucket list/set +- Never sends notifications directly +- Never queries Elasticsearch per event + +## Evaluator Job + +### RateNotificationEvaluatorJob + +- Runs periodically (recommended: every 60 seconds) +- Acquires distributed lock so only one evaluator runs per cluster +- Inspects recently active counters from active bucket sets +- Sums buckets for each rule's configured window +- Skips disabled rules +- Skips snoozed rules (where `SnoozedUntilUtc > now`) +- Compares observed count ≥ threshold +- Enforces cooldown per rule + subject +- Enqueues `RateNotification` work item on threshold crossing +- Sets cooldown key when enqueue succeeds +- Logs fired/skipped reasons with structured context + +This v1 does NOT need a full Normal/Pending/Firing/Recovering state machine. Simple threshold + cooldown + snooze is sufficient. + +## Queue Model + +### RateNotification + +```csharp +public class RateNotification +{ + public string RuleId { get; set; } + public int RuleVersion { get; set; } + public string OrganizationId { get; set; } + public string ProjectId { get; set; } + public string UserId { get; set; } + public string SubjectKey { get; set; } + public string? StackId { get; set; } + public DateTime WindowStartUtc { get; set; } + public DateTime WindowEndUtc { get; set; } + public long ObservedCount { get; set; } + public int Threshold { get; set; } +} +``` + +## Delivery Job + +### RateNotificationsJob + +- Loads rule by ID +- Loads project +- Loads user +- Validates: + - Rule still exists + - Rule is enabled + - Rule version matches or is compatible + - User belongs to organization + - User email is verified + - User email notifications are enabled + - Project/org still exists +- Sends email through `IMailer` +- Skips with structured logs when validation fails +- Does not send Slack/webhooks in v1 (marked as future work) + +## Email + +### IMailer.SendRateNotificationAsync + +```csharp +Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStart, DateTime windowEnd, Stack? stack); +``` + +Email includes: + +- Rule name +- Project name +- Observed count +- Threshold +- Window +- Subject type (project or stack) +- Stack title (when subject is stack and available) +- Link to project or stack +- Cooldown explanation +- No "everything is fine" messaging + +**Example subject:** `[ProjectName] Error rate exceeded` + +**Example body:** + +``` +Rule: Production error storm +Observed: 241 errors in 5 minutes +Threshold: 100 errors in 5 minutes +Cooldown: Further notifications for this rule are suppressed for 30 minutes. +``` + +## Frontend + +### Feature module + +`src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/` + +UI supports: + +- List rules for current user/project +- Create rule +- Edit rule +- Delete rule +- Enable/disable rule +- Snooze/unsnooze rule + +### Form fields + +- Name +- Signal (dropdown: All Events, Errors, Critical Errors, New Errors, Regressions) +- Subject (Project or Stack) +- Stack selector (shown when Subject = Stack) +- Threshold (number) +- Window (dropdown: 1m, 5m, 10m, 15m, 30m, 1h) +- Cooldown (duration, minimum = window) +- Enabled (toggle) + +### Not built in v1 + +- Rule history tab +- Delivery history +- Action builder +- Webhook builder +- Slack builder +- Digest UI +- Quiet hours UI +- Organization inheritance UI +- Preview charts + +### Noise warning + +Display when creating/editing: "This rule may be noisy. Use a cooldown to avoid repeated emails." + +## Metrics and Logging + +### Metrics + +- `rate_notification.rules.loaded` +- `rate_notification.counters.incremented` +- `rate_notification.evaluator.runs` +- `rate_notification.evaluator.rules_evaluated` +- `rate_notification.evaluator.notifications_enqueued` +- `rate_notification.delivery.sent` +- `rate_notification.delivery.skipped` + +### Structured log fields + +- `RuleId` +- `ProjectId` +- `UserId` +- `ObservedCount` +- `Threshold` +- `Reason` (skipped/fired) + +## Bootstrap / DI + +- Register `IRateNotificationRuleRepository` / `RateNotificationRuleRepository` +- Register `RateNotificationRuleIndex` +- Register `RateCounterService` +- Register `RateNotificationEvaluatorJob` +- Register `IQueue` notification queue +- Register `RateNotificationsJob` (delivery) +- Update `Exceptionless.Insulation` queue registration for Redis/Azure/SQS providers +- Add queue health check if existing patterns support it + +## Testing Strategy + +### Unit tests + +- Rule validation (threshold, window, cooldown, subject/stack consistency, name) +- Counter key builder +- Counter increments +- Bucket summing +- Signal matching +- Cooldown behavior +- Snooze behavior +- Evaluator threshold crossing logic + +### Integration tests + +- User can CRUD own rules +- User cannot manage another user's rules +- Global admin can manage another user's rules +- Stack rule rejects stack from another project +- Evaluator enqueues notification when threshold crossed +- Evaluator does not enqueue below threshold +- Evaluator respects cooldown +- Evaluator respects snooze +- Delivery skips disabled rule +- Delivery skips unverified email +- Delivery skips user not in org +- Delivery sends email for valid rule + +### Not tested in v1 + +- Action execution +- Webhooks +- Slack +- Digests +- No-data alerts +- Backtesting +- Rule history UI diff --git a/openspec/changes/add-personal-rate-notifications/proposal.md b/openspec/changes/add-personal-rate-notifications/proposal.md new file mode 100644 index 0000000000..583f87408f --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/proposal.md @@ -0,0 +1,117 @@ +# Proposal: Add Personal Rate Notifications + +## Summary + +Add personal, rate-based project and stack notifications. Users can configure rules like: + +- "Email me when this project has more than 100 errors in 5 minutes." +- "Email me when this stack occurs more than 20 times in 10 minutes." + +The implementation is intentionally small and focused: + +- Cheap cache-backed counters (1-minute UTC buckets) +- Asynchronous evaluator job +- Email delivery +- Cooldown and snooze to reduce noise + +This feature is informed by [issue #177](https://github.com/exceptionless/Exceptionless/issues/177), but intentionally does not implement the full notification wishlist. Issue #177's core goal — notifications should keep users informed without overwhelming them — is addressed through mandatory cooldowns, snooze support, and cache-only hot paths. + +## Classification + +- **Type:** Feature +- **Affected areas:** + - Backend/API + - Event pipeline + - Cache/Redis + - Queue jobs + - Email + - Svelte UI + - Tests +- **OpenSpec justification:** + - New API endpoints + - New persisted rule model + - New event-pipeline counter behavior + - New evaluator/delivery jobs + - Cross-cutting notification behavior + - User-facing notification settings + +## Goals + +- Notify users when project or stack activity crosses a configured threshold. +- Detect high-volume repeated errors that current new/error/regression notifications can miss. +- Reduce notification noise versus per-event email streams. +- Keep event ingestion cheap. +- Support horizontal scaling with distributed cache and queues. +- Validate user/project/org state before sending. +- Support snoozing a noisy rule. +- Provide enough logging, metrics, and tests to trust the system. + +## Non-goals + +- Anomaly detection or machine learning +- Percent-change alerts +- Arbitrary query/filter language +- Tag rules +- Environment rules +- Time-of-day rules +- Quiet hours / quiet days +- Daily/weekly/monthly summary changes +- Digest emails +- No-data alerts +- Recovery/resolved notifications +- Notification grouping/digesting +- Generic webhook actions +- Slack actions (marked as future work) +- PagerDuty/OpsGenie integrations +- External recipients who are not Exceptionless users +- Mutating automated actions +- Action execution engine +- Durable action execution +- Rule history UI +- Delivery history UI +- In-app notification center +- Billing/overage notification changes +- Email sender/from-address overhaul +- Queue/system health notification UI +- Reduce-noise in-app callouts + +## Deliberate Cutbacks + +Issue #177 is broad — it covers configurable rules based on type, tags, time of day, number of exceptions, environment, snoozing, periodic digest emails, reduced-noise behavior, in-app notices, and third-party integrations. + +This change intentionally implements only **personal rate notifications** — the minimum useful product: + +> "When this project or stack exceeds X matching events in Y minutes, email me, but not more than once per cooldown, and let me snooze the rule." + +The following are explicitly deferred: + +- Digests and periodic summaries +- Quiet hours and time-of-day logic +- Advanced conditional rules (tags, environment, arbitrary filters) +- External recipients and non-user notification targets +- Webhooks, Slack, PagerDuty, and other delivery channels +- Mutating or automated actions +- Organization-level rules and inheritance + +The first release should prove the cheap counter architecture and noise-control model before adding advanced alerting features. + +## Compatibility Risks + +| Risk | Mitigation | +|------|-----------| +| Existing project notification settings remain unchanged | Rate rules are stored separately; `Project.NotificationSettings` is not modified | +| Existing `EventNotificationsJob` behavior remains unchanged | Rate counters are a new pipeline action; existing notification queueing is unaffected | +| Existing `DailySummaryJob` behavior remains unchanged | No changes to daily summary logic | +| Existing Slack/webhook integrations are not changed | New delivery path is email-only; existing `WebHookNotification` queue untouched | +| Rate counters depend on distributed cache in production | In-memory cache/queues remain development-only; production requires Redis/Azure providers | +| Notification noise could increase if defaults are bad | Mandatory cooldowns, validation, and max rules per project (20) | +| Rules may become stale if user/project/org state changes | Delivery job re-validates rule, user, project, and org state before sending | + +## Rollback Plan + +1. Disable the rate notification evaluator job. +2. Disable the `UpdateRateCountersAction` in the event pipeline. +3. Existing event notifications and daily summaries continue to operate unchanged. +4. Delete or ignore persisted rate notification rules if needed. +5. Remove new UI route/feature module. +6. Remove new queues and cache keys if rolling back fully. diff --git a/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md b/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md new file mode 100644 index 0000000000..09ff924d02 --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md @@ -0,0 +1,333 @@ +# Spec: Rate Notifications + +## ADDED Requirements + +### Requirement: User can list their project rate notification rules + +The API MUST allow authenticated users to retrieve their own rate notification rules for a specific project. + +#### Scenario: Authenticated user lists own rules + +Given authenticated user with access to project +When GET `/api/v2/users/{userId}/projects/{projectId}/rate-notifications` +Then return only that user's rules for that project. + +### Requirement: User can create a project rate notification rule + +The API MUST allow authenticated users to create a rate notification rule scoped to a project with a threshold, window, cooldown, signal, and subject. + +#### Scenario: Valid project rule is persisted + +Given authenticated user with access to project +When POST valid rule with threshold, window, cooldown, signal, subject=Project +Then rule is persisted with organization_id, project_id, user_id, threshold, window, cooldown, signal, subject. + +### Requirement: User can create a stack rate notification rule + +The API MUST allow authenticated users to create a rate notification rule scoped to a specific stack within a project. + +#### Scenario: Valid stack rule is persisted + +Given authenticated user with access to project +And stack belongs to project +When POST subject=Stack and stack_id +Then rule is persisted. + +### Requirement: Invalid stack scope is rejected + +The API MUST reject a rule targeting a stack that does not belong to the specified project. + +#### Scenario: Stack from another project is rejected + +Given stack does not belong to project +When user creates stack rule +Then response is 400 or 404. + +### Requirement: User cannot manage another user's rules + +The API MUST deny non-admin users access to rate notification rules belonging to other users. + +#### Scenario: Non-admin user is denied access to other user's rules + +Given non-admin user A +When they request user B's rate notification rules +Then response is 404 or 403. + +### Requirement: Global admin can manage another user's rules + +The API MUST allow global administrators to create, read, update, and delete rate notification rules on behalf of any user. + +#### Scenario: Admin accesses another user's rules + +Given global admin +When they manage another user's rate notification rules +Then request succeeds. + +### Requirement: Rule validation prevents noisy/invalid rules + +The API MUST enforce validation constraints to prevent misconfigured or excessively noisy rules. + +#### Scenario: Threshold must be positive + +Given threshold <= 0 +When user creates rule +Then response is 400 with validation error. + +#### Scenario: Unsupported window is rejected + +Given window is not one of 1m, 5m, 10m, 15m, 30m, 1h +When user creates rule +Then response is 400 with validation error. + +#### Scenario: Cooldown shorter than window is rejected + +Given cooldown < window +When user creates rule +Then response is 400 with validation error. + +#### Scenario: Project subject with stack_id is rejected + +Given subject = Project and stack_id is set +When user creates rule +Then response is 400 with validation error. + +#### Scenario: Stack subject without stack_id is rejected + +Given subject = Stack and stack_id is null +When user creates rule +Then response is 400 with validation error. + +#### Scenario: Empty name is rejected + +Given name is empty +When user creates rule +Then response is 400 with validation error. + +#### Scenario: Name exceeding 100 characters is rejected + +Given name length > 100 +When user creates rule +Then response is 400 with validation error. + +#### Scenario: Exceeding max rules per project is rejected + +Given user already has 20 rules for project +When user creates another rule +Then response is 400 with validation error. + +### Requirement: Event pipeline increments matching configured counters + +The event pipeline MUST increment the corresponding rate counter in the current UTC minute bucket when an event matches an enabled rule's signal. + +#### Scenario: Matching event increments counter + +Given enabled project rule exists +When matching event is processed +Then matching rate counter for current UTC minute bucket is incremented. + +### Requirement: Event pipeline skips projects without enabled rules + +The event pipeline MUST NOT perform any counter operations for projects with no enabled rate notification rules. + +#### Scenario: No rules means no counter work + +Given no enabled rate notification rules for project +When event is processed +Then no rate counter is incremented. + +### Requirement: Event pipeline increments only required counters + +The pipeline MUST only increment counters that are required by at least one enabled rule, not all possible signal counters. + +#### Scenario: Only configured signal counters are incremented + +Given project has only an Errors rule +When critical error event is processed +Then Errors counter is incremented +And unrelated counters are not incremented unless required by configured rules. + +### Requirement: Stack counters are stack-specific + +Stack-scoped counters MUST only be incremented by events on the specific stack referenced by the rule. + +#### Scenario: Event on different stack does not increment + +Given stack rule exists for stack A +When event occurs on stack B +Then stack A counter is not incremented. + +### Requirement: Evaluator enqueues notification when threshold is crossed + +The evaluator job MUST enqueue a RateNotification work item when the sum of counter buckets for a rule's window meets or exceeds the threshold. + +#### Scenario: Threshold reached triggers enqueue + +Given rule threshold is 100 errors in 5 minutes +And matching counters sum to 100 or more +When evaluator runs +Then RateNotification work item is enqueued. + +### Requirement: Evaluator does not enqueue below threshold + +The evaluator MUST NOT enqueue a notification when the observed event count is below the rule threshold. + +#### Scenario: Below threshold is silent + +Given rule threshold is 100 +And observed count is 99 +When evaluator runs +Then no work item is enqueued. + +### Requirement: Cooldown suppresses repeated sends + +The evaluator MUST NOT enqueue a new notification for a rule until the cooldown period from the previous firing has expired. + +#### Scenario: Active cooldown prevents re-enqueue + +Given rule has fired +And cooldown has not expired +When threshold is crossed again +Then no new work item is enqueued. + +### Requirement: Snooze suppresses sends + +The evaluator MUST NOT fire a snoozed rule until the snooze period expires. + +#### Scenario: Snoozed rule does not fire + +Given rule snoozed_until_utc is in the future +When threshold is crossed +Then no work item is enqueued. + +### Requirement: Disabled rules do not fire + +The evaluator MUST skip disabled rules during evaluation. + +#### Scenario: Disabled rule is skipped + +Given rule is disabled +When threshold is crossed +Then no work item is enqueued. + +### Requirement: No healthy/no-activity emails + +The system MUST NOT send notifications indicating that everything is fine or that no activity occurred. + +#### Scenario: Below threshold produces no notification + +Given threshold is not crossed +When evaluator runs +Then no healthy/no-activity notification is sent. + +### Requirement: Delivery sends email for valid notification + +The delivery job MUST send a rate notification email when the rule, project, user, and email state are all valid. + +#### Scenario: Valid state delivers email + +Given RateNotification work item +And rule/project/user are valid +And user email is verified +And user email notifications are enabled +When delivery job processes item +Then rate notification email is queued. + +### Requirement: Delivery skips invalid user state + +The delivery job MUST skip sending and log structured reasons when any precondition is not met. + +#### Scenario: Unverified email is skipped + +Given user email is unverified +When delivery job processes item +Then notification is skipped with log. + +#### Scenario: Notifications disabled is skipped + +Given user email notifications are disabled +When delivery job processes item +Then notification is skipped with log. + +#### Scenario: User not in organization is skipped + +Given user no longer belongs to organization +When delivery job processes item +Then notification is skipped with log. + +#### Scenario: Deleted project is skipped + +Given project is deleted +When delivery job processes item +Then notification is skipped with log. + +#### Scenario: Deleted rule is skipped + +Given rule is deleted +When delivery job processes item +Then notification is skipped with log. + +#### Scenario: Disabled rule is skipped during delivery + +Given rule is disabled +When delivery job processes item +Then notification is skipped with log. + +#### Scenario: Stale rule version is skipped + +Given rule version does not match work item +When delivery job processes item +Then notification is skipped with log. + +### Requirement: Email includes actionable context + +Rate notification emails MUST include all information the user needs to understand and act on the alert. + +#### Scenario: Email body contains all required fields + +Given notification email is queued +Then email includes rule name, project name, observed count, threshold, window, subject type, stack title (when applicable), link to project or stack, and cooldown explanation. + +### Requirement: User can manage project rate rules in Svelte UI + +The Svelte UI MUST provide a full CRUD interface for rate notification rules within project settings. + +#### Scenario: Full CRUD and controls available + +Given user opens project notification settings +Then they can list, create, edit, delete, enable/disable, snooze, and unsnooze rate rules. + +### Requirement: UI avoids advanced/deferred features + +The v1 UI MUST NOT expose features that are deferred to future iterations. + +#### Scenario: Deferred features are not exposed + +Given user opens rate notification management UI +Then UI does not expose digests, webhooks, automated actions, quiet hours, arbitrary filters, external recipients, or no-data alerts. + +### Requirement: Rate notification hot path is cache-only + +The event pipeline counter path MUST use only cache operations and MUST NOT query Elasticsearch per event. + +#### Scenario: No Elasticsearch per event + +Given event is processed +Then rate notification countering does not query Elasticsearch per event. + +### Requirement: Production requires distributed cache/queue + +Production deployments MUST use provider-backed (Redis/Azure/SQS) cache and queues for rate notification infrastructure. + +#### Scenario: Production uses provider-backed infrastructure + +Given production deployment +Then rate notification counters and queues use provider-backed cache/queues, not in-memory providers. + +### Requirement: Rollback does not affect existing notifications + +Disabling rate notification components MUST NOT impact existing event notification or daily summary behavior. + +#### Scenario: Disabling rate notifications leaves existing behavior intact + +Given rate notification evaluator/action is disabled +Then existing event notifications and daily summaries continue to operate unchanged. diff --git a/openspec/changes/add-personal-rate-notifications/tasks.md b/openspec/changes/add-personal-rate-notifications/tasks.md new file mode 100644 index 0000000000..d4cde871fe --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/tasks.md @@ -0,0 +1,143 @@ +# Tasks: Add Personal Rate Notifications + +## Backend - Models and Repositories + +- [ ] **Add RateNotificationRule model and enums** + - Create `RateNotificationRule` model with all fields (Id, OrganizationId, ProjectId, UserId, Version, Name, IsEnabled, Signal, Subject, StackId, Threshold, Window, Cooldown, SnoozedUntilUtc, CreatedUtc, UpdatedUtc, IsDeleted) + - Create `RateNotificationSignal` enum (AllEvents, Errors, CriticalErrors, NewErrors, Regressions) + - Create `RateNotificationSubject` enum (Project, Stack) + +- [ ] **Add IRateNotificationRuleRepository and implementation** + - Create interface extending `IRepositoryOwnedByOrganizationAndProject` + - Add methods: `GetByProjectIdAndUserIdAsync`, `GetEnabledByProjectIdAsync`, `CountByProjectIdAndUserIdAsync` + - Create Elasticsearch-backed implementation following existing repository patterns + +- [ ] **Register repository in DI** + - Register `IRateNotificationRuleRepository` / `RateNotificationRuleRepository` in `Bootstrapper.cs` + +## Backend - Countering and Evaluation + +- [ ] **Add RateNotificationRuleIndex service** + - Load enabled rules for a project from repository + - Cache compiled counter definitions with short TTL + - Invalidate on rule create/update/delete/snooze/unsnooze + - Cache key: `rate:v1:rules:project:{projectId}` + +- [ ] **Add RateCounterService** + - Implement 1-minute UTC bucket counters via `ICacheClient` + - Methods: increment counter, sum buckets for window, check/set cooldown + - Counter key format: `rate:v1:count:{epochMinute}:{counterKey}` + - Active bucket tracking: `rate:v1:active:{epochMinute}` + - Cooldown key format: `rate:v1:cooldown:{ruleId}:{subjectKey}` + - TTLs: counter/active = 3h, cooldown = cooldown + 10m + +- [ ] **Add UpdateRateCountersAction (event pipeline)** + - Priority after stack assignment (e.g., 75) + - Load rule index for project; exit fast if no enabled rules + - Match event against compiled counter definitions (signal matching) + - Increment matching counters; add to active bucket set + - Never query Elasticsearch per event; never send notifications directly + +- [ ] **Add RateNotificationEvaluatorJob** + - Periodic job (60s interval) with distributed lock + - Inspect recently active counters + - Sum buckets for each rule's window + - Skip disabled/snoozed rules + - Compare observed ≥ threshold; enforce cooldown per rule+subject + - Enqueue `RateNotification` work item on threshold crossing + - Set cooldown key on successful enqueue + - Structured logging for fired/skipped reasons + +- [ ] **Add RateNotification queue model** + - Fields: RuleId, RuleVersion, OrganizationId, ProjectId, UserId, SubjectKey, StackId, WindowStartUtc, WindowEndUtc, ObservedCount, Threshold + +- [ ] **Register counter/evaluator services in DI** + - Register `RateNotificationRuleIndex`, `RateCounterService`, `RateNotificationEvaluatorJob` + - Register `IQueue` in Core and Insulation bootstrappers + +## Backend - Delivery + +- [ ] **Add RateNotificationsJob (delivery)** + - Load rule, project, user + - Validate: rule exists, enabled, version compatible, user in org, email verified, notifications enabled, project/org exists + - Send email via `IMailer.SendRateNotificationAsync` + - Skip with structured logs on validation failure + +- [ ] **Add IMailer.SendRateNotificationAsync** + - Add method to `IMailer` interface and `Mailer` implementation + - Email includes: rule name, project name, observed count, threshold, window, subject type, stack title (when applicable), link, cooldown explanation + - Subject format: `[ProjectName] Error rate exceeded` + - No "everything is fine" messaging + +- [ ] **Update Insulation queue registration** + - Register `IQueue` for Redis/Azure/SQS providers in `Exceptionless.Insulation/Bootstrapper.cs` + +## Backend - API + +- [ ] **Add RateNotificationRuleController** + - Routes: GET list, POST create, GET by id, PUT update, DELETE, POST snooze, POST unsnooze + - Authorization: current user manages own rules; global admin manages any user's rules; user must access project org + - Validation: threshold > 0, supported windows, cooldown ≥ window, subject/stack consistency, max 20 rules per user per project, name non-empty and ≤ 100 chars + +- [ ] **Add request/response DTOs** + - Create/update request models with validation attributes + - Snooze request model (duration or until timestamp) + +- [ ] **Update tests/http files** + - Add HTTP sample requests for all rate notification endpoints + +## Frontend + +- [ ] **Add rate-notifications feature module** + - Create `src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/` + - Models/types matching API DTOs + - TanStack Query API wrappers (list, create, update, delete, snooze, unsnooze) + +- [ ] **Add rate notification rule list component** + - List rules for current user/project + - Show enable/disable toggle, snooze status + - Delete confirmation + +- [ ] **Add rate notification rule form component** + - Fields: Name, Signal, Subject, Stack selector, Threshold, Window, Cooldown, Enabled + - Validation matching backend constraints + - Noise warning copy: "This rule may be noisy. Use a cooldown to avoid repeated emails." + +- [ ] **Integrate into project settings** + - Add rate notifications section/tab in project notification settings + - Route and navigation entry + +## Tests + +- [ ] **Add unit tests** + - Rule validation (threshold, window, cooldown, subject/stack, name) + - Counter key builder + - Counter increments and bucket summing + - Signal matching logic + - Cooldown behavior + - Snooze behavior + - Evaluator threshold crossing + +- [ ] **Add integration tests** + - User CRUD own rules (list, create, get, update, delete) + - User cannot manage another user's rules + - Global admin can manage another user's rules + - Stack rule rejects stack from another project + - Evaluator enqueues when threshold crossed + - Evaluator does not enqueue below threshold + - Evaluator respects cooldown + - Evaluator respects snooze + - Delivery skips disabled rule + - Delivery skips unverified email + - Delivery skips user not in org + - Delivery sends email for valid rule + +## Validation + +- [ ] **Run OpenSpec validation** + - `openspec validate add-personal-rate-notifications --strict` + +- [ ] **Run relevant builds and tests** + - `dotnet build` + - `dotnet test` + - Frontend build and lint From f821e7f59a453275d5a7d5661e111a3a7bf07208 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:04:58 -0500 Subject: [PATCH 02/18] fix: address five spec review findings in personal rate notifications Bugs identified via staff-engineer review comparing against existing notification pipeline (070_QueueNotificationAction, EventNotificationsJob, Stack.AllowNotifications): 1. Missing premium feature gate: The spec introduced a new free notification channel but the existing system requires HasPremiumFeatures. Added to proposal risks table, design gating section, evaluator, and Svelte UI. 2. Muted-stack / discarded-event suppression: Counters would have counted events on Ignored/Snoozed/Discarded stacks and known-bot requests. Added AllowNotifications and IsBot checks to UpdateRateCountersAction, mirroring existing 070_QueueNotificationAction logic. 3. Snooze back-alert bug (shared-counter race): Rate counters are keyed by project/stack + signal, not per-rule. After snooze expires, activity gathered during the muted window would immediately fire because the counter reflects global traffic. Fix: evaluator uses max(windowStartUtc, rule.SnoozedUntilUtc) as the lower bucket boundary so only post-snooze traffic counts toward the threshold. 4. Orphaned rule lifecycle: No cleanup on membership removal or project/org deletion. Added CleanupRateNotificationRulesAsync pattern mirroring OrganizationService.CleanupProjectNotificationSettingsAsync. 5. Stack-scoped email missing context: Delivery job never loaded the stack, so stack-scoped email copy had no title/link. Added stack lookup requirement to delivery design and spec. Also expanded test matrix to cover all five findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../add-personal-rate-notifications/design.md | 38 ++++++++- .../proposal.md | 9 +++ .../specs/rate-notifications/spec.md | 77 +++++++++++++++++++ .../add-personal-rate-notifications/tasks.md | 22 +++++- 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/openspec/changes/add-personal-rate-notifications/design.md b/openspec/changes/add-personal-rate-notifications/design.md index 71bd3d7031..416337c9dc 100644 --- a/openspec/changes/add-personal-rate-notifications/design.md +++ b/openspec/changes/add-personal-rate-notifications/design.md @@ -12,7 +12,7 @@ ## Scope -v1 is personal rate notifications only. No organization-level rules, no webhooks, no digests. +v1 is personal rate notifications only. No organization-level rules, no webhooks, no digests. It also follows the existing premium-only occurrence-notification model instead of introducing a new free notification channel. ## Data Model @@ -65,6 +65,7 @@ public enum RateNotificationSubject - Rules are stored separately from `Project.NotificationSettings`. - Existing `NotificationSettings` should not be expanded because rate rules are richer and independently mutable. - No tag/environment/time-of-day/external-recipient fields in v1. +- `SnoozedUntilUtc` is also the rule's resume boundary. Manual unsnooze sets it to the current UTC time instead of clearing it so the evaluator can ignore activity gathered during the muted period. ## Repository @@ -104,6 +105,11 @@ Elasticsearch-backed repository following existing patterns (e.g., `StackReposit - User must have access to the project's organization. - External recipients are not supported in v1. +### Premium gating + +- Rate notifications follow the current occurrence-notification premium model. +- Rules may remain persisted across plan downgrade, but countering, evaluation, delivery, and Svelte create/enable controls MUST treat the feature as unavailable until premium is restored. + ### Validation - `threshold > 0` @@ -182,8 +188,12 @@ project:{projectId}:stack:{stackId}:signal:AllEvents ### UpdateRateCountersAction - Runs after stack assignment (priority > 70, e.g., 75) +- Exits fast when the organization does not have premium features - Loads `RateNotificationRuleIndex` for project - Exits fast if no enabled rules +- Skips events on stacks where `!ctx.Stack.AllowNotifications` +- Skips canceled/discarded events that would not produce occurrence notifications +- Skips requests already marked as bots by request-info enrichment - Matches event against compiled counter definitions - Increments matching counters via `RateCounterService` - Adds counter key to active bucket list/set @@ -197,7 +207,9 @@ project:{projectId}:stack:{stackId}:signal:AllEvents - Runs periodically (recommended: every 60 seconds) - Acquires distributed lock so only one evaluator runs per cluster - Inspects recently active counters from active bucket sets +- Skips organizations without premium features - Sums buckets for each rule's configured window +- Uses `max(windowStartUtc, rule.SnoozedUntilUtc)` as the lower bound when a snooze boundary falls inside the evaluation window so a rule resumes from a fresh baseline - Skips disabled rules - Skips snoozed rules (where `SnoozedUntilUtc > now`) - Compares observed count ≥ threshold @@ -208,6 +220,12 @@ project:{projectId}:stack:{stackId}:signal:AllEvents This v1 does NOT need a full Normal/Pending/Firing/Recovering state machine. Simple threshold + cooldown + snooze is sufficient. +### Snooze semantics + +- Snooze suppresses delivery immediately. +- When a snooze expires or a user manually unsnoozes a rule, the rule resumes from a fresh baseline. +- Activity observed entirely during the snooze window MUST NOT trigger an immediate post-snooze alert, even when another enabled rule kept the shared counter hot. + ## Queue Model ### RateNotification @@ -236,6 +254,7 @@ public class RateNotification - Loads rule by ID - Loads project - Loads user +- Loads stack for stack-scoped rules so email copy can include stack title and deep link - Validates: - Rule still exists - Rule is enabled @@ -248,6 +267,13 @@ public class RateNotification - Skips with structured logs when validation fails - Does not send Slack/webhooks in v1 (marked as future work) +## Lifecycle cleanup + +- Remove rate notification rules when a user loses organization access. +- Remove rate notification rules when a project or organization is deleted. +- Invalidate cached rule indexes when cleanup runs so orphaned rules stop consuming evaluator work immediately. +- Reuse the same work-item cleanup pattern already used for `Project.NotificationSettings` updates where practical. + ## Email ### IMailer.SendRateNotificationAsync @@ -294,6 +320,7 @@ UI supports: - Delete rule - Enable/disable rule - Snooze/unsnooze rule +- Disabled/upgrade state when the organization lacks premium features ### Form fields @@ -363,8 +390,11 @@ Display when creating/editing: "This rule may be noisy. Use a cooldown to avoid - Counter increments - Bucket summing - Signal matching +- Premium gating +- `AllowNotifications` / bot suppression checks - Cooldown behavior - Snooze behavior +- Fresh-baseline behavior after snooze expiry or manual unsnooze - Evaluator threshold crossing logic ### Integration tests @@ -373,14 +403,20 @@ Display when creating/editing: "This rule may be noisy. Use a cooldown to avoid - User cannot manage another user's rules - Global admin can manage another user's rules - Stack rule rejects stack from another project +- Non-premium organizations do not counter, evaluate, or deliver rate notifications +- Events on stacks with `AllowNotifications = false` do not increment rate counters +- Bot-marked requests do not increment rate counters - Evaluator enqueues notification when threshold crossed - Evaluator does not enqueue below threshold - Evaluator respects cooldown - Evaluator respects snooze +- Activity gathered during snooze does not fire immediately when the rule resumes - Delivery skips disabled rule - Delivery skips unverified email - Delivery skips user not in org - Delivery sends email for valid rule +- Delivery loads stack context for stack-scoped emails +- Membership/project/org cleanup removes orphaned rules ### Not tested in v1 diff --git a/openspec/changes/add-personal-rate-notifications/proposal.md b/openspec/changes/add-personal-rate-notifications/proposal.md index 583f87408f..6f215db58f 100644 --- a/openspec/changes/add-personal-rate-notifications/proposal.md +++ b/openspec/changes/add-personal-rate-notifications/proposal.md @@ -13,6 +13,8 @@ The implementation is intentionally small and focused: - Asynchronous evaluator job - Email delivery - Cooldown and snooze to reduce noise +- Premium-gated runtime behavior that matches existing occurrence notifications +- Existing notification suppression semantics so muted traffic does not reappear as rate noise This feature is informed by [issue #177](https://github.com/exceptionless/Exceptionless/issues/177), but intentionally does not implement the full notification wishlist. Issue #177's core goal — notifications should keep users informed without overwhelming them — is addressed through mandatory cooldowns, snooze support, and cache-only hot paths. @@ -42,8 +44,11 @@ This feature is informed by [issue #177](https://github.com/exceptionless/Except - Reduce notification noise versus per-event email streams. - Keep event ingestion cheap. - Support horizontal scaling with distributed cache and queues. +- Preserve current premium-only occurrence-notification behavior. +- Honor existing notification suppression so ignored, snoozed, discarded, and fixed stacks — and bot traffic already excluded from occurrence emails — do not generate rate alerts. - Validate user/project/org state before sending. - Support snoozing a noisy rule. +- Resume from a fresh baseline after snooze instead of back-alerting on traffic that happened while the rule was muted. - Provide enough logging, metrics, and tests to trust the system. ## Non-goals @@ -103,9 +108,13 @@ The first release should prove the cheap counter architecture and noise-control | Existing `EventNotificationsJob` behavior remains unchanged | Rate counters are a new pipeline action; existing notification queueing is unaffected | | Existing `DailySummaryJob` behavior remains unchanged | No changes to daily summary logic | | Existing Slack/webhook integrations are not changed | New delivery path is email-only; existing `WebHookNotification` queue untouched | +| Existing premium-only occurrence notification behavior could drift | Countering, evaluation, delivery, and Svelte enablement follow the same premium gating model as existing occurrence notifications | | Rate counters depend on distributed cache in production | In-memory cache/queues remain development-only; production requires Redis/Azure providers | +| Muted stacks or bot traffic could reappear as new rate noise | Countering honors `Stack.AllowNotifications`, canceled/discarded contexts, and request-info bot markers before incrementing counters | +| Snooze could defer a notification instead of suppressing it | Evaluation resumes from a fresh baseline using the snooze boundary so activity gathered during snooze does not fire immediately on resume | | Notification noise could increase if defaults are bad | Mandatory cooldowns, validation, and max rules per project (20) | | Rules may become stale if user/project/org state changes | Delivery job re-validates rule, user, project, and org state before sending | +| Orphaned rules could be indexed and evaluated forever | Add cleanup on user membership changes and project/org deletion, plus cache invalidation for removed rules | ## Rollback Plan diff --git a/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md b/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md index 09ff924d02..5c71129d33 100644 --- a/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md +++ b/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md @@ -135,6 +135,18 @@ Given no enabled rate notification rules for project When event is processed Then no rate counter is incremented. +### Requirement: Rate notifications honor premium feature gating + +Rate notifications MUST follow the existing premium-only occurrence-notification model and MUST NOT become a free notification channel. + +#### Scenario: Non-premium organizations do not activate rate notifications + +Given project organization does not have premium features +When matching events are processed or the evaluator runs +Then no rate counters are incremented +And no rate notification work item is enqueued +And no rate notification email is sent. + ### Requirement: Event pipeline increments only required counters The pipeline MUST only increment counters that are required by at least one enabled rule, not all possible signal counters. @@ -156,6 +168,22 @@ Given stack rule exists for stack A When event occurs on stack B Then stack A counter is not incremented. +### Requirement: Rate notifications honor existing occurrence-notification suppression + +Rate notifications MUST respect existing occurrence-notification suppression semantics so muted traffic does not reappear as rate noise. + +#### Scenario: Events on muted stacks do not increment counters + +Given the event stack has `AllowNotifications = false` +When the event is processed +Then no rate counter is incremented for that event. + +#### Scenario: Bot-marked requests do not increment counters + +Given request enrichment has marked the event request as a bot +When the event is processed +Then no rate counter is incremented for that event. + ### Requirement: Evaluator enqueues notification when threshold is crossed The evaluator job MUST enqueue a RateNotification work item when the sum of counter buckets for a rule's window meets or exceeds the threshold. @@ -199,6 +227,24 @@ Given rule snoozed_until_utc is in the future When threshold is crossed Then no work item is enqueued. +### Requirement: Snooze resumes from a fresh baseline + +When a snoozed rule resumes, the evaluator MUST ignore activity observed entirely during the snooze window so the rule does not back-alert immediately after unsnooze or natural expiry. + +#### Scenario: Unsnoozing does not immediately fire on snoozed activity + +Given a rule was snoozed while matching events continued +And the shared subject counter remained active for another enabled rule +When the user unsnoozes the rule +Then the evaluator does not enqueue a rate notification until new post-unsnooze activity crosses the threshold. + +#### Scenario: Snooze expiry does not immediately fire on snoozed activity + +Given a rule remained snoozed until its snooze window expired +And matching activity during the snooze window crossed the threshold +When the evaluator next runs after the snooze expires +Then the evaluator does not enqueue a rate notification until new post-expiry activity crosses the threshold. + ### Requirement: Disabled rules do not fire The evaluator MUST skip disabled rules during evaluation. @@ -287,6 +333,12 @@ Rate notification emails MUST include all information the user needs to understa Given notification email is queued Then email includes rule name, project name, observed count, threshold, window, subject type, stack title (when applicable), link to project or stack, and cooldown explanation. +#### Scenario: Stack-scoped email includes stack context + +Given a stack-scoped rate notification email is queued +When the delivery job loads the stack context +Then the email includes the stack title and a deep link to the stack. + ### Requirement: User can manage project rate rules in Svelte UI The Svelte UI MUST provide a full CRUD interface for rate notification rules within project settings. @@ -296,6 +348,13 @@ The Svelte UI MUST provide a full CRUD interface for rate notification rules wit Given user opens project notification settings Then they can list, create, edit, delete, enable/disable, snooze, and unsnooze rate rules. +#### Scenario: Non-premium organizations show rate notifications as unavailable + +Given the organization does not have premium features +When the user opens project notification settings +Then the UI shows the feature as unavailable +And the user cannot create or enable active rate notification rules. + ### Requirement: UI avoids advanced/deferred features The v1 UI MUST NOT expose features that are deferred to future iterations. @@ -331,3 +390,21 @@ Disabling rate notification components MUST NOT impact existing event notificati Given rate notification evaluator/action is disabled Then existing event notifications and daily summaries continue to operate unchanged. + +### Requirement: Rule lifecycle cleanup removes orphaned rules + +The system MUST remove or invalidate orphaned rate notification rules when the owning membership, project, or organization is deleted. + +#### Scenario: Membership removal cleans up user rules + +Given a user is removed from the organization +When cleanup runs +Then that user's rate notification rules for the organization are removed or invalidated +And cached rule indexes are invalidated. + +#### Scenario: Project deletion cleans up project rules + +Given a project is deleted +When cleanup runs +Then rate notification rules for that project are removed or invalidated +And cached rule indexes are invalidated. diff --git a/openspec/changes/add-personal-rate-notifications/tasks.md b/openspec/changes/add-personal-rate-notifications/tasks.md index d4cde871fe..2744258d0a 100644 --- a/openspec/changes/add-personal-rate-notifications/tasks.md +++ b/openspec/changes/add-personal-rate-notifications/tasks.md @@ -33,15 +33,19 @@ - [ ] **Add UpdateRateCountersAction (event pipeline)** - Priority after stack assignment (e.g., 75) + - Exit fast if organization lacks premium features - Load rule index for project; exit fast if no enabled rules + - Skip events on stacks where `AllowNotifications` is false + - Skip canceled/discarded events and requests already marked as bots - Match event against compiled counter definitions (signal matching) - Increment matching counters; add to active bucket set - Never query Elasticsearch per event; never send notifications directly - [ ] **Add RateNotificationEvaluatorJob** - Periodic job (60s interval) with distributed lock + - Skip organizations without premium features - Inspect recently active counters - - Sum buckets for each rule's window + - Sum buckets for each rule's window using a fresh baseline after snooze/unsnooze - Skip disabled/snoozed rules - Compare observed ≥ threshold; enforce cooldown per rule+subject - Enqueue `RateNotification` work item on threshold crossing @@ -59,6 +63,7 @@ - [ ] **Add RateNotificationsJob (delivery)** - Load rule, project, user + - Load stack for stack-scoped rules - Validate: rule exists, enabled, version compatible, user in org, email verified, notifications enabled, project/org exists - Send email via `IMailer.SendRateNotificationAsync` - Skip with structured logs on validation failure @@ -78,6 +83,7 @@ - Routes: GET list, POST create, GET by id, PUT update, DELETE, POST snooze, POST unsnooze - Authorization: current user manages own rules; global admin manages any user's rules; user must access project org - Validation: threshold > 0, supported windows, cooldown ≥ window, subject/stack consistency, max 20 rules per user per project, name non-empty and ≤ 100 chars + - Preserve persisted rules across plan downgrade, but do not allow the runtime or Svelte UI to treat non-premium orgs as active rate-notification senders - [ ] **Add request/response DTOs** - Create/update request models with validation attributes @@ -96,6 +102,7 @@ - [ ] **Add rate notification rule list component** - List rules for current user/project - Show enable/disable toggle, snooze status + - Show premium-gated disabled state / upgrade messaging when org lacks premium features - Delete confirmation - [ ] **Add rate notification rule form component** @@ -114,8 +121,11 @@ - Counter key builder - Counter increments and bucket summing - Signal matching logic + - Premium gating + - `AllowNotifications` / bot suppression - Cooldown behavior - Snooze behavior + - Fresh-baseline behavior after snooze/unsnooze - Evaluator threshold crossing - [ ] **Add integration tests** @@ -123,14 +133,24 @@ - User cannot manage another user's rules - Global admin can manage another user's rules - Stack rule rejects stack from another project + - Non-premium organizations do not counter, evaluate, or deliver rate notifications + - Events on stacks with `AllowNotifications = false` do not increment rate counters + - Bot-marked requests do not increment rate counters - Evaluator enqueues when threshold crossed - Evaluator does not enqueue below threshold - Evaluator respects cooldown - Evaluator respects snooze + - Activity gathered during snooze does not fire immediately when the rule resumes - Delivery skips disabled rule - Delivery skips unverified email - Delivery skips user not in org - Delivery sends email for valid rule + - Delivery loads stack context for stack-scoped emails + - Membership/project/org cleanup removes orphaned rules + +- [ ] **Add lifecycle cleanup** + - Remove/invalidate rules when user membership changes + - Remove/invalidate rules when project or organization is deleted ## Validation From c8773f8c872da474b6bdc0ee2588efaba61dcd17 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:41:52 -0500 Subject: [PATCH 03/18] feat: add RateNotificationRule model, repository, and ES index Introduces the core domain model for personal rate notification rules. Each rule belongs to an org/project/user and configures a threshold- based alert for a specific signal (errors, critical errors, etc.) with snooze/cooldown semantics to prevent alert fatigue. --- .../Models/Enums/RateNotificationSignal.cs | 10 +++ .../Models/Enums/RateNotificationSubject.cs | 7 +++ .../Models/Queues/RateNotification.cs | 16 +++++ .../Models/RateNotificationRule.cs | 55 ++++++++++++++++ .../ExceptionlessElasticConfiguration.cs | 2 + .../Indexes/RateNotificationRuleIndex.cs | 43 +++++++++++++ .../IRateNotificationRuleRepository.cs | 13 ++++ .../RateNotificationRuleRepository.cs | 63 +++++++++++++++++++ 8 files changed, 209 insertions(+) create mode 100644 src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs create mode 100644 src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs create mode 100644 src/Exceptionless.Core/Models/Queues/RateNotification.cs create mode 100644 src/Exceptionless.Core/Models/RateNotificationRule.cs create mode 100644 src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs create mode 100644 src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs create mode 100644 src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs diff --git a/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs new file mode 100644 index 0000000000..e45a6f276f --- /dev/null +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs @@ -0,0 +1,10 @@ +namespace Exceptionless.Core.Models; + +public enum RateNotificationSignal +{ + AllEvents = 0, + Errors = 1, + CriticalErrors = 2, + NewErrors = 3, + Regressions = 4 +} diff --git a/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs b/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs new file mode 100644 index 0000000000..44bca330a3 --- /dev/null +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs @@ -0,0 +1,7 @@ +namespace Exceptionless.Core.Models; + +public enum RateNotificationSubject +{ + Project = 0, + Stack = 1 +} diff --git a/src/Exceptionless.Core/Models/Queues/RateNotification.cs b/src/Exceptionless.Core/Models/Queues/RateNotification.cs new file mode 100644 index 0000000000..55e81254ef --- /dev/null +++ b/src/Exceptionless.Core/Models/Queues/RateNotification.cs @@ -0,0 +1,16 @@ +namespace Exceptionless.Core.Queues.Models; + +public class RateNotification +{ + public required string RuleId { get; set; } + public required int RuleVersion { get; set; } + public required string OrganizationId { get; set; } + public required string ProjectId { get; set; } + public required string UserId { get; set; } + public required string SubjectKey { get; set; } + public string? StackId { get; set; } + public required DateTime WindowStartUtc { get; set; } + public required DateTime WindowEndUtc { get; set; } + public required long ObservedCount { get; set; } + public required int Threshold { get; set; } +} diff --git a/src/Exceptionless.Core/Models/RateNotificationRule.cs b/src/Exceptionless.Core/Models/RateNotificationRule.cs new file mode 100644 index 0000000000..0ffa6ad49a --- /dev/null +++ b/src/Exceptionless.Core/Models/RateNotificationRule.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Attributes; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Models; + +public class RateNotificationRule : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates +{ + [ObjectId] + public string Id { get; set; } = null!; + + [Required] + [ObjectId] + public string OrganizationId { get; set; } = null!; + + [Required] + [ObjectId] + public string ProjectId { get; set; } = null!; + + [Required] + [ObjectId] + public string UserId { get; set; } = null!; + + public int Version { get; set; } = 1; + + [Required] + [MaxLength(100)] + public string Name { get; set; } = null!; + + public bool IsEnabled { get; set; } = true; + + public RateNotificationSignal Signal { get; set; } + + public RateNotificationSubject Subject { get; set; } + + [ObjectId] + public string? StackId { get; set; } + + [Range(1, int.MaxValue)] + public int Threshold { get; set; } = 10; + + public TimeSpan Window { get; set; } = TimeSpan.FromHours(1); + + public TimeSpan Cooldown { get; set; } = TimeSpan.FromHours(1); + + public DateTime? SnoozedUntilUtc { get; set; } + + public DateTime? LastFiredUtc { get; set; } + + public bool IsDeleted { get; set; } + + public DateTime CreatedUtc { get; set; } + + public DateTime UpdatedUtc { get; set; } +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs index 2158adeade..127008a6dc 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs @@ -44,6 +44,7 @@ ILoggerFactory loggerFactory AddIndex(Migrations = new MigrationIndex(this, _appOptions.ElasticsearchOptions.ScopePrefix + "migrations", appOptions.ElasticsearchOptions.NumberOfReplicas)); AddIndex(Organizations = new OrganizationIndex(this)); AddIndex(Projects = new ProjectIndex(this)); + AddIndex(RateNotificationRules = new RateNotificationRuleIndex(this)); AddIndex(SavedViews = new SavedViewIndex(this)); AddIndex(Tokens = new TokenIndex(this)); AddIndex(Users = new UserIndex(this)); @@ -72,6 +73,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder) public MigrationIndex Migrations { get; } public OrganizationIndex Organizations { get; } public ProjectIndex Projects { get; } + public RateNotificationRuleIndex RateNotificationRules { get; } public SavedViewIndex SavedViews { get; } public TokenIndex Tokens { get; } public UserIndex Users { get; } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs new file mode 100644 index 0000000000..aab51d9184 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs @@ -0,0 +1,43 @@ +using Exceptionless.Core.Models; +using Foundatio.Repositories.Elasticsearch.Configuration; +using Foundatio.Repositories.Elasticsearch.Extensions; +using Nest; + +namespace Exceptionless.Core.Repositories.Configuration; + +public sealed class RateNotificationRuleIndex : VersionedIndex +{ + private readonly ExceptionlessElasticConfiguration _configuration; + + public RateNotificationRuleIndex(ExceptionlessElasticConfiguration configuration) + : base(configuration, configuration.Options.ScopePrefix + "rate-notification-rules", 1) + { + _configuration = configuration; + } + + public override TypeMappingDescriptor ConfigureIndexMapping(TypeMappingDescriptor map) + { + return map + .Dynamic(false) + .Properties(p => p + .SetupDefaults() + .Keyword(f => f.Name(e => e.OrganizationId)) + .Keyword(f => f.Name(e => e.ProjectId)) + .Keyword(f => f.Name(e => e.UserId)) + .Keyword(f => f.Name(e => e.StackId)) + .Keyword(f => f.Name(e => e.Signal)) + .Keyword(f => f.Name(e => e.Subject)) + .Boolean(f => f.Name(e => e.IsEnabled)) + .Boolean(f => f.Name(e => e.IsDeleted)) + .Text(f => f.Name(e => e.Name).AddKeywordField()) + ); + } + + public override CreateIndexDescriptor ConfigureIndex(CreateIndexDescriptor idx) + { + return base.ConfigureIndex(idx.Settings(s => s + .NumberOfShards(_configuration.Options.NumberOfShards) + .NumberOfReplicas(_configuration.Options.NumberOfReplicas) + .Priority(5))); + } +} diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs new file mode 100644 index 0000000000..f24ef6961f --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs @@ -0,0 +1,13 @@ +using Exceptionless.Core.Models; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Repositories; + +public interface IRateNotificationRuleRepository : IRepositoryOwnedByOrganizationAndProject +{ + Task> GetByProjectIdAndUserIdAsync(string projectId, string userId, CommandOptionsDescriptor? options = null); + Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null); + Task CountByProjectIdAndUserIdAsync(string projectId, string userId); + Task RemoveByProjectIdAndUserIdAsync(string projectId, string userId); +} diff --git a/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs new file mode 100644 index 0000000000..91998a0cdf --- /dev/null +++ b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs @@ -0,0 +1,63 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Validation; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Nest; + +namespace Exceptionless.Core.Repositories; + +public sealed class RateNotificationRuleRepository : RepositoryOwnedByOrganizationAndProject, IRateNotificationRuleRepository +{ + public RateNotificationRuleRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + : base(configuration.RateNotificationRules, validator, options) + { + } + + public Task> GetByProjectIdAndUserIdAsync(string projectId, string userId, CommandOptionsDescriptor? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + ArgumentException.ThrowIfNullOrEmpty(userId); + + return FindAsync(q => q + .Project(projectId) + .FieldEquals(r => r.UserId, userId) + .SortAscending(r => r.Name.Suffix("keyword")), options); + } + + public Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + + return FindAsync(q => q + .Project(projectId) + .FieldEquals(r => r.IsEnabled, true) + .FieldEquals(r => r.IsDeleted, false), options); + } + + public async Task CountByProjectIdAndUserIdAsync(string projectId, string userId) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + ArgumentException.ThrowIfNullOrEmpty(userId); + + return await CountAsync(q => q + .Project(projectId) + .FieldEquals(r => r.UserId, userId)); + } + + public async Task RemoveByProjectIdAndUserIdAsync(string projectId, string userId) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + ArgumentException.ThrowIfNullOrEmpty(userId); + + var results = await FindAsync(q => q + .Project(projectId) + .FieldEquals(r => r.UserId, userId), o => o.PageLimit(1000)); + + if (results.Total is 0) + return 0; + + await RemoveAsync(results.Documents); + return results.Total; + } +} From f2bc457e452c9ca1776dab63832331930b29f696 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:41:58 -0500 Subject: [PATCH 04/18] feat: add RateCounterService and pipeline action for event rate tracking RateCounterService uses ICacheClient 1-minute bucket counters to track event rates per signal/project/stack. 075_UpdateRateCountersAction [Priority(75)] increments counters during event ingestion for all matching signals. --- .../Pipeline/075_UpdateRateCountersAction.cs | 110 ++++++++++++++++++ .../Services/RateCounterService.cs | 100 ++++++++++++++++ .../Services/RateNotificationRuleCache.cs | 53 +++++++++ 3 files changed, 263 insertions(+) create mode 100644 src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs create mode 100644 src/Exceptionless.Core/Services/RateCounterService.cs create mode 100644 src/Exceptionless.Core/Services/RateNotificationRuleCache.cs diff --git a/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs new file mode 100644 index 0000000000..75381a2afc --- /dev/null +++ b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs @@ -0,0 +1,110 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Exceptionless.Core.Pipeline; + +[Priority(75)] +public class UpdateRateCountersAction : EventPipelineActionBase +{ + private readonly RateNotificationRuleCache _ruleCache; + private readonly RateCounterService _counterService; + private readonly UserAgentParser _parser; + private readonly JsonSerializerOptions _jsonOptions; + + public UpdateRateCountersAction( + RateNotificationRuleCache ruleCache, + RateCounterService counterService, + UserAgentParser parser, + JsonSerializerOptions jsonOptions, + AppOptions options, + ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _ruleCache = ruleCache; + _counterService = counterService; + _parser = parser; + _jsonOptions = jsonOptions; + ContinueOnError = true; + } + + public override async Task ProcessAsync(EventContext ctx) + { + // Premium gate — rate notifications require premium features + if (!ctx.Organization.HasPremiumFeatures) + return; + + // Stack must allow notifications + if (ctx.Stack is null || !ctx.Stack.AllowNotifications) + return; + + // Load enabled rules for this project + var rules = await _ruleCache.GetEnabledRulesAsync(ctx.Event.ProjectId); + if (rules.Count == 0) + return; + + // Bot check — same pattern as EventNotificationsJob + var request = ctx.Event.GetRequestInfo(_jsonOptions); + if (!String.IsNullOrEmpty(request?.UserAgent)) + { + var botPatterns = ctx.Project.Configuration.Settings + .GetStringCollection(SettingsDictionary.KnownKeys.UserAgentBotPatterns).ToList(); + var info = await _parser.ParseAsync(request.UserAgent); + if (info is not null && info.Device.IsSpider || request.UserAgent.AnyWildcardMatches(botPatterns)) + { + _logger.LogTrace("Skipping rate counter update for bot user agent: {UserAgent}", request.UserAgent); + return; + } + } + + // Build the set of signals matched by this event + bool isError = ctx.Event.IsError(); + bool isCritical = ctx.Event.IsCritical(); + var matchedSignals = new HashSet(); + + // AllEvents always matches + matchedSignals.Add(RateNotificationSignal.AllEvents); + + if (isError) + matchedSignals.Add(RateNotificationSignal.Errors); + + if (isError && isCritical) + matchedSignals.Add(RateNotificationSignal.CriticalErrors); + + if (ctx.IsNew && isError) + matchedSignals.Add(RateNotificationSignal.NewErrors); + + if (ctx.IsRegression) + matchedSignals.Add(RateNotificationSignal.Regressions); + + // Increment counters for each matching rule + foreach (var rule in rules) + { + if (!matchedSignals.Contains(rule.Signal)) + continue; + + // For Stack subject, only match if this event belongs to the rule's stack + if (rule.Subject == RateNotificationSubject.Stack) + { + if (String.IsNullOrEmpty(rule.StackId) || !String.Equals(ctx.Event.StackId, rule.StackId, StringComparison.Ordinal)) + continue; + } + + string counterKey = BuildCounterKey(rule); + await _counterService.IncrementAsync(counterKey); + } + } + + internal static string BuildCounterKey(RateNotificationRule rule) + { + return rule.Subject switch + { + RateNotificationSubject.Project => $"project:{rule.ProjectId}:signal:{rule.Signal}", + RateNotificationSubject.Stack => $"project:{rule.ProjectId}:stack:{rule.StackId}:signal:{rule.Signal}", + _ => $"project:{rule.ProjectId}:signal:{rule.Signal}" + }; + } +} diff --git a/src/Exceptionless.Core/Services/RateCounterService.cs b/src/Exceptionless.Core/Services/RateCounterService.cs new file mode 100644 index 0000000000..5294864e2e --- /dev/null +++ b/src/Exceptionless.Core/Services/RateCounterService.cs @@ -0,0 +1,100 @@ +using Foundatio.Caching; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Services; + +/// +/// Cache-backed 1-minute bucket counter service for rate notification evaluation. +/// Keys: +/// rate:v1:count:{epochMinute}:{counterKey} — TTL 3h +/// rate:v1:active:{epochMinute} — TTL 3h (list of counter keys) +/// rate:v1:cooldown:{ruleId}:{subjectKey} — TTL = cooldown + 10min +/// +public class RateCounterService +{ + private readonly ICacheClient _cache; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + private static readonly TimeSpan BucketTtl = TimeSpan.FromHours(3); + + public RateCounterService(ICacheClient cache, TimeProvider timeProvider, ILoggerFactory loggerFactory) + { + _cache = cache; + _timeProvider = timeProvider; + _logger = loggerFactory.CreateLogger(); + } + + /// Increments the 1-minute bucket counter for the given counter key at the current UTC minute. + public async Task IncrementAsync(string counterKey, CancellationToken ct = default) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + long epochMinute = GetEpochMinute(now); + + string countKey = GetCountKey(epochMinute, counterKey); + await _cache.IncrementAsync(countKey, 1, BucketTtl); + + string activeKey = GetActiveKey(epochMinute); + await _cache.ListAddAsync(activeKey, counterKey, BucketTtl); + } + + /// Sums all 1-minute bucket counts for the given counter key in the range [fromUtc, toUtc]. + public async Task SumBucketsAsync(string counterKey, DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) + { + long fromMinute = GetEpochMinute(fromUtc); + long toMinute = GetEpochMinute(toUtc); + + long total = 0; + for (long minute = fromMinute; minute <= toMinute; minute++) + { + string key = GetCountKey(minute, counterKey); + var value = await _cache.GetAsync(key); + if (value.HasValue) + total += value.Value; + } + + return total; + } + + /// Returns all counter keys that were active during the given minute. + public async Task> GetActiveCounterKeysAsync(DateTime minute, CancellationToken ct = default) + { + long epochMinute = GetEpochMinute(minute); + string activeKey = GetActiveKey(epochMinute); + + var result = await _cache.GetListAsync(activeKey); + if (!result.HasValue || result.Value is null) + return Array.Empty(); + + return result.Value.Where(k => k is not null).Distinct().ToList()!; + } + + /// Returns true if the rule/subject is currently on cooldown. + public Task IsOnCooldownAsync(string ruleId, string subjectKey, CancellationToken ct = default) + { + string key = GetCooldownKey(ruleId, subjectKey); + return _cache.ExistsAsync(key); + } + + /// Sets a cooldown for the given rule/subject combination. + public Task SetCooldownAsync(string ruleId, string subjectKey, TimeSpan duration, CancellationToken ct = default) + { + string key = GetCooldownKey(ruleId, subjectKey); + // TTL = duration + 10 minutes buffer + return _cache.SetAsync(key, true, duration.Add(TimeSpan.FromMinutes(10))); + } + + // ---- Key helpers ---- + + private static long GetEpochMinute(DateTime utc) + => (long)(utc - DateTime.UnixEpoch).TotalMinutes; + + private static string GetCountKey(long epochMinute, string counterKey) + => $"rate:v1:count:{epochMinute}:{counterKey}"; + + private static string GetActiveKey(long epochMinute) + => $"rate:v1:active:{epochMinute}"; + + private static string GetCooldownKey(string ruleId, string subjectKey) + => $"rate:v1:cooldown:{ruleId}:{subjectKey}"; +} diff --git a/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs new file mode 100644 index 0000000000..777b754ac7 --- /dev/null +++ b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs @@ -0,0 +1,53 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Caching; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Services; + +/// +/// Cache layer over IRateNotificationRuleRepository. +/// Cache key: rate:v1:rules:project:{projectId} TTL: 5 minutes +/// +public class RateNotificationRuleCache +{ + private readonly IRateNotificationRuleRepository _repository; + private readonly ICacheClient _cache; + private readonly ILogger _logger; + + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); + + public RateNotificationRuleCache(IRateNotificationRuleRepository repository, ICacheClient cache, ILoggerFactory loggerFactory) + { + _repository = repository; + _cache = cache; + _logger = loggerFactory.CreateLogger(); + } + + /// Returns all enabled, non-deleted rules for the given project (cached). + public async Task> GetEnabledRulesAsync(string projectId, CancellationToken ct = default) + { + string cacheKey = GetCacheKey(projectId); + + var cached = await _cache.GetAsync>(cacheKey); + if (cached.HasValue && cached.Value is not null) + return cached.Value; + + var results = await _repository.GetEnabledByProjectIdAsync(projectId, o => o.PageLimit(1000)); + var rules = results.Documents.ToList(); + + await _cache.SetAsync(cacheKey, rules, CacheTtl); + return rules; + } + + /// Invalidates the cache for the given project. + public Task InvalidateAsync(string projectId) + { + string cacheKey = GetCacheKey(projectId); + return _cache.RemoveAsync(cacheKey); + } + + private static string GetCacheKey(string projectId) => $"rate:v1:rules:project:{projectId}"; +} From fc1636f5ed8bbdadbbfd4278538ff6602c12b19b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:42:06 -0500 Subject: [PATCH 05/18] feat: add RateNotificationEvaluatorJob and RateNotificationsJob EvaluatorJob runs minutely (cron) to check rules against counters and enqueue notifications. Implements the snooze back-alert fix: uses max(windowStart, snoozedUntilUtc) as the effective window start to prevent false alerts from traffic counted during a snooze period. RateNotificationsJob dequeues and sends notifications via IMailer. --- .../Jobs/RateNotificationEvaluatorJob.cs | 224 ++++++++++++++++++ .../Jobs/RateNotificationsJob.cs | 105 ++++++++ 2 files changed, 329 insertions(+) create mode 100644 src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs create mode 100644 src/Exceptionless.Core/Jobs/RateNotificationsJob.cs diff --git a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs new file mode 100644 index 0000000000..4f97835c30 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs @@ -0,0 +1,224 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Foundatio.Jobs; +using Foundatio.Lock; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Resilience; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs; + +[Job(Description = "Evaluates rate notification rules and enqueues notifications.", IsContinuous = false)] +public class RateNotificationEvaluatorJob : JobWithLockBase +{ + private readonly RateCounterService _counterService; + private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IQueue _notificationQueue; + private readonly ILockProvider _lockProvider; + + /// How far back to scan for active counter minutes. + private static readonly TimeSpan ScanWindow = TimeSpan.FromHours(2); + + public RateNotificationEvaluatorJob( + RateCounterService counterService, + IRateNotificationRuleRepository ruleRepository, + IOrganizationRepository organizationRepository, + IQueue notificationQueue, + ILockProvider lockProvider, + TimeProvider timeProvider, + IResiliencePolicyProvider resiliencePolicyProvider, + ILoggerFactory loggerFactory) : base(timeProvider, resiliencePolicyProvider, loggerFactory) + { + _counterService = counterService; + _ruleRepository = ruleRepository; + _organizationRepository = organizationRepository; + _notificationQueue = notificationQueue; + _lockProvider = lockProvider; + } + + protected override Task GetLockAsync(CancellationToken cancellationToken = default) + { + return _lockProvider.TryAcquireAsync(nameof(RateNotificationEvaluatorJob), TimeSpan.FromMinutes(2), cancellationToken); + } + + protected override async Task RunInternalAsync(JobContext context) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var scanFrom = now.Subtract(ScanWindow); + + // Round scanFrom down to minute boundary; stop 1 minute before now (current bucket may be incomplete) + var fromMinute = new DateTime(scanFrom.Year, scanFrom.Month, scanFrom.Day, scanFrom.Hour, scanFrom.Minute, 0, DateTimeKind.Utc); + var toMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc).AddMinutes(-1); + + if (fromMinute > toMinute) + return JobResult.Success; + + _logger.LogInformation("Evaluating rate notification rules from {From} to {To}", fromMinute, toMinute); + + // Collect all unique counter keys across all minutes in the scan window + var allCounterKeys = new HashSet(); + for (var minute = fromMinute; minute <= toMinute; minute = minute.AddMinutes(1)) + { + var keys = await _counterService.GetActiveCounterKeysAsync(minute, context.CancellationToken); + foreach (var key in keys) + allCounterKeys.Add(key); + } + + if (allCounterKeys.Count == 0) + { + _logger.LogDebug("No active counter keys found in scan window"); + return JobResult.Success; + } + + // For each unique counter key, evaluate all matching rules + foreach (string counterKey in allCounterKeys) + { + if (context.CancellationToken.IsCancellationRequested) + return JobResult.Cancelled; + + await EvaluateCounterKeyAsync(counterKey, now, context.CancellationToken); + } + + _logger.LogInformation("Finished evaluating rate notification rules"); + return JobResult.Success; + } + + private async Task EvaluateCounterKeyAsync(string counterKey, DateTime now, CancellationToken ct) + { + // Parse projectId from counter key to load rules + string? projectId = ParseProjectIdFromCounterKey(counterKey); + if (projectId is null) + { + _logger.LogWarning("Unable to parse projectId from counter key: {CounterKey}", counterKey); + return; + } + + // Load enabled rules for project matching this counter key + var allProjectRules = await _ruleRepository.GetEnabledByProjectIdAsync(projectId, o => o.PageLimit(1000)); + + // Filter rules matching this counter key + var matchingRules = allProjectRules.Documents + .Where(r => CounterKeyMatchesRule(counterKey, r)) + .ToList(); + + if (matchingRules.Count == 0) + return; + + // Load org once per project for premium check + string? organizationId = matchingRules.First().OrganizationId; + var org = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); + if (org is null) + return; + + if (!org.HasPremiumFeatures) + return; + + foreach (var rule in matchingRules) + { + if (ct.IsCancellationRequested) + return; + + await EvaluateRuleAsync(rule, counterKey, now, ct); + } + } + + private async Task EvaluateRuleAsync(RateNotificationRule rule, string counterKey, DateTime now, CancellationToken ct) + { + if (!rule.IsEnabled) + return; + + // Skip if actively snoozed + if (rule.SnoozedUntilUtc.HasValue && rule.SnoozedUntilUtc.Value > now) + { + _logger.LogDebug("Skipping snoozed rule {RuleId} snoozed until {SnoozedUntil}", rule.Id, rule.SnoozedUntilUtc); + return; + } + + var windowStartUtc = now.Subtract(rule.Window); + + // SNOOZE BACK-ALERT FIX: + // If the rule was recently un-snoozed (SnoozedUntilUtc is set and in the past), use that as the + // effective window start to ignore traffic that occurred during the snooze period. + var effectiveWindowStartUtc = rule.SnoozedUntilUtc.HasValue && rule.SnoozedUntilUtc.Value > windowStartUtc + ? rule.SnoozedUntilUtc.Value + : windowStartUtc; + + var observedCount = await _counterService.SumBucketsAsync(counterKey, effectiveWindowStartUtc, now, ct); + + if (observedCount < rule.Threshold) + { + _logger.LogDebug("Rule {RuleId}: observed={Observed} < threshold={Threshold}, skipping", rule.Id, observedCount, rule.Threshold); + return; + } + + // Build subject key (for cooldown scoping) + string subjectKey = BuildSubjectKey(rule); + + // Check cooldown + if (await _counterService.IsOnCooldownAsync(rule.Id, subjectKey, ct)) + { + _logger.LogDebug("Rule {RuleId} is on cooldown for subject {SubjectKey}", rule.Id, subjectKey); + return; + } + + // Enqueue notification + await _notificationQueue.EnqueueAsync(new RateNotification + { + RuleId = rule.Id, + RuleVersion = rule.Version, + OrganizationId = rule.OrganizationId, + ProjectId = rule.ProjectId, + UserId = rule.UserId, + SubjectKey = subjectKey, + StackId = rule.StackId, + WindowStartUtc = effectiveWindowStartUtc, + WindowEndUtc = now, + ObservedCount = observedCount, + Threshold = rule.Threshold + }); + + // Set cooldown + await _counterService.SetCooldownAsync(rule.Id, subjectKey, rule.Cooldown, ct); + + // Update LastFiredUtc + rule.LastFiredUtc = now; + await _ruleRepository.SaveAsync(rule); + + _logger.LogInformation("Rate notification fired: rule={RuleId} project={ProjectId} observed={Observed} threshold={Threshold}", + rule.Id, rule.ProjectId, observedCount, rule.Threshold); + } + + /// Parses the projectId from a counter key of the form: project:{projectId}:... + private static string? ParseProjectIdFromCounterKey(string counterKey) + { + const string prefix = "project:"; + if (!counterKey.StartsWith(prefix)) + return null; + + int start = prefix.Length; + int end = counterKey.IndexOf(':', start); + if (end < 0) + return null; + + return counterKey[start..end]; + } + + /// Returns true if the counter key was generated by the given rule. + private static bool CounterKeyMatchesRule(string counterKey, RateNotificationRule rule) + { + string expected = UpdateRateCountersAction.BuildCounterKey(rule); + return String.Equals(counterKey, expected, StringComparison.Ordinal); + } + + private static string BuildSubjectKey(RateNotificationRule rule) + { + return rule.Subject == RateNotificationSubject.Stack && !String.IsNullOrEmpty(rule.StackId) + ? $"stack:{rule.StackId}" + : $"project:{rule.ProjectId}"; + } +} diff --git a/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs new file mode 100644 index 0000000000..253ec202de --- /dev/null +++ b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs @@ -0,0 +1,105 @@ +using Exceptionless.Core.Mail; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Jobs; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Resilience; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs; + +[Job(Description = "Delivers rate notification emails.", InitialDelay = "5s")] +public class RateNotificationsJob : QueueJobBase +{ + private readonly IMailer _mailer; + private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IProjectRepository _projectRepository; + private readonly IUserRepository _userRepository; + private readonly IStackRepository _stackRepository; + + public RateNotificationsJob( + IQueue queue, + IMailer mailer, + IRateNotificationRuleRepository ruleRepository, + IProjectRepository projectRepository, + IUserRepository userRepository, + IStackRepository stackRepository, + TimeProvider timeProvider, + IResiliencePolicyProvider resiliencePolicyProvider, + ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) + { + _mailer = mailer; + _ruleRepository = ruleRepository; + _projectRepository = projectRepository; + _userRepository = userRepository; + _stackRepository = stackRepository; + } + + protected override async Task ProcessQueueEntryAsync(QueueEntryContext context) + { + var wi = context.QueueEntry.Value; + + // Load rule + var rule = await _ruleRepository.GetByIdAsync(wi.RuleId); + if (rule is null) + return JobResult.SuccessWithMessage($"Rate notification rule {wi.RuleId} not found; skipping."); + + if (!rule.IsEnabled) + return JobResult.SuccessWithMessage($"Rule {wi.RuleId} is disabled; skipping."); + + // Version check — rule was mutated after enqueueing + if (rule.Version != wi.RuleVersion) + { + _logger.LogInformation("Rule {RuleId} version mismatch: expected {Expected}, found {Actual}; skipping stale notification", + wi.RuleId, wi.RuleVersion, rule.Version); + return JobResult.Success; + } + + // Load project + var project = await _projectRepository.GetByIdAsync(rule.ProjectId, o => o.Cache()); + if (project is null) + return JobResult.SuccessWithMessage($"Project {rule.ProjectId} not found; skipping."); + + // Load user + var user = await _userRepository.GetByIdAsync(rule.UserId, o => o.Cache()); + if (user is null) + return JobResult.SuccessWithMessage($"User {rule.UserId} not found; skipping."); + + // User must still be a member of the org + if (!user.OrganizationIds.Contains(rule.OrganizationId)) + { + _logger.LogInformation("User {UserId} is no longer a member of org {OrgId}; skipping rate notification", rule.UserId, rule.OrganizationId); + return JobResult.Success; + } + + if (!user.IsEmailAddressVerified) + { + _logger.LogInformation("User {UserId} email not verified; skipping rate notification", rule.UserId); + return JobResult.Success; + } + + if (!user.EmailNotificationsEnabled) + { + _logger.LogInformation("User {UserId} has email notifications disabled; skipping rate notification", rule.UserId); + return JobResult.Success; + } + + // Load stack if this is a stack-scoped rule + Stack? stack = null; + if (rule.Subject == RateNotificationSubject.Stack && !String.IsNullOrEmpty(rule.StackId)) + { + stack = await _stackRepository.GetByIdAsync(rule.StackId, o => o.Cache()); + if (stack is null) + _logger.LogWarning("Stack {StackId} not found for rate notification rule {RuleId}", rule.StackId, rule.Id); + } + + await _mailer.SendRateNotificationAsync(user, project, rule, wi.ObservedCount, wi.WindowStartUtc, wi.WindowEndUtc, stack); + + _logger.LogInformation("Sent rate notification email: rule={RuleId} user={UserId} project={ProjectId} observed={Observed}", + rule.Id, rule.UserId, rule.ProjectId, wi.ObservedCount); + + return JobResult.Success; + } +} From bd51eed3d82842cafacb82997c4b22b5c2a4b851 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:42:12 -0500 Subject: [PATCH 06/18] feat: add SendRateNotificationAsync to IMailer and rate-notification email template Adds Handlebars HTML email template and mailer implementation. Template includes rule name, signal, threshold, observed count, project info, and snooze/manage links. --- .../Exceptionless.Core.csproj | 2 + src/Exceptionless.Core/Mail/IMailer.cs | 1 + src/Exceptionless.Core/Mail/Mailer.cs | 41 +++++++++++++++++++ .../Mail/Templates/rate-notification.html | 1 + .../Mail/CountingMailer.cs | 5 +++ tests/Exceptionless.Tests/Mail/NullMailer.cs | 5 +++ 6 files changed, 55 insertions(+) create mode 100644 src/Exceptionless.Core/Mail/Templates/rate-notification.html diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index 2ee31516e5..af25f9a2c5 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -6,6 +6,7 @@ + @@ -16,6 +17,7 @@ + diff --git a/src/Exceptionless.Core/Mail/IMailer.cs b/src/Exceptionless.Core/Mail/IMailer.cs index 79b3b7f39e..a8f1c1e2b8 100644 --- a/src/Exceptionless.Core/Mail/IMailer.cs +++ b/src/Exceptionless.Core/Mail/IMailer.cs @@ -10,6 +10,7 @@ public interface IMailer Task SendOrganizationNoticeAsync(User user, Organization organization, bool isOverMonthlyLimit, bool isOverHourlyLimit); Task SendOrganizationPaymentFailedAsync(User owner, Organization organization); Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan); + Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStart, DateTime windowEnd, Stack? stack = null); Task SendUserEmailVerifyAsync(User user); Task SendUserPasswordResetAsync(User user); } diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index b6498c1d45..16d6ba395e 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -281,6 +281,47 @@ public Task SendUserPasswordResetAsync(User user) }, template); } + public Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStart, DateTime windowEnd, Stack? stack = null) + { + const string template = "rate-notification"; + string windowDescription = FormatWindow(rule.Window); + + var data = new Dictionary { + { "Subject", $"Error rate exceeded: {rule.Name}" }, + { "BaseUrl", _appOptions.BaseURL }, + { "ProjectName", project.Name }, + { "ProjectId", project.Id }, + { "RuleName", rule.Name }, + { "ObservedCount", observedCount }, + { "Threshold", rule.Threshold }, + { "Window", windowDescription }, + { "Signal", rule.Signal.ToString() }, + { "Subject_Type", rule.Subject.ToString() }, + { "StackId", rule.StackId }, + { "StackTitle", stack?.Title }, + { "HasStack", stack is not null }, + { "WindowStartUtc", windowStart.ToString("u") }, + { "WindowEndUtc", windowEnd.ToString("u") }, + { "Cooldown", FormatWindow(rule.Cooldown) } + }; + + return QueueMessageAsync(new MailMessage + { + To = user.EmailAddress, + Subject = $"[{project.Name}] Error rate exceeded: {rule.Name}", + Body = RenderTemplate(template, data) + }, template); + } + + private static string FormatWindow(TimeSpan window) + { + if (window.TotalHours >= 1 && window.Minutes == 0) + return $"{(int)window.TotalHours}h"; + if (window.TotalMinutes >= 1 && window.Seconds == 0) + return $"{(int)window.TotalMinutes}min"; + return window.ToString(); + } + private string RenderTemplate(string name, IDictionary data) { var template = GetCompiledTemplate(name); diff --git a/src/Exceptionless.Core/Mail/Templates/rate-notification.html b/src/Exceptionless.Core/Mail/Templates/rate-notification.html new file mode 100644 index 0000000000..5b6da87dfb --- /dev/null +++ b/src/Exceptionless.Core/Mail/Templates/rate-notification.html @@ -0,0 +1 @@ +{{Subject}}
Exceptionless
 

Your rate notification rule “{{RuleName}}” has fired for the {{ProjectName}} project.

Events Observed
{{ObservedCount}} events (threshold: {{Threshold}}) in the last {{Window}}


Signal
{{Signal}}


Scope
{{Subject_Type}}{{#if HasStack}} — {{StackTitle}}{{/if}}


Window
{{WindowStartUtc}} – {{WindowEndUtc}}

{{#if HasStack}}View Stack {{else}}View Project Dashboard {{/if}}

This notification will not be sent again for another {{Cooldown}} (cooldown period).

diff --git a/tests/Exceptionless.Tests/Mail/CountingMailer.cs b/tests/Exceptionless.Tests/Mail/CountingMailer.cs index 96d5671c3f..1a6f5c1097 100644 --- a/tests/Exceptionless.Tests/Mail/CountingMailer.cs +++ b/tests/Exceptionless.Tests/Mail/CountingMailer.cs @@ -65,6 +65,11 @@ public Task SendUserPasswordResetAsync(User user) return Task.CompletedTask; } + public Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStart, DateTime windowEnd, Stack? stack = null) + { + return Task.CompletedTask; + } + public void Reset() { Interlocked.Exchange(ref _organizationNoticeCount, 0); diff --git a/tests/Exceptionless.Tests/Mail/NullMailer.cs b/tests/Exceptionless.Tests/Mail/NullMailer.cs index 826be1324d..bef6901698 100644 --- a/tests/Exceptionless.Tests/Mail/NullMailer.cs +++ b/tests/Exceptionless.Tests/Mail/NullMailer.cs @@ -44,4 +44,9 @@ public Task SendUserPasswordResetAsync(User user) { return Task.CompletedTask; } + + public Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStart, DateTime windowEnd, Stack? stack = null) + { + return Task.CompletedTask; + } } From 9692a4bc0874e8767a04ef2b5c2018b10847c986 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:42:19 -0500 Subject: [PATCH 07/18] feat: add RateNotificationRule API controller and DTOs REST CRUD + snooze/unsnooze endpoints under: /api/v2/users/{userId}/projects/{projectId}/rate-notifications Unsnooze sets SnoozedUntilUtc = now (not null) to prevent back-alerts after snooze expiry. Premium gate: non-premium forces IsEnabled=false. --- .../RateNotificationRuleController.cs | 387 ++++++++++++++++++ .../RateNotificationRuleModels.cs | 82 ++++ 2 files changed, 469 insertions(+) create mode 100644 src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs create mode 100644 src/Exceptionless.Web/Models/RateNotification/RateNotificationRuleModels.cs diff --git a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs new file mode 100644 index 0000000000..3a3998734d --- /dev/null +++ b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs @@ -0,0 +1,387 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Foundatio.Repositories; + +namespace Exceptionless.App.Controllers.API; + +/// +/// Personal rate notification rule management. +/// +[Route(API_PREFIX + "/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications")] +[Authorize(Policy = AuthorizationRoles.UserPolicy)] +public class RateNotificationRuleController : ExceptionlessApiController +{ + private const int MaxRulesPerUserPerProject = 20; + + private static readonly TimeSpan[] ValidWindows = + [ + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(5), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(15), + TimeSpan.FromMinutes(30), + TimeSpan.FromHours(1) + ]; + + private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IProjectRepository _projectRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IStackRepository _stackRepository; + private readonly RateNotificationRuleCache _ruleCache; + + public RateNotificationRuleController( + IRateNotificationRuleRepository ruleRepository, + IProjectRepository projectRepository, + IOrganizationRepository organizationRepository, + IStackRepository stackRepository, + RateNotificationRuleCache ruleCache, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) : base(timeProvider) + { + _ruleRepository = ruleRepository; + _projectRepository = projectRepository; + _organizationRepository = organizationRepository; + _stackRepository = stackRepository; + _ruleCache = ruleCache; + } + + /// Get all rate notification rules for a user/project. + [HttpGet] + public async Task>> GetAsync( + string userId, + string projectId, + int page = 1, + int limit = 25) + { + if (!CanManage(userId)) + return NotFound(); + + var project = await GetProjectAndCheckAccessAsync(projectId); + if (project is null) + return NotFound(); + + page = GetPage(page); + limit = GetLimit(limit); + + var results = await _ruleRepository.GetByProjectIdAndUserIdAsync(projectId, userId, o => o.PageNumber(page).PageLimit(limit)); + var viewModels = results.Documents.Select(MapToView).ToList(); + return OkWithResourceLinks(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + } + + /// Create a rate notification rule. + [HttpPost] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task> PostAsync( + string userId, + string projectId, + [FromBody] NewRateNotificationRule model) + { + if (!CanManage(userId)) + return NotFound(); + + var project = await GetProjectAndCheckAccessAsync(projectId); + if (project is null) + return NotFound(); + + // Validate window + if (!ValidWindows.Contains(model.Window)) + return ValidationProblem(detail: $"Window must be one of: {String.Join(", ", ValidWindows.Select(w => w.ToString()))}"); + + // Validate cooldown >= window + if (model.Cooldown < model.Window) + return ValidationProblem(detail: "Cooldown must be greater than or equal to Window."); + + // Validate subject / stackId + if (model.Subject == RateNotificationSubject.Stack) + { + if (String.IsNullOrEmpty(model.StackId)) + return ValidationProblem(detail: "StackId is required when Subject is Stack."); + + var stack = await _stackRepository.GetByIdAsync(model.StackId, o => o.Cache()); + if (stack is null || !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal)) + return ValidationProblem(detail: "The specified StackId does not belong to this project."); + } + else if (!String.IsNullOrEmpty(model.StackId)) + { + return ValidationProblem(detail: "StackId must be empty when Subject is Project."); + } + + // Enforce max rules limit + long count = await _ruleRepository.CountByProjectIdAndUserIdAsync(projectId, userId); + if (count >= MaxRulesPerUserPerProject) + return ValidationProblem(detail: $"Maximum of {MaxRulesPerUserPerProject} rate notification rules per user per project."); + + // Premium gate — non-premium users can create rules but they start disabled + var org = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + bool isEnabled = model.IsEnabled; + if (!org?.HasPremiumFeatures ?? false) + isEnabled = false; + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var rule = new RateNotificationRule + { + OrganizationId = project.OrganizationId, + ProjectId = projectId, + UserId = userId, + Name = model.Name, + IsEnabled = isEnabled, + Signal = model.Signal, + Subject = model.Subject, + StackId = model.StackId, + Threshold = model.Threshold, + Window = model.Window, + Cooldown = model.Cooldown, + Version = 1, + CreatedUtc = now, + UpdatedUtc = now + }; + + rule = await _ruleRepository.AddAsync(rule, o => o.Cache()); + await _ruleCache.InvalidateAsync(projectId); + + return Created(new Uri(Url.Link("GetRateNotificationRuleById", new { userId, projectId, ruleId = rule.Id })!, UriKind.RelativeOrAbsolute), MapToView(rule)); + } + + /// Get a specific rate notification rule. + [HttpGet("{ruleId:objectid}", Name = "GetRateNotificationRuleById")] + public async Task> GetByIdAsync(string userId, string projectId, string ruleId) + { + if (!CanManage(userId)) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + if (rule is null) + return NotFound(); + + return Ok(MapToView(rule)); + } + + /// Update a rate notification rule. + [HttpPut("{ruleId:objectid}")] + [Consumes("application/json")] + public async Task> PutAsync( + string userId, + string projectId, + string ruleId, + [FromBody] UpdateRateNotificationRule model) + { + if (!CanManage(userId)) + return NotFound(); + + var project = await GetProjectAndCheckAccessAsync(projectId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + if (rule is null) + return NotFound(); + + // Apply updates + if (model.Name is not null) + rule.Name = model.Name; + + if (model.Signal.HasValue) + rule.Signal = model.Signal.Value; + + if (model.Subject.HasValue) + rule.Subject = model.Subject.Value; + + if (model.Threshold.HasValue) + rule.Threshold = model.Threshold.Value; + + // StackId update + var newStackId = model.StackId; + var newSubject = rule.Subject; + + if (newSubject == RateNotificationSubject.Stack) + { + if (String.IsNullOrEmpty(newStackId)) + return ValidationProblem(detail: "StackId is required when Subject is Stack."); + + var stack = await _stackRepository.GetByIdAsync(newStackId, o => o.Cache()); + if (stack is null || !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal)) + return ValidationProblem(detail: "The specified StackId does not belong to this project."); + + rule.StackId = newStackId; + } + else + { + rule.StackId = null; + } + + if (model.Window.HasValue) + { + if (!ValidWindows.Contains(model.Window.Value)) + return ValidationProblem(detail: $"Window must be one of: {String.Join(", ", ValidWindows.Select(w => w.ToString()))}"); + rule.Window = model.Window.Value; + } + + if (model.Cooldown.HasValue) + rule.Cooldown = model.Cooldown.Value; + + if (rule.Cooldown < rule.Window) + return ValidationProblem(detail: "Cooldown must be greater than or equal to Window."); + + if (model.IsEnabled.HasValue) + { + var org = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + rule.IsEnabled = model.IsEnabled.Value && (org?.HasPremiumFeatures ?? false); + } + + rule.Version++; + rule.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + + await _ruleRepository.SaveAsync(rule, o => o.Cache()); + await _ruleCache.InvalidateAsync(projectId); + + return Ok(MapToView(rule)); + } + + /// Delete a rate notification rule. + [HttpDelete("{ruleId:objectid}")] + public async Task DeleteAsync(string userId, string projectId, string ruleId) + { + if (!CanManage(userId)) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + if (rule is null) + return NotFound(); + + await _ruleRepository.RemoveAsync(rule); + await _ruleCache.InvalidateAsync(projectId); + + return NoContent(); + } + + /// Snooze a rate notification rule. + [HttpPost("{ruleId:objectid}/snooze")] + [Consumes("application/json")] + public async Task> SnoozeAsync( + string userId, + string projectId, + string ruleId, + [FromBody] SnoozeRateNotificationRuleRequest request) + { + if (!CanManage(userId)) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + if (rule is null) + return NotFound(); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + if (request.UntilUtc.HasValue) + { + rule.SnoozedUntilUtc = request.UntilUtc.Value; + } + else if (request.DurationSeconds.HasValue) + { + rule.SnoozedUntilUtc = now.AddSeconds(request.DurationSeconds.Value); + } + else + { + return ValidationProblem(detail: "Either DurationSeconds or UntilUtc must be provided."); + } + + rule.Version++; + rule.UpdatedUtc = now; + await _ruleRepository.SaveAsync(rule, o => o.Cache()); + await _ruleCache.InvalidateAsync(projectId); + + return Ok(MapToView(rule)); + } + + /// Unsnooze a rate notification rule. Sets SnoozedUntilUtc = now to establish a fresh baseline. + [HttpPost("{ruleId:objectid}/unsnooze")] + public async Task> UnsnoozeAsync(string userId, string projectId, string ruleId) + { + if (!CanManage(userId)) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + if (rule is null) + return NotFound(); + + // Set to now (NOT null) so the evaluator uses now as the effective window start — no back-alert + rule.SnoozedUntilUtc = _timeProvider.GetUtcNow().UtcDateTime; + rule.Version++; + rule.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + await _ruleRepository.SaveAsync(rule, o => o.Cache()); + await _ruleCache.InvalidateAsync(projectId); + + return Ok(MapToView(rule)); + } + + // ---- Helpers ---- + + private bool CanManage(string userId) + { + // User can manage their own rules; global admins can manage any user's rules + return String.Equals(CurrentUser.Id, userId, StringComparison.Ordinal) || Request.IsGlobalAdmin(); + } + + private async Task GetProjectAndCheckAccessAsync(string projectId) + { + var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache()); + if (project is null || !CanAccessOrganization(project.OrganizationId)) + return null; + return project; + } + + private async Task GetRuleAndCheckAccessAsync(string ruleId, string userId, string projectId) + { + var rule = await _ruleRepository.GetByIdAsync(ruleId); + if (rule is null) + return null; + + // Rule must belong to the specified user+project + if (!String.Equals(rule.UserId, userId, StringComparison.Ordinal)) + return null; + + if (!String.Equals(rule.ProjectId, projectId, StringComparison.Ordinal)) + return null; + + // Current user must be able to manage this rule + if (!CanManage(userId)) + return null; + + return rule; + } + + private ViewRateNotificationRule MapToView(RateNotificationRule rule) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + return new ViewRateNotificationRule + { + Id = rule.Id, + OrganizationId = rule.OrganizationId, + ProjectId = rule.ProjectId, + UserId = rule.UserId, + Version = rule.Version, + Name = rule.Name, + IsEnabled = rule.IsEnabled, + Signal = rule.Signal, + Subject = rule.Subject, + StackId = rule.StackId, + Threshold = rule.Threshold, + Window = rule.Window, + Cooldown = rule.Cooldown, + SnoozedUntilUtc = rule.SnoozedUntilUtc, + IsSnoozed = rule.SnoozedUntilUtc.HasValue && rule.SnoozedUntilUtc.Value > now, + LastFiredUtc = rule.LastFiredUtc, + CreatedUtc = rule.CreatedUtc, + UpdatedUtc = rule.UpdatedUtc + }; + } +} diff --git a/src/Exceptionless.Web/Models/RateNotification/RateNotificationRuleModels.cs b/src/Exceptionless.Web/Models/RateNotification/RateNotificationRuleModels.cs new file mode 100644 index 0000000000..0ff72cc38d --- /dev/null +++ b/src/Exceptionless.Web/Models/RateNotification/RateNotificationRuleModels.cs @@ -0,0 +1,82 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models; + +namespace Exceptionless.Web.Models; + +public record NewRateNotificationRule +{ + [Required] + [MaxLength(100)] + public string Name { get; init; } = null!; + + public RateNotificationSignal Signal { get; init; } + + public RateNotificationSubject Subject { get; init; } + + [ObjectId] + public string? StackId { get; init; } + + [Range(1, int.MaxValue)] + public int Threshold { get; init; } = 10; + + public TimeSpan Window { get; init; } = TimeSpan.FromHours(1); + + public TimeSpan Cooldown { get; init; } = TimeSpan.FromHours(1); + + public bool IsEnabled { get; init; } = true; +} + +public record UpdateRateNotificationRule +{ + [MaxLength(100)] + public string? Name { get; init; } + + public RateNotificationSignal? Signal { get; init; } + + public RateNotificationSubject? Subject { get; init; } + + [ObjectId] + public string? StackId { get; init; } + + [Range(1, int.MaxValue)] + public int? Threshold { get; init; } + + public TimeSpan? Window { get; init; } + + public TimeSpan? Cooldown { get; init; } + + public bool? IsEnabled { get; init; } +} + +public record ViewRateNotificationRule +{ + public string Id { get; init; } = null!; + public string OrganizationId { get; init; } = null!; + public string ProjectId { get; init; } = null!; + public string UserId { get; init; } = null!; + public int Version { get; init; } + public string Name { get; init; } = null!; + public bool IsEnabled { get; init; } + public RateNotificationSignal Signal { get; init; } + public RateNotificationSubject Subject { get; init; } + public string? StackId { get; init; } + public int Threshold { get; init; } + public TimeSpan Window { get; init; } + public TimeSpan Cooldown { get; init; } + public DateTime? SnoozedUntilUtc { get; init; } + public bool IsSnoozed { get; init; } + public DateTime? LastFiredUtc { get; init; } + public DateTime CreatedUtc { get; init; } + public DateTime UpdatedUtc { get; init; } +} + +public record SnoozeRateNotificationRuleRequest +{ + /// Snooze duration in seconds. Mutually exclusive with UntilUtc. + [Range(1, int.MaxValue)] + public int? DurationSeconds { get; init; } + + /// Snooze until this UTC timestamp. Mutually exclusive with DurationSeconds. + public DateTime? UntilUtc { get; init; } +} From 1de73edcc99c57649b3815e17530fb32db75dc9a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:42:26 -0500 Subject: [PATCH 08/18] feat: register rate notification services, jobs, and queues in DI - IRateNotificationRuleRepository, RateCounterService, RateNotificationRuleCache - RateNotificationsJob (queue delivery job) - RateNotificationEvaluatorJob (minutely distributed cron job) - RateNotification queue for in-process, Azure, Redis, SQS providers --- src/Exceptionless.Core/Bootstrapper.cs | 6 ++++++ src/Exceptionless.Insulation/Bootstrapper.cs | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 334f2b07ae..cebbaf3b6e 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -128,6 +128,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(s => CreateQueue(s)); services.AddSingleton(s => CreateQueue(s)); services.AddSingleton(s => CreateQueue(s, TimeSpan.FromHours(1))); + services.AddSingleton(s => CreateQueue(s)); services.TryAddEnumerable(ServiceDescriptor.Singleton, WorkItemDuplicateDetectionQueueBehavior>()); @@ -163,6 +164,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -205,6 +207,8 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddStartupAction(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -275,12 +279,14 @@ public static void AddHostedJobs(IServiceCollection services, ILoggerFactory log services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); + services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); services.AddDistributedCronJob(Cron.Minutely()); + services.AddDistributedCronJob(Cron.Minutely()); services.AddDistributedCronJob("30 */4 * * *"); services.AddDistributedCronJob("45 */8 * * *"); services.AddDistributedCronJob(Cron.Daily(1)); diff --git a/src/Exceptionless.Insulation/Bootstrapper.cs b/src/Exceptionless.Insulation/Bootstrapper.cs index 6174ca2cc3..05ed57b454 100644 --- a/src/Exceptionless.Insulation/Bootstrapper.cs +++ b/src/Exceptionless.Insulation/Bootstrapper.cs @@ -89,6 +89,7 @@ private static IHealthChecksBuilder RegisterHealthChecks(IServiceCollection serv .AddAutoNamedCheck>("EventUserDescriptions", "AllJobs") .AddAutoNamedCheck>("EventNotifications", "AllJobs") .AddAutoNamedCheck>("WebHooks", "AllJobs") + .AddAutoNamedCheck>("RateNotifications", "AllJobs") .AddAutoNamedCheck>("AllJobs") .AddAutoNamedCheck>("WorkItem", "AllJobs") @@ -159,6 +160,7 @@ private static void RegisterQueue(IServiceCollection container, QueueOptions opt container.ReplaceSingleton(s => CreateAzureStorageQueue(s, options)); container.ReplaceSingleton(s => CreateAzureStorageQueue(s, options)); container.ReplaceSingleton(s => CreateAzureStorageQueue(s, options)); + container.ReplaceSingleton(s => CreateAzureStorageQueue(s, options)); container.ReplaceSingleton(s => CreateAzureStorageQueue(s, options)); container.ReplaceSingleton(s => CreateAzureStorageQueue(s, options, workItemTimeout: TimeSpan.FromHours(1))); } @@ -168,6 +170,7 @@ private static void RegisterQueue(IServiceCollection container, QueueOptions opt container.ReplaceSingleton(s => CreateRedisQueue(s, options, runMaintenanceTasks)); container.ReplaceSingleton(s => CreateRedisQueue(s, options, runMaintenanceTasks)); container.ReplaceSingleton(s => CreateRedisQueue(s, options, runMaintenanceTasks)); + container.ReplaceSingleton(s => CreateRedisQueue(s, options, runMaintenanceTasks)); container.ReplaceSingleton(s => CreateRedisQueue(s, options, runMaintenanceTasks)); container.ReplaceSingleton(s => CreateRedisQueue(s, options, runMaintenanceTasks, workItemTimeout: TimeSpan.FromHours(1))); } @@ -177,6 +180,7 @@ private static void RegisterQueue(IServiceCollection container, QueueOptions opt container.ReplaceSingleton(s => CreateSQSQueue(s, options)); container.ReplaceSingleton(s => CreateSQSQueue(s, options)); container.ReplaceSingleton(s => CreateSQSQueue(s, options)); + container.ReplaceSingleton(s => CreateSQSQueue(s, options)); container.ReplaceSingleton(s => CreateSQSQueue(s, options)); container.ReplaceSingleton(s => CreateSQSQueue(s, options, workItemTimeout: TimeSpan.FromHours(1))); } From 09c4b3f57b41f644bc72ab088cb0e0f6ab469dc9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:42:34 -0500 Subject: [PATCH 09/18] test: add rate notification tests including snooze back-alert regression RateCounterServiceTests: 12 unit tests (no ES), including the critical regression: SumBucketsAsync_WithSnoozeFix_IgnoresTrafficDuringSnooze proves that events counted during a snooze period are excluded. RateNotificationEvaluatorJobTests: integration-level job tests including the back-alert scenario at job level (1 notification for Rule B only). RateNotificationRuleControllerTests: CRUD + snooze endpoint tests. --- .../RateNotificationRuleControllerTests.cs | 405 ++++++++++++++++++ .../Jobs/RateNotificationEvaluatorJobTests.cs | 221 ++++++++++ .../Services/RateCounterServiceTests.cs | 274 ++++++++++++ 3 files changed, 900 insertions(+) create mode 100644 tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs create mode 100644 tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs create mode 100644 tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs diff --git a/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs new file mode 100644 index 0000000000..bdcf4a5a57 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs @@ -0,0 +1,405 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +public sealed class RateNotificationRuleControllerTests : IntegrationTestsBase +{ + private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IUserRepository _userRepository; + + public RateNotificationRuleControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _ruleRepository = GetService(); + _userRepository = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + var service = GetService(); + await service.CreateDataAsync(); + } + + // ---- Helper: get current user via /me endpoint ---- + private async Task GetTestOrganizationUserAsync() + { + var user = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPath("users/me") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(user); + return user; + } + + private async Task GetGlobalAdminUserAsync() + { + var user = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPath("users/me") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(user); + return user; + } + + private string RuleUrl(string userId, string projectId) => + $"users/{userId}/projects/{projectId}/rate-notifications"; + + private string RuleUrl(string userId, string projectId, string ruleId) => + $"users/{userId}/projects/{projectId}/rate-notifications/{ruleId}"; + + // ---- CRUD tests ---- + + [Fact] + public async Task GetAsync_AsOwnUser_ReturnsList() + { + var user = await GetTestOrganizationUserAsync(); + + var results = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(results); + } + + [Fact] + public async Task GetAsync_AsDifferentUser_ReturnsNotFound() + { + // Using org user ID to try to access another user's rules + var adminUser = await GetGlobalAdminUserAsync(); + + await SendRequestAsync(r => r + .AsTestOrganizationUser() // Org user + .AppendPath(RuleUrl(adminUser.Id, SampleDataService.TEST_PROJECT_ID)) // accessing admin's rules + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task GetAsync_AsGlobalAdmin_CanAccessAnyUsersRules() + { + var orgUser = await GetTestOrganizationUserAsync(); + + var results = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() // admin accessing org user's rules + .AppendPath(RuleUrl(orgUser.Id, SampleDataService.TEST_PROJECT_ID)) + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(results); + } + + [Fact] + public async Task PostAsync_ValidRule_CreatesRule() + { + var user = await GetTestOrganizationUserAsync(); + + var newRule = new NewRateNotificationRule + { + Name = "Error spike", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + IsEnabled = true + }; + + var created = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(newRule) + .StatusCodeShouldBeCreated() + ); + + Assert.NotNull(created); + Assert.Equal("Error spike", created.Name); + Assert.Equal(RateNotificationSignal.Errors, created.Signal); + Assert.Equal(10, created.Threshold); + } + + [Fact] + public async Task PostAsync_InvalidWindow_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + var newRule = new NewRateNotificationRule + { + Name = "Bad window", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(7), // invalid — not in allowed list + Cooldown = TimeSpan.FromMinutes(30), + IsEnabled = true + }; + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(newRule) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PostAsync_CooldownLessThanWindow_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + var newRule = new NewRateNotificationRule + { + Name = "Bad cooldown", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(30), + Cooldown = TimeSpan.FromMinutes(5), // less than window + IsEnabled = true + }; + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(newRule) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PostAsync_StackSubjectWithoutStackId_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + var newRule = new NewRateNotificationRule + { + Name = "Stack rule no id", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Stack, // stack subject + StackId = null, // missing StackId + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + IsEnabled = true + }; + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(newRule) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task GetByIdAsync_ExistingRule_ReturnsRule() + { + var user = await GetTestOrganizationUserAsync(); + + // Create first + var created = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Get-by-id test", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated() + ); + + Assert.NotNull(created); + + // Fetch by ID + var fetched = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)) + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(fetched); + Assert.Equal(created.Id, fetched.Id); + Assert.Equal("Get-by-id test", fetched.Name); + } + + [Fact] + public async Task DeleteAsync_ExistingRule_RemovesRule() + { + var user = await GetTestOrganizationUserAsync(); + + // Create + var created = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Delete me", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated() + ); + + Assert.NotNull(created); + + // Delete + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)) + .StatusCodeShouldBeNoContent() + ); + + // Confirm deleted + var rule = await _ruleRepository.GetByIdAsync(created.Id); + Assert.Null(rule); + } + + [Fact] + public async Task SnoozeAsync_ValidDuration_SetsSnooze() + { + var user = await GetTestOrganizationUserAsync(); + + // Create a rule + var created = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Snooze test", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated() + ); + + Assert.NotNull(created); + + // Snooze for 1 hour + var snoozed = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath($"{RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)}/snooze") + .Content(new SnoozeRateNotificationRuleRequest { DurationSeconds = 3600 }) + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(snoozed); + Assert.True(snoozed.IsSnoozed, "Rule should be snoozed after snooze request."); + Assert.NotNull(snoozed.SnoozedUntilUtc); + } + + [Fact] + public async Task UnsnoozeAsync_SnoozedRule_SetsSnoozedUntilToNow() + { + var user = await GetTestOrganizationUserAsync(); + + // Create + snooze + var created = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Unsnooze test", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated() + ); + + Assert.NotNull(created); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath($"{RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)}/snooze") + .Content(new SnoozeRateNotificationRuleRequest { DurationSeconds = 3600 }) + .StatusCodeShouldBeOk() + ); + + // Unsnooze — sets SnoozedUntilUtc = now, so IsSnoozed = false + var unsnoozed = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath($"{RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)}/unsnooze") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(unsnoozed); + Assert.False(unsnoozed.IsSnoozed, "Rule should NOT be actively snoozed after unsnooze (SnoozedUntilUtc = now)."); + // SnoozedUntilUtc is still set (to now) — not null. This is the fresh baseline mechanism. + Assert.NotNull(unsnoozed.SnoozedUntilUtc); + } + + [Fact] + public async Task PostAsync_ExceedsMaxRulesPerUser_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + // Create 20 rules (the maximum) + for (int i = 0; i < 20; i++) + { + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = $"Rule {i + 1}", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated() + ); + } + + // 21st rule should fail + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Rule 21", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } +} diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs new file mode 100644 index 0000000000..641702c01f --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs @@ -0,0 +1,221 @@ +using Exceptionless.Core.Jobs; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Foundatio.Queues; +using Xunit; + +namespace Exceptionless.Tests.Jobs; + +public class RateNotificationEvaluatorJobTests : IntegrationTestsBase +{ + private readonly RateNotificationEvaluatorJob _job; + private readonly RateCounterService _counterService; + private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IOrganizationRepository _orgRepository; + private readonly IProjectRepository _projectRepository; + private readonly IQueue _notificationQueue; + + public RateNotificationEvaluatorJobTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _job = GetService(); + _counterService = GetService(); + _ruleRepository = GetService(); + _orgRepository = GetService(); + _projectRepository = GetService(); + _notificationQueue = GetService>(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + var service = GetService(); + await service.CreateDataAsync(); + } + + private RateNotificationRule BuildRule(string? projectId = null, RateNotificationSignal signal = RateNotificationSignal.Errors, int threshold = 10, string? window = null, string? cooldown = null) + { + var now = TimeProvider.GetUtcNow().UtcDateTime; + return new RateNotificationRule + { + OrganizationId = SampleDataService.TEST_ORG_ID, + ProjectId = projectId ?? SampleDataService.TEST_PROJECT_ID, + UserId = "507f1f77bcf86cd799439011", + Name = "Test Rule", + IsEnabled = true, + Signal = signal, + Subject = RateNotificationSubject.Project, + Threshold = threshold, + Window = window is not null ? TimeSpan.Parse(window) : TimeSpan.FromMinutes(5), + Cooldown = cooldown is not null ? TimeSpan.Parse(cooldown) : TimeSpan.FromMinutes(10), + Version = 1, + CreatedUtc = now, + UpdatedUtc = now + }; + } + + private string BuildCounterKey(RateNotificationRule rule) => + $"project:{rule.ProjectId}:signal:{rule.Signal}"; + + [Fact] + public async Task RunAsync_WhenThresholdCrossed_EnqueuesNotification() + { + var ct = TestContext.Current.CancellationToken; + // Arrange: start at event time (2 min before "now"), then advance forward + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-2)); + + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 5)); + string counterKey = BuildCounterKey(rule); + + // Simulate 10 events within the 5-minute window + for (int i = 0; i < 10; i++) + await _counterService.IncrementAsync(counterKey, ct); + + TimeProvider.Advance(TimeSpan.FromMinutes(2)); + + // Act + await _job.RunAsync(ct); + + // Assert — notification enqueued + var stats = await _notificationQueue.GetQueueStatsAsync(); + Assert.True(stats.Enqueued > 0, "Expected a RateNotification to be enqueued when threshold is crossed."); + } + + [Fact] + public async Task RunAsync_WhenBelowThreshold_DoesNotEnqueue() + { + var ct = TestContext.Current.CancellationToken; + // Arrange: start at event time, advance to "now" + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-2)); + + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 50)); + string counterKey = BuildCounterKey(rule); + + // Only 5 events — well below threshold of 50 + for (int i = 0; i < 5; i++) + await _counterService.IncrementAsync(counterKey, ct); + + TimeProvider.Advance(TimeSpan.FromMinutes(2)); + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + // Act + await _job.RunAsync(ct); + + // Assert — no new notification + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + Assert.Equal(queueBefore, queueAfter); + } + + [Fact] + public async Task RunAsync_WhenOnCooldown_DoesNotEnqueueAgain() + { + var ct = TestContext.Current.CancellationToken; + // Arrange: start at event time, advance to "now", then set cooldown + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-2)); + + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 5)); + string counterKey = BuildCounterKey(rule); + string subjectKey = $"project:{rule.ProjectId}"; + + // Add enough events to cross threshold + for (int i = 0; i < 10; i++) + await _counterService.IncrementAsync(counterKey, ct); + + TimeProvider.Advance(TimeSpan.FromMinutes(2)); + + // Put rule on cooldown (at "now") + await _counterService.SetCooldownAsync(rule.Id, subjectKey, TimeSpan.FromHours(1), ct); + + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + // Act + await _job.RunAsync(ct); + + // Assert — still on cooldown, nothing extra enqueued + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + Assert.Equal(queueBefore, queueAfter); + } + + [Fact] + public async Task RunAsync_WhenActivelySnoozed_SkipsEvaluation() + { + var ct = TestContext.Current.CancellationToken; + // Arrange: start at event time, advance to "now" + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-2)); + + var ruleData = BuildRule(threshold: 5); + ruleData.SnoozedUntilUtc = now.AddHours(2); // snoozed for 2 more hours + var rule = await _ruleRepository.AddAsync(ruleData); + string counterKey = BuildCounterKey(rule); + + // Add events above threshold + for (int i = 0; i < 20; i++) + await _counterService.IncrementAsync(counterKey, ct); + + TimeProvider.Advance(TimeSpan.FromMinutes(2)); + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + // Act + await _job.RunAsync(ct); + + // Assert — snoozed rule does not fire + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + Assert.Equal(queueBefore, queueAfter); + } + + /// + /// CRITICAL REGRESSION: Snooze back-alert prevention at job level. + /// + /// Setup: Rule A was snoozed until T-2min (snooze recently expired). + /// 15 events were counted at T-3min (during the snooze period). + /// Rule B (identical but not snoozed) gets the same traffic. + /// + /// Expected: + /// Rule B fires: full 5-min window sees 15 events at or above threshold. + /// Rule A does NOT fire: effective window is [T-2min, now], so 0 events (prevented). + /// + [Fact] + public async Task RunAsync_SnoozeBackAlert_SnoozedRuleIgnoresTrafficDuringSnoozeWindow() + { + var ct = TestContext.Current.CancellationToken; + // Arrange: start at T-3min (events arrive during snooze), then advance to "now" + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-3)); + + // Rule A: snoozed until T-2min (snooze has just expired at "now") + var ruleAData = BuildRule(threshold: 10); + ruleAData.SnoozedUntilUtc = now.AddMinutes(-2); + var ruleA = await _ruleRepository.AddAsync(ruleAData); + + // Rule B: not snoozed — should fire normally + var ruleB = await _ruleRepository.AddAsync(BuildRule(threshold: 10)); + + // Both rules watch the same signal/counter key + string counterKey = BuildCounterKey(ruleA); + Assert.Equal(counterKey, BuildCounterKey(ruleB)); + + // Simulate 15 events at T-3min (during Rule A's snooze window) + for (int i = 0; i < 15; i++) + await _counterService.IncrementAsync(counterKey, ct); + + TimeProvider.Advance(TimeSpan.FromMinutes(3)); + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + // Act + await _job.RunAsync(ct); + + // Assert + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + long newNotifications = queueAfter - queueBefore; + + // Rule B should have fired (1 notification), Rule A should NOT have fired + // So exactly 1 notification should be enqueued. + Assert.Equal(1, newNotifications); + } +} diff --git a/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs new file mode 100644 index 0000000000..8d8a53bbbf --- /dev/null +++ b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs @@ -0,0 +1,274 @@ +using Exceptionless.Core.Services; +using Exceptionless.Tests.Utility; +using Foundatio.Caching; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Exceptionless.Tests.Services; + +/// +/// Unit tests for RateCounterService, including the critical snooze back-alert regression test. +/// Uses an in-memory cache client and ProxyTimeProvider — no Elasticsearch required. +/// +public class RateCounterServiceTests +{ + private const string CounterKey = "project:P1:signal:Errors"; + private const string RuleId = "rule-001"; + private const string SubjectKey = "project:P1"; + + private static (RateCounterService service, ProxyTimeProvider timeProvider, InMemoryCacheClient cache) Create() + { + var timeProvider = new ProxyTimeProvider(); + var cache = new InMemoryCacheClient(new InMemoryCacheClientOptions + { + LoggerFactory = NullLoggerFactory.Instance + }); + var service = new RateCounterService(cache, timeProvider, NullLoggerFactory.Instance); + return (service, timeProvider, cache); + } + + // ------------------------------------------------------------------------- + // CRITICAL REGRESSION TEST: Snooze back-alert prevention + // ------------------------------------------------------------------------- + + /// + /// Verifies that when a rule was snoozed and the snooze recently expired, the + /// evaluator's SumBucketsAsync call uses max(windowStart, snoozedUntil) as the + /// effective window start — so traffic counted during the snooze window does NOT + /// trigger the rule. + /// + /// Without the snooze fix: Rule A would see the 15 events counted 3 minutes ago + /// (inside the 5-minute window) and fire incorrectly. + /// + /// With the snooze fix: Rule A uses snoozedUntilUtc as the effective lower boundary, + /// so it only counts events after the snooze expired (0 events) and does NOT fire. + /// + [Fact] + public async Task SumBucketsAsync_WithSnoozeFix_IgnoresTrafficDuringSnooze() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + + // Start at T-3min (the time the snoozed events occurred) + var baseTime = new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); + var eventTime = baseTime.AddMinutes(-3); + timeProvider.SetUtcNow(eventTime); + + for (int i = 0; i < 15; i++) + await service.IncrementAsync(CounterKey, ct); + + // Advance to "now" (T+0) + timeProvider.Advance(TimeSpan.FromMinutes(3)); + + var now = baseTime; + var windowDuration = TimeSpan.FromMinutes(5); + var windowStartUtc = now.Subtract(windowDuration); // T-5min + + // Rule A: snoozed until T-2min (snooze recently expired) + var snoozedUntilUtc = now.AddMinutes(-2); // T-2min + + // Without snooze fix: sum from T-5min to now = 15 events (fires incorrectly) + long withoutFix = await service.SumBucketsAsync(CounterKey, windowStartUtc, now, ct); + + // With snooze fix: effective window start = max(T-5min, T-2min) = T-2min + // Events were counted at T-3min which is BEFORE T-2min, so they're excluded + var effectiveWindowStart = snoozedUntilUtc > windowStartUtc ? snoozedUntilUtc : windowStartUtc; + long withFix = await service.SumBucketsAsync(CounterKey, effectiveWindowStart, now, ct); + + // Without fix: 15 events (would fire incorrectly) + Assert.Equal(15, withoutFix); + + // With fix: 0 events (correctly prevents back-alert) + Assert.Equal(0, withFix); + } + + /// + /// Verifies that Rule B (not snoozed) DOES fire for the same traffic. + /// Companion test to the snooze regression — proves the snooze fix is selective. + /// + [Fact] + public async Task SumBucketsAsync_NonSnoozedRule_CountsAllTrafficInWindow() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + + // Start at T-3min, then advance forward + var baseTime = new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); + var eventTime = baseTime.AddMinutes(-3); + timeProvider.SetUtcNow(eventTime); + + for (int i = 0; i < 15; i++) + await service.IncrementAsync(CounterKey, ct); + + timeProvider.Advance(TimeSpan.FromMinutes(3)); + + var now = baseTime; + var windowStartUtc = now.AddMinutes(-5); // full 5min window + + // Non-snoozed rule: uses full window + long count = await service.SumBucketsAsync(CounterKey, windowStartUtc, now, ct); + + // All 15 events are in the 5-minute window => threshold=10 => fires + Assert.Equal(15, count); + } + + // ------------------------------------------------------------------------- + // Bucket increment and sum tests + // ------------------------------------------------------------------------- + + [Fact] + public async Task IncrementAsync_SingleIncrement_CreatesCountBucket() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc); + timeProvider.SetUtcNow(now); + + await service.IncrementAsync(CounterKey, ct); + + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-1), now, ct); + Assert.Equal(1, count); + } + + [Fact] + public async Task IncrementAsync_MultipleIncrements_AccumulatesCount() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc); + timeProvider.SetUtcNow(now); + + for (int i = 0; i < 7; i++) + await service.IncrementAsync(CounterKey, ct); + + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-1), now, ct); + Assert.Equal(7, count); + } + + [Fact] + public async Task SumBucketsAsync_AcrossMultipleMinutes_SumsAllBuckets() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 5, 0, DateTimeKind.Utc); + + // Add 3 events at T-4min + timeProvider.SetUtcNow(now.AddMinutes(-4)); + for (int i = 0; i < 3; i++) + await service.IncrementAsync(CounterKey, ct); + + // Add 5 events at T-2min + timeProvider.SetUtcNow(now.AddMinutes(-2)); + for (int i = 0; i < 5; i++) + await service.IncrementAsync(CounterKey, ct); + + // Add 2 events at T-0 + timeProvider.SetUtcNow(now); + for (int i = 0; i < 2; i++) + await service.IncrementAsync(CounterKey, ct); + + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-5), now, ct); + Assert.Equal(10, count); + } + + [Fact] + public async Task SumBucketsAsync_EmptyWindow_ReturnsZero() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc); + timeProvider.SetUtcNow(now); + + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-5), now, ct); + Assert.Equal(0, count); + } + + [Fact] + public async Task SumBucketsAsync_EventsOutsideWindow_NotCounted() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 10, 0, DateTimeKind.Utc); + + // Add events 10 minutes ago — outside a 5-minute window + timeProvider.SetUtcNow(now.AddMinutes(-10)); + for (int i = 0; i < 20; i++) + await service.IncrementAsync(CounterKey, ct); + + timeProvider.SetUtcNow(now); + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-5), now, ct); + Assert.Equal(0, count); + } + + // ------------------------------------------------------------------------- + // Active counter key tracking + // ------------------------------------------------------------------------- + + [Fact] + public async Task GetActiveCounterKeysAsync_AfterIncrement_ReturnsKey() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc); + timeProvider.SetUtcNow(now); + + await service.IncrementAsync(CounterKey, ct); + + var keys = await service.GetActiveCounterKeysAsync(now, ct); + Assert.Contains(CounterKey, keys); + } + + [Fact] + public async Task GetActiveCounterKeysAsync_DifferentMinute_ReturnsEmpty() + { + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 5, 0, DateTimeKind.Utc); + timeProvider.SetUtcNow(now); + + await service.IncrementAsync(CounterKey, ct); + + // Ask for a different minute + var keys = await service.GetActiveCounterKeysAsync(now.AddMinutes(-3), ct); + Assert.DoesNotContain(CounterKey, keys); + } + + // ------------------------------------------------------------------------- + // Cooldown tests + // ------------------------------------------------------------------------- + + [Fact] + public async Task IsOnCooldownAsync_WhenNoCooldownSet_ReturnsFalse() + { + var ct = TestContext.Current.CancellationToken; + var (service, _, _) = Create(); + bool onCooldown = await service.IsOnCooldownAsync(RuleId, SubjectKey, ct); + Assert.False(onCooldown); + } + + [Fact] + public async Task IsOnCooldownAsync_AfterSetCooldown_ReturnsTrue() + { + var ct = TestContext.Current.CancellationToken; + var (service, _, _) = Create(); + await service.SetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromHours(1), ct); + bool onCooldown = await service.IsOnCooldownAsync(RuleId, SubjectKey, ct); + Assert.True(onCooldown); + } + + [Fact] + public async Task SetCooldownAsync_DifferentRules_IndependentCooldowns() + { + var ct = TestContext.Current.CancellationToken; + var (service, _, _) = Create(); + const string ruleId2 = "rule-002"; + + await service.SetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromHours(1), ct); + + bool rule1OnCooldown = await service.IsOnCooldownAsync(RuleId, SubjectKey, ct); + bool rule2OnCooldown = await service.IsOnCooldownAsync(ruleId2, SubjectKey, ct); + + Assert.True(rule1OnCooldown); + Assert.False(rule2OnCooldown); + } +} From 43254b075d80d14334f986b314679c2e95d3cd6e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:42:37 -0500 Subject: [PATCH 10/18] chore: update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5cd0861747..9c8213d1bc 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ debug-storybook.log .devcontainer/devcontainer-lock.json *.lscache +.gstack/ From 2d93a42e02d843c4ed11e7e91be348e345fa00e4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:48:29 -0500 Subject: [PATCH 11/18] feat: add rate-notifications Svelte 5 frontend feature module - types.ts: ViewRateNotificationRule, NewRateNotificationRule, UpdateRateNotificationRule, SnoozeRateNotificationRuleRequest - api.svelte.ts: TanStack Query wrappers for list, create, update, delete, snooze, unsnooze - components/rate-notification-rule-list.svelte: list with enable toggle, snooze badge, delete confirm, premium gate - components/rate-notification-rule-form.svelte: create/edit form with validation, premium gate, noise warning - index.ts: barrel export Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/rate-notifications/api.svelte.ts | 210 +++++++++++++ .../rate-notification-rule-form.svelte | 284 ++++++++++++++++++ .../rate-notification-rule-list.svelte | 217 +++++++++++++ .../lib/features/rate-notifications/index.ts | 5 + .../lib/features/rate-notifications/types.ts | 78 +++++ 5 files changed, 794 insertions(+) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts new file mode 100644 index 0000000000..f0224d51d2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts @@ -0,0 +1,210 @@ +import type { + NewRateNotificationRule, + SnoozeRateNotificationRuleRequest, + UpdateRateNotificationRule, + ViewRateNotificationRule +} from '$features/rate-notifications/types'; + +import { accessToken } from '$features/auth/index.svelte'; +import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; + +export const queryKeys = { + create: (userId: string | undefined, projectId: string | undefined) => + [...queryKeys.list(userId, projectId), 'create'] as const, + delete: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => + [...queryKeys.id(userId, projectId, ruleId), 'delete'] as const, + id: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => + [...queryKeys.list(userId, projectId), ruleId] as const, + list: (userId: string | undefined, projectId: string | undefined) => + ['RateNotificationRule', userId, projectId] as const, + snooze: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => + [...queryKeys.id(userId, projectId, ruleId), 'snooze'] as const, + unsnooze: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => + [...queryKeys.id(userId, projectId, ruleId), 'unsnooze'] as const, + update: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => + [...queryKeys.id(userId, projectId, ruleId), 'update'] as const +}; + +export async function invalidateRateNotificationQueries( + queryClient: QueryClient, + userId: string | undefined, + projectId: string | undefined, + ruleId?: string | undefined +) { + if (ruleId) { + await queryClient.invalidateQueries({ queryKey: queryKeys.id(userId, projectId, ruleId) }); + } + await queryClient.invalidateQueries({ queryKey: queryKeys.list(userId, projectId) }); +} + +function ruleRoute(userId: string, projectId: string, ruleId?: string): string { + const base = `users/${userId}/projects/${projectId}/rate-notifications`; + return ruleId ? `${base}/${ruleId}` : base; +} + +// ---- List ---- + +export interface GetRuleListRequest { + params?: { limit?: number; page?: number }; + route: { projectId: string | undefined; userId: string | undefined }; +} + +export function getRateNotificationRulesQuery(request: GetRuleListRequest) { + const queryClient = useQueryClient(); + + return createQuery, ProblemDetails>(() => ({ + enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, + onSuccess: (data: FetchClientResponse) => { + data.data?.forEach((rule) => { + queryClient.setQueryData(queryKeys.id(request.route.userId, request.route.projectId, rule.id), rule); + }); + }, + queryClient, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON( + ruleRoute(request.route.userId!, request.route.projectId!), + { params: { limit: 50, ...request.params }, signal } + ); + return response; + }, + queryKey: [...queryKeys.list(request.route.userId, request.route.projectId), { params: request.params }] + })); +} + +// ---- Create ---- + +export interface CreateRuleRequest { + route: { projectId: string | undefined; userId: string | undefined }; +} + +export function createRateNotificationRule(request: CreateRuleRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, + mutationFn: async (body: NewRateNotificationRule) => { + const client = useFetchClient(); + const response = await client.postJSON( + ruleRoute(request.route.userId!, request.route.projectId!), + body + ); + return response.data!; + }, + mutationKey: queryKeys.create(request.route.userId, request.route.projectId), + onSuccess: (rule: ViewRateNotificationRule) => { + queryClient.setQueryData(queryKeys.id(request.route.userId, request.route.projectId, rule.id), rule); + queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + } + })); +} + +// ---- Update ---- + +export interface UpdateRuleRequest { + route: { projectId: string | undefined; ruleId: string | undefined; userId: string | undefined }; +} + +export function updateRateNotificationRule(request: UpdateRuleRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => + !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, + mutationFn: async (body: UpdateRateNotificationRule) => { + const client = useFetchClient(); + const response = await client.putJSON( + ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!), + body + ); + return response.data!; + }, + mutationKey: queryKeys.update(request.route.userId, request.route.projectId, request.route.ruleId), + onSuccess: (rule: ViewRateNotificationRule) => { + queryClient.setQueryData(queryKeys.id(request.route.userId, request.route.projectId, rule.id), rule); + queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + } + })); +} + +// ---- Delete ---- + +export interface DeleteRuleRequest { + route: { projectId: string | undefined; ruleId: string | undefined; userId: string | undefined }; +} + +export function deleteRateNotificationRule(request: DeleteRuleRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => + !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, + mutationFn: async () => { + const client = useFetchClient(); + await client.delete(ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!), { + expectedStatusCodes: [204] + }); + }, + mutationKey: queryKeys.delete(request.route.userId, request.route.projectId, request.route.ruleId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + } + })); +} + +// ---- Snooze ---- + +export interface SnoozeRuleRequest { + route: { projectId: string | undefined; ruleId: string | undefined; userId: string | undefined }; +} + +export function snoozeRateNotificationRule(request: SnoozeRuleRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => + !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, + mutationFn: async (body: SnoozeRateNotificationRuleRequest) => { + const client = useFetchClient(); + await client.postJSON( + `${ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!)}/snooze`, + body, + { expectedStatusCodes: [200, 204] } + ); + }, + mutationKey: queryKeys.snooze(request.route.userId, request.route.projectId, request.route.ruleId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.id(request.route.userId, request.route.projectId, request.route.ruleId) + }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + } + })); +} + +// ---- Unsnooze ---- + +export function unsnoozeRateNotificationRule(request: SnoozeRuleRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => + !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, + mutationFn: async () => { + const client = useFetchClient(); + await client.postJSON( + `${ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!)}/unsnooze`, + {}, + { expectedStatusCodes: [200, 204] } + ); + }, + mutationKey: queryKeys.unsnooze(request.route.userId, request.route.projectId, request.route.ruleId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.id(request.route.userId, request.route.projectId, request.route.ruleId) + }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte new file mode 100644 index 0000000000..554deaad06 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte @@ -0,0 +1,284 @@ + + +
{ e.preventDefault(); handleSubmit(); }}> + {#if !hasPremiumFeatures} + + + + Upgrade now to enable and create rate notification rules. + + + {/if} + + + + This rule may be noisy. Use a cooldown to avoid repeated emails. + + + +
+ + + {#if nameError} +

{nameError}

+ {/if} +
+ + +
+ + + + {SIGNAL_LABELS[signal]} + + + {#each Object.entries(SIGNAL_LABELS) as [value, label] (value)} + {label} + {/each} + + +
+ + +
+ + + + {subject} + + + Project + Stack + + +
+ + + {#if subject === 'Stack'} +
+ + + {#if stackIdError} +

{stackIdError}

+ {/if} +
+ {/if} + + +
+ + + {#if thresholdError} +

{thresholdError}

+ {/if} +
+ + +
+ + + + {WINDOW_OPTIONS.find((o) => o.value === window)?.label ?? window} + + + {#each WINDOW_OPTIONS as option (option.value)} + {option.label} + {/each} + + +
+ + +
+ + + + {WINDOW_OPTIONS.find((o) => o.value === cooldown)?.label ?? cooldown} + + + {#each WINDOW_OPTIONS as option (option.value)} + {option.label} + {/each} + + 2 hours + 4 hours + 8 hours + 24 hours + + + {#if cooldownError} +

{cooldownError}

+ {/if} + Further notifications for this rule are suppressed during the cooldown period. +
+ + +
+ + +
+ + {#if formError} +

{formError}

+ {/if} + +
+ {#if onCancel} + + {/if} + +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte new file mode 100644 index 0000000000..917994e2a5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte @@ -0,0 +1,217 @@ + + +
+ + + {#if !hasPremiumFeatures} + + + + Upgrade now to enable personal rate notifications! + + + {/if} + + {#if listQuery.isLoading} +
+ {#each { length: 2 } as _} +
+ {/each} +
+ {:else if rules.length === 0} +
+ +

No rate notification rules yet.

+ {#if hasPremiumFeatures} + + {/if} +
+ {:else} +
+ {#each rules as rule (rule.id)} +
+
+
+ +
+ {SIGNAL_LABELS[rule.signal]} + + ≥{rule.threshold} in {formatWindow(rule.window)} + + {#if rule.is_snoozed} + + + Snoozed + + {/if} + {#if rule.subject === 'Stack' && rule.stack_id} + Stack-scoped + {/if} +
+
+
+ + toggleEnabled(rule, checked)} + aria-label={rule.is_enabled ? 'Disable rule' : 'Enable rule'} + /> + +
+
+
+ {/each} +
+ + {#if hasPremiumFeatures && rules.length < MAX_RULES_PER_PROJECT} + + {/if} + {/if} +
+ + + !open && (confirmDeleteRuleId = undefined)}> + + + Delete rule? + + This action cannot be undone. The rate notification rule will be permanently deleted. + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts new file mode 100644 index 0000000000..8e3c411379 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts @@ -0,0 +1,5 @@ +// Rate notifications feature barrel export +export { default as RateNotificationRuleForm } from './components/rate-notification-rule-form.svelte'; +export { default as RateNotificationRuleList } from './components/rate-notification-rule-list.svelte'; +export * from './api.svelte'; +export * from './types'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts new file mode 100644 index 0000000000..7f56f5bbbd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts @@ -0,0 +1,78 @@ +// Types matching the RateNotificationRule API DTOs + +export type RateNotificationSignal = 'AllEvents' | 'Errors' | 'CriticalErrors' | 'NewErrors' | 'Regressions'; +export type RateNotificationSubject = 'Project' | 'Stack'; + +export interface ViewRateNotificationRule { + id: string; + organization_id: string; + project_id: string; + user_id: string; + version: number; + name: string; + is_enabled: boolean; + signal: RateNotificationSignal; + subject: RateNotificationSubject; + stack_id?: string; + threshold: number; + /** ISO 8601 duration string (e.g. "00:05:00") */ + window: string; + /** ISO 8601 duration string */ + cooldown: string; + snoozed_until_utc?: string; + last_fired_utc?: string; + created_utc: string; + updated_utc: string; + /** Computed: snoozed_until_utc is in the future */ + is_snoozed: boolean; +} + +export interface NewRateNotificationRule { + name: string; + signal: RateNotificationSignal; + subject: RateNotificationSubject; + stack_id?: string; + threshold: number; + /** ISO 8601 duration string (e.g. "00:05:00") */ + window: string; + /** ISO 8601 duration string */ + cooldown: string; + is_enabled: boolean; +} + +export interface UpdateRateNotificationRule { + name?: string; + signal?: RateNotificationSignal; + subject?: RateNotificationSubject; + stack_id?: string; + threshold?: number; + window?: string; + cooldown?: string; + is_enabled?: boolean; +} + +export interface SnoozeRateNotificationRuleRequest { + duration_seconds?: number; + until_utc?: string; +} + +/** Friendly labels for signal enum values */ +export const SIGNAL_LABELS: Record = { + AllEvents: 'All Events', + CriticalErrors: 'Critical Errors', + Errors: 'Errors', + NewErrors: 'New Errors', + Regressions: 'Regressions' +}; + +/** Allowed window durations (as ISO 8601) mapped to friendly labels */ +export const WINDOW_OPTIONS: { label: string; value: string }[] = [ + { label: '1 minute', value: '00:01:00' }, + { label: '5 minutes', value: '00:05:00' }, + { label: '10 minutes', value: '00:10:00' }, + { label: '15 minutes', value: '00:15:00' }, + { label: '30 minutes', value: '00:30:00' }, + { label: '1 hour', value: '01:00:00' } +]; + +export const MAX_RULES_PER_PROJECT = 20; From 143cfea9da8889fdece5b31dd360c0bcd7668116 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 07:58:01 -0500 Subject: [PATCH 12/18] fix: use ImmediateConsistency on AddAsync to prevent rule-count bypass and test false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Controller: AddAsync(rule, o => o.Cache().ImmediateConsistency()) ensures the 20-rule-per-project limit is enforced even under rapid concurrent creation. Without this, ES doesn't refresh before CountByProjectIdAndUserIdAsync, returning stale count=0 and allowing unlimited rules. - Tests: add Foundatio.Repositories using + ImmediateConsistency() on all _ruleRepository.AddAsync calls in RateNotificationEvaluatorJobTests so that GetEnabledByProjectIdAsync sees freshly indexed rules. Previously the 3 passing tests were false positives (rules invisible → no enqueue = expected) and the 2 failing tests were false negatives (rules invisible → no enqueue ≠ expected). All 29 rate-notification tests now pass: 12/12 RateCounterServiceTests 12/12 RateNotificationRuleControllerTests 5/5 RateNotificationEvaluatorJobTests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Controllers/RateNotificationRuleController.cs | 2 +- .../Jobs/RateNotificationEvaluatorJobTests.cs | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs index 3a3998734d..c4bf00aa0d 100644 --- a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs +++ b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs @@ -145,7 +145,7 @@ public async Task> PostAsync( UpdatedUtc = now }; - rule = await _ruleRepository.AddAsync(rule, o => o.Cache()); + rule = await _ruleRepository.AddAsync(rule, o => o.Cache().ImmediateConsistency()); await _ruleCache.InvalidateAsync(projectId); return Created(new Uri(Url.Link("GetRateNotificationRuleById", new { userId, projectId, ruleId = rule.Id })!, UriKind.RelativeOrAbsolute), MapToView(rule)); diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs index 641702c01f..6a55b4653c 100644 --- a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs @@ -5,6 +5,7 @@ using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Foundatio.Queues; +using Foundatio.Repositories; using Xunit; namespace Exceptionless.Tests.Jobs; @@ -67,7 +68,7 @@ public async Task RunAsync_WhenThresholdCrossed_EnqueuesNotification() var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); TimeProvider.SetUtcNow(now.AddMinutes(-2)); - var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 5)); + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 5), o => o.ImmediateConsistency()); string counterKey = BuildCounterKey(rule); // Simulate 10 events within the 5-minute window @@ -92,7 +93,7 @@ public async Task RunAsync_WhenBelowThreshold_DoesNotEnqueue() var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); TimeProvider.SetUtcNow(now.AddMinutes(-2)); - var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 50)); + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 50), o => o.ImmediateConsistency()); string counterKey = BuildCounterKey(rule); // Only 5 events — well below threshold of 50 @@ -118,7 +119,7 @@ public async Task RunAsync_WhenOnCooldown_DoesNotEnqueueAgain() var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); TimeProvider.SetUtcNow(now.AddMinutes(-2)); - var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 5)); + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 5), o => o.ImmediateConsistency()); string counterKey = BuildCounterKey(rule); string subjectKey = $"project:{rule.ProjectId}"; @@ -151,7 +152,7 @@ public async Task RunAsync_WhenActivelySnoozed_SkipsEvaluation() var ruleData = BuildRule(threshold: 5); ruleData.SnoozedUntilUtc = now.AddHours(2); // snoozed for 2 more hours - var rule = await _ruleRepository.AddAsync(ruleData); + var rule = await _ruleRepository.AddAsync(ruleData, o => o.ImmediateConsistency()); string counterKey = BuildCounterKey(rule); // Add events above threshold @@ -191,10 +192,10 @@ public async Task RunAsync_SnoozeBackAlert_SnoozedRuleIgnoresTrafficDuringSnooze // Rule A: snoozed until T-2min (snooze has just expired at "now") var ruleAData = BuildRule(threshold: 10); ruleAData.SnoozedUntilUtc = now.AddMinutes(-2); - var ruleA = await _ruleRepository.AddAsync(ruleAData); + var ruleA = await _ruleRepository.AddAsync(ruleAData, o => o.ImmediateConsistency()); // Rule B: not snoozed — should fire normally - var ruleB = await _ruleRepository.AddAsync(BuildRule(threshold: 10)); + var ruleB = await _ruleRepository.AddAsync(BuildRule(threshold: 10), o => o.ImmediateConsistency()); // Both rules watch the same signal/counter key string counterKey = BuildCounterKey(ruleA); From 0cbe8299cd706e1a6ffd23c44676e5f9a224d4d3 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 08:23:20 -0500 Subject: [PATCH 13/18] fix: string-enum serialization + mutation-init + UI integration for rate notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. Enum serialization mismatch: RateNotificationSignal/Subject were bare int enums; API rejected frontend's string values ('Errors', 'Project') with 400. Fix: add JsonStringEnumConverter (STJ) + StringEnumConverter (Newtonsoft) to both enums, matching the StackStatus pattern used elsewhere in the codebase. API now returns and accepts string enum names. 2. Mutation created inside event handler: createRateNotificationRule/ updateRateNotificationRule were called inside handleSubmit(), which runs outside Svelte's synchronous component init context — TanStack Query context not available there. Fix: create both mutations at init with reactive getter props, call mutateAsync only inside the handler. Also: integrated RateNotificationRuleList + RateNotificationRuleForm into account/notifications page, fixed type errors in both components (Select.Root type='single', Alert variant, parseSeconds array safety). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/Enums/RateNotificationSignal.cs | 5 ++ .../Models/Enums/RateNotificationSubject.cs | 5 ++ .../rate-notification-rule-form.svelte | 40 ++++++++---- .../rate-notification-rule-list.svelte | 5 +- .../(app)/account/notifications/+page.svelte | 65 +++++++++++++++++++ 5 files changed, 105 insertions(+), 15 deletions(-) diff --git a/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs index e45a6f276f..2e3e9e620b 100644 --- a/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs @@ -1,5 +1,10 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json.Converters; + namespace Exceptionless.Core.Models; +[JsonConverter(typeof(JsonStringEnumConverter))] +[Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] public enum RateNotificationSignal { AllEvents = 0, diff --git a/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs b/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs index 44bca330a3..0e3f353c04 100644 --- a/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs @@ -1,5 +1,10 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json.Converters; + namespace Exceptionless.Core.Models; +[JsonConverter(typeof(JsonStringEnumConverter))] +[Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] public enum RateNotificationSubject { Project = 0, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte index 554deaad06..cca7f22d0c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte @@ -15,6 +15,7 @@ import { createRateNotificationRule, updateRateNotificationRule } from '$features/rate-notifications/api.svelte'; import { SIGNAL_LABELS, WINDOW_OPTIONS } from '$features/rate-notifications/types'; import type { RateNotificationSignal, RateNotificationSubject } from '$features/rate-notifications/types'; + // NOTE: mutations must be created at initialization, not inside event handlers (Svelte/TanStack context requirement) interface Props { hasPremiumFeatures?: boolean; @@ -47,10 +48,12 @@ const thresholdError = $derived(threshold < 1 ? 'Threshold must be at least 1.' : undefined); const stackIdError = $derived(subject === 'Stack' && !stackId.trim() ? 'Stack ID is required when subject is Stack.' : undefined); - // Check cooldown >= window function parseSeconds(iso: string): number { - const [h, m, s] = iso.split(':').map(Number); - return (h || 0) * 3600 + (m || 0) * 60 + (s || 0); + const parts = iso.split(':'); + const h = parseInt(parts[0] ?? '0', 10); + const m = parseInt(parts[1] ?? '0', 10); + const s = parseInt(parts[2] ?? '0', 10); + return h * 3600 + m * 60 + s; } const cooldownError = $derived( parseSeconds(cooldown) < parseSeconds(window) ? 'Cooldown must be at least as long as the window.' : undefined @@ -72,6 +75,21 @@ } }); + const createMutation = createRateNotificationRule({ + route: { + get projectId() { return projectId; }, + get userId() { return userId; } + } + }); + + const updateMutation = updateRateNotificationRule({ + route: { + get projectId() { return rule?.project_id; }, + get ruleId() { return rule?.id; }, + get userId() { return rule?.user_id; } + } + }); + async function handleSubmit() { if (hasErrors || saving) return; @@ -90,9 +108,7 @@ threshold, window }; - const updated = await updateRateNotificationRule({ - route: { projectId: rule.project_id, ruleId: rule.id, userId: rule.user_id } - }).mutateAsync(body); + const updated = await updateMutation.mutateAsync(body); toast.success('Rule updated.'); onSaved?.(updated); } else { @@ -106,7 +122,7 @@ threshold, window }; - const created = await createRateNotificationRule({ route: { projectId, userId } }).mutateAsync(body); + const created = await createMutation.mutateAsync(body); toast.success('Rule created.'); onSaved?.(created); } @@ -130,7 +146,7 @@ {/if} - + This rule may be noisy. Use a cooldown to avoid repeated emails. @@ -155,7 +171,7 @@
- + {SIGNAL_LABELS[signal]} @@ -170,7 +186,7 @@
- + {subject} @@ -220,7 +236,7 @@
- + {WINDOW_OPTIONS.find((o) => o.value === window)?.label ?? window} @@ -235,7 +251,7 @@
- + {WINDOW_OPTIONS.find((o) => o.value === cooldown)?.label ?? cooldown} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte index 917994e2a5..afee9f76d5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte @@ -93,11 +93,10 @@ } function formatWindow(isoWindow: string): string { - // Parse HH:MM:SS to friendly string const parts = isoWindow.split(':'); if (parts.length < 3) return isoWindow; - const h = parseInt(parts[0], 10); - const m = parseInt(parts[1], 10); + const h = parseInt(parts[0] ?? '0', 10); + const m = parseInt(parts[1] ?? '0', 10); if (h > 0) return `${h}h`; if (m === 1) return '1 min'; return `${m} min`; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte index 5c660266e7..c23b8fe355 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte @@ -1,14 +1,18 @@
@@ -201,5 +230,41 @@ {emailNotificationsEnabled} hasPremiumFeatures={selectedProject.has_premium_features} /> + +
+

Rate Notifications

+ Get notified when event rates for this project exceed your custom thresholds. +
+ + {/if}
+ + !open && closeRateRuleDialog()}> + + + {editingRateRule ? 'Edit Rate Notification Rule' : 'Create Rate Notification Rule'} + + {editingRateRule ? 'Update the rule settings below.' : 'Configure when you want to receive an email notification based on event rates.'} + + + {#if selectedProject} + + {/if} + + From 568f8fc3eef6cdb0d1a90f0b7e8883143a808bdf Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 31 May 2026 08:24:38 -0500 Subject: [PATCH 14/18] style: address bot feedback - combine nested ifs and use Where() in pipeline action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pipeline/075_UpdateRateCountersAction.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs index 75381a2afc..edbe7c4707 100644 --- a/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs +++ b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs @@ -81,17 +81,12 @@ public override async Task ProcessAsync(EventContext ctx) matchedSignals.Add(RateNotificationSignal.Regressions); // Increment counters for each matching rule - foreach (var rule in rules) + foreach (var rule in rules.Where(r => matchedSignals.Contains(r.Signal))) { - if (!matchedSignals.Contains(rule.Signal)) - continue; - // For Stack subject, only match if this event belongs to the rule's stack - if (rule.Subject == RateNotificationSubject.Stack) - { - if (String.IsNullOrEmpty(rule.StackId) || !String.Equals(ctx.Event.StackId, rule.StackId, StringComparison.Ordinal)) - continue; - } + if (rule.Subject == RateNotificationSubject.Stack && + (String.IsNullOrEmpty(rule.StackId) || !String.Equals(ctx.Event.StackId, rule.StackId, StringComparison.Ordinal))) + continue; string counterKey = BuildCounterKey(rule); await _counterService.IncrementAsync(counterKey); From 794ad11cb4ab750c76ac0161f6ec707922eff406 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 10:33:26 -0500 Subject: [PATCH 15/18] fix: harden rate notification runtime --- .gitignore | 1 - src/Exceptionless.Core/Bootstrapper.cs | 16 + .../Extensions/OrganizationExtensions.cs | 7 + src/Exceptionless.Core/Jobs/CleanupDataJob.cs | 2 + .../Jobs/RateNotificationEvaluatorJob.cs | 145 ++-- .../Jobs/RateNotificationsJob.cs | 62 +- .../Models/Enums/RateNotificationSignal.cs | 2 - .../Models/Enums/RateNotificationSubject.cs | 2 - .../Models/Queues/RateNotification.cs | 6 +- .../Pipeline/075_UpdateRateCountersAction.cs | 64 +- .../Indexes/RateNotificationRuleIndex.cs | 36 +- .../IRateNotificationRuleRepository.cs | 2 +- .../RateNotificationRuleRepository.cs | 19 +- .../Services/OrganizationService.cs | 23 +- .../Services/RateCounterService.cs | 57 +- .../Services/RateNotificationRuleCache.cs | 7 +- .../Utility/AppDiagnostics.cs | 8 + .../Controllers/OrganizationController.cs | 1 + .../Controllers/ProjectController.cs | 4 + .../RateNotificationRuleController.cs | 105 ++- .../Exceptionless.Web.csproj | 1 + .../Controllers/ContactControllerTests.cs | 5 + .../Controllers/Data/controller-manifest.json | 112 +++ .../Controllers/Data/openapi.json | 669 +++++++++++++++++- .../RateNotificationRuleControllerTests.cs | 161 ++++- .../Jobs/CleanupDataJobTests.cs | 2 +- .../Jobs/RateNotificationEvaluatorJobTests.cs | 27 +- .../Jobs/RateNotificationsJobTests.cs | 182 +++++ tests/Exceptionless.Tests/Mail/NullMailer.cs | 5 + .../Pipeline/UpdateRateCountersActionTests.cs | 76 ++ .../Repositories/OAuthTokenRepositoryTests.cs | 2 +- .../Models/OAuthTokenSerializerTests.cs | 2 +- .../Services/RateCounterServiceTests.cs | 71 +- tests/http/rate-notifications.http | 94 +++ 34 files changed, 1725 insertions(+), 253 deletions(-) create mode 100644 tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs create mode 100644 tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs create mode 100644 tests/http/rate-notifications.http diff --git a/.gitignore b/.gitignore index 06c59331ca..d1f62c2742 100644 --- a/.gitignore +++ b/.gitignore @@ -86,4 +86,3 @@ docs/_cache/ .devcontainer/devcontainer-lock.json *.lscache -.gstack/ diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index ea5bbd2fae..712b07636f 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -111,6 +111,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(s => CreateQueue(s)); services.TryAddEnumerable(ServiceDescriptor.Singleton, WorkItemDuplicateDetectionQueueBehavior>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, RateNotificationDuplicateDetectionQueueBehavior>()); services.AddSingleton(); services.AddSingleton(); @@ -124,6 +125,18 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO })); services.AddSingleton(s => s.GetRequiredService()); services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(s => new HybridCacheClient( + s.GetRequiredService(), + s.GetRequiredService(), + new InMemoryCacheClientOptions + { + CloneValues = true, + Serializer = s.GetRequiredService(), + TimeProvider = s.GetRequiredService(), + ResiliencePolicyProvider = s.GetRequiredService(), + LoggerFactory = s.GetRequiredService() + }, + s.GetRequiredService())); services.AddSingleton(s => new InMemoryFileStorage(new InMemoryFileStorageOptions { @@ -327,4 +340,7 @@ private static IQueue CreateQueue(IServiceProvider container, TimeSpan? wo private sealed class WorkItemDuplicateDetectionQueueBehavior(ICacheClient cacheClient, ILoggerFactory loggerFactory) : DuplicateDetectionQueueBehavior(cacheClient, loggerFactory, TimeSpan.FromHours(24)); + + private sealed class RateNotificationDuplicateDetectionQueueBehavior(ICacheClient cacheClient, ILoggerFactory loggerFactory) + : DuplicateDetectionQueueBehavior(cacheClient, loggerFactory, TimeSpan.FromHours(3)); } diff --git a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs index f296861efa..21b6831d42 100644 --- a/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs +++ b/src/Exceptionless.Core/Extensions/OrganizationExtensions.cs @@ -6,6 +6,13 @@ namespace Exceptionless.Core.Extensions; public static class OrganizationExtensions { + public const string RateNotificationsFeature = "rate-notifications"; + + public static bool HasRateNotifications(this Organization organization) + { + return organization.HasPremiumFeatures && organization.Features.Contains(RateNotificationsFeature); + } + public static Invite? GetInvite(this Organization organization, string token) { if (String.IsNullOrEmpty(token)) diff --git a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs index f212c67cf4..b08c87f78c 100644 --- a/src/Exceptionless.Core/Jobs/CleanupDataJob.cs +++ b/src/Exceptionless.Core/Jobs/CleanupDataJob.cs @@ -277,6 +277,7 @@ private async Task RemoveOrganizationAsync(Organization organization, JobContext await _organizationService.RemoveTokensAsync(organization); await _organizationService.RemoveWebHooksAsync(organization); await _organizationService.RemoveSavedViewsAsync(organization); + await _organizationService.RemoveRateNotificationRulesAsync(organization.Id); await _organizationService.CancelSubscriptionsAsync(organization); await _organizationService.RemoveUsersAsync(organization, null); @@ -331,6 +332,7 @@ private async Task RemoveProjectsAsync(Project project, JobContext context) _logger.RemoveProjectStart(project.Name, project.Id); await _tokenRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); await _webHookRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); + await _organizationService.RemoveProjectRateNotificationRulesAsync(project.OrganizationId, project.Id); await RenewLockAsync(context); long removedEvents = await _eventRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); diff --git a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs index 4f97835c30..c3f2faccca 100644 --- a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs +++ b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs @@ -1,8 +1,11 @@ +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.DateTimeExtensions; using Foundatio.Jobs; using Foundatio.Lock; using Foundatio.Queues; @@ -48,12 +51,15 @@ public RateNotificationEvaluatorJob( protected override async Task RunInternalAsync(JobContext context) { + using var evaluationTimer = AppDiagnostics.RateNotificationEvaluationTime.StartTimer(); var now = _timeProvider.GetUtcNow().UtcDateTime; - var scanFrom = now.Subtract(ScanWindow); - - // Round scanFrom down to minute boundary; stop 1 minute before now (current bucket may be incomplete) - var fromMinute = new DateTime(scanFrom.Year, scanFrom.Month, scanFrom.Day, scanFrom.Hour, scanFrom.Minute, 0, DateTimeKind.Utc); - var toMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc).AddMinutes(-1); + var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc); + var toMinute = currentMinute.AddMinutes(-1); + var lastEvaluatedMinute = await _counterService.GetLastEvaluatedMinuteAsync(context.CancellationToken); + var earliestRecoveryMinute = toMinute.Subtract(ScanWindow).AddMinutes(1); + var fromMinute = lastEvaluatedMinute.HasValue + ? new[] { lastEvaluatedMinute.Value.AddMinutes(1), earliestRecoveryMinute }.Max() + : earliestRecoveryMinute; if (fromMinute > toMinute) return JobResult.Success; @@ -69,86 +75,81 @@ protected override async Task RunInternalAsync(JobContext context) allCounterKeys.Add(key); } - if (allCounterKeys.Count == 0) - { - _logger.LogDebug("No active counter keys found in scan window"); - return JobResult.Success; - } + var counterKeysByProject = allCounterKeys + .Select(counterKey => (CounterKey: counterKey, ProjectId: ParseProjectIdFromCounterKey(counterKey))) + .Where(entry => entry.ProjectId is not null) + .GroupBy(entry => entry.ProjectId!, entry => entry.CounterKey) + .ToList(); + + AppDiagnostics.RateNotificationActiveCounterKeys.Record(allCounterKeys.Count); + AppDiagnostics.RateNotificationActiveProjects.Record(counterKeysByProject.Count); - // For each unique counter key, evaluate all matching rules - foreach (string counterKey in allCounterKeys) + foreach (var projectCounterKeys in counterKeysByProject) { if (context.CancellationToken.IsCancellationRequested) return JobResult.Cancelled; - await EvaluateCounterKeyAsync(counterKey, now, context.CancellationToken); + await EvaluateProjectAsync(projectCounterKeys.Key, projectCounterKeys, currentMinute, context.CancellationToken); } + await _counterService.SetLastEvaluatedMinuteAsync(toMinute, context.CancellationToken); _logger.LogInformation("Finished evaluating rate notification rules"); return JobResult.Success; } - private async Task EvaluateCounterKeyAsync(string counterKey, DateTime now, CancellationToken ct) + private async Task EvaluateProjectAsync(string projectId, IEnumerable counterKeys, DateTime evaluationEndUtc, CancellationToken ct) { - // Parse projectId from counter key to load rules - string? projectId = ParseProjectIdFromCounterKey(counterKey); - if (projectId is null) - { - _logger.LogWarning("Unable to parse projectId from counter key: {CounterKey}", counterKey); - return; - } - - // Load enabled rules for project matching this counter key var allProjectRules = await _ruleRepository.GetEnabledByProjectIdAsync(projectId, o => o.PageLimit(1000)); - - // Filter rules matching this counter key - var matchingRules = allProjectRules.Documents - .Where(r => CounterKeyMatchesRule(counterKey, r)) - .ToList(); - - if (matchingRules.Count == 0) + if (allProjectRules.Documents.Count == 0) return; - // Load org once per project for premium check - string? organizationId = matchingRules.First().OrganizationId; - var org = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); - if (org is null) + string organizationId = allProjectRules.Documents.First().OrganizationId; + var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); + if (organization is null || !organization.HasRateNotifications()) return; - if (!org.HasPremiumFeatures) - return; + var rulesByCounterKey = allProjectRules.Documents + .GroupBy(UpdateRateCountersAction.BuildCounterKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal); - foreach (var rule in matchingRules) + foreach (string counterKey in counterKeys) { - if (ct.IsCancellationRequested) - return; + if (!rulesByCounterKey.TryGetValue(counterKey, out var matchingRules)) + continue; - await EvaluateRuleAsync(rule, counterKey, now, ct); + foreach (var rule in matchingRules) + { + if (ct.IsCancellationRequested) + return; + + await EvaluateRuleAsync(rule, counterKey, evaluationEndUtc, ct); + } } } - private async Task EvaluateRuleAsync(RateNotificationRule rule, string counterKey, DateTime now, CancellationToken ct) + private async Task EvaluateRuleAsync(RateNotificationRule rule, string counterKey, DateTime evaluationEndUtc, CancellationToken ct) { if (!rule.IsEnabled) return; // Skip if actively snoozed - if (rule.SnoozedUntilUtc.HasValue && rule.SnoozedUntilUtc.Value > now) + if (rule.SnoozedUntilUtc.HasValue && rule.SnoozedUntilUtc.Value > evaluationEndUtc) { _logger.LogDebug("Skipping snoozed rule {RuleId} snoozed until {SnoozedUntil}", rule.Id, rule.SnoozedUntilUtc); return; } - var windowStartUtc = now.Subtract(rule.Window); + var windowStartUtc = evaluationEndUtc.Subtract(rule.Window); // SNOOZE BACK-ALERT FIX: // If the rule was recently un-snoozed (SnoozedUntilUtc is set and in the past), use that as the // effective window start to ignore traffic that occurred during the snooze period. - var effectiveWindowStartUtc = rule.SnoozedUntilUtc.HasValue && rule.SnoozedUntilUtc.Value > windowStartUtc - ? rule.SnoozedUntilUtc.Value + var snoozeBoundaryUtc = rule.SnoozedUntilUtc?.Ceiling(TimeSpan.FromMinutes(1)); + var effectiveWindowStartUtc = snoozeBoundaryUtc.HasValue && snoozeBoundaryUtc.Value > windowStartUtc + ? snoozeBoundaryUtc.Value : windowStartUtc; - var observedCount = await _counterService.SumBucketsAsync(counterKey, effectiveWindowStartUtc, now, ct); + var observedCount = await _counterService.SumBucketsAsync(counterKey, effectiveWindowStartUtc, evaluationEndUtc, ct); if (observedCount < rule.Threshold) { @@ -159,34 +160,39 @@ private async Task EvaluateRuleAsync(RateNotificationRule rule, string counterKe // Build subject key (for cooldown scoping) string subjectKey = BuildSubjectKey(rule); - // Check cooldown - if (await _counterService.IsOnCooldownAsync(rule.Id, subjectKey, ct)) + // Atomically claim the cooldown before enqueueing so overlapping evaluators cannot duplicate alerts. + if (!await _counterService.TrySetCooldownAsync(rule.Id, subjectKey, rule.Cooldown, ct)) { _logger.LogDebug("Rule {RuleId} is on cooldown for subject {SubjectKey}", rule.Id, subjectKey); return; } - // Enqueue notification - await _notificationQueue.EnqueueAsync(new RateNotification + try + { + await _notificationQueue.EnqueueAsync(new RateNotification + { + RuleId = rule.Id, + RuleVersion = rule.Version, + OrganizationId = rule.OrganizationId, + ProjectId = rule.ProjectId, + UserId = rule.UserId, + SubjectKey = subjectKey, + StackId = rule.StackId, + WindowStartUtc = effectiveWindowStartUtc, + WindowEndUtc = evaluationEndUtc, + ObservedCount = observedCount, + Threshold = rule.Threshold + }); + AppDiagnostics.RateNotificationsEnqueued.Add(1); + } + catch { - RuleId = rule.Id, - RuleVersion = rule.Version, - OrganizationId = rule.OrganizationId, - ProjectId = rule.ProjectId, - UserId = rule.UserId, - SubjectKey = subjectKey, - StackId = rule.StackId, - WindowStartUtc = effectiveWindowStartUtc, - WindowEndUtc = now, - ObservedCount = observedCount, - Threshold = rule.Threshold - }); - - // Set cooldown - await _counterService.SetCooldownAsync(rule.Id, subjectKey, rule.Cooldown, ct); + await _counterService.RemoveCooldownAsync(rule.Id, subjectKey, ct); + throw; + } // Update LastFiredUtc - rule.LastFiredUtc = now; + rule.LastFiredUtc = evaluationEndUtc; await _ruleRepository.SaveAsync(rule); _logger.LogInformation("Rate notification fired: rule={RuleId} project={ProjectId} observed={Observed} threshold={Threshold}", @@ -208,13 +214,6 @@ await _notificationQueue.EnqueueAsync(new RateNotification return counterKey[start..end]; } - /// Returns true if the counter key was generated by the given rule. - private static bool CounterKeyMatchesRule(string counterKey, RateNotificationRule rule) - { - string expected = UpdateRateCountersAction.BuildCounterKey(rule); - return String.Equals(counterKey, expected, StringComparison.Ordinal); - } - private static string BuildSubjectKey(RateNotificationRule rule) { return rule.Subject == RateNotificationSubject.Stack && !String.IsNullOrEmpty(rule.StackId) diff --git a/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs index 253ec202de..03cd8792b9 100644 --- a/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs +++ b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs @@ -1,7 +1,9 @@ +using Exceptionless.Core.Extensions; using Exceptionless.Core.Mail; using Exceptionless.Core.Models; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; using Foundatio.Jobs; using Foundatio.Queues; using Foundatio.Repositories; @@ -15,6 +17,7 @@ public class RateNotificationsJob : QueueJobBase { private readonly IMailer _mailer; private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly IUserRepository _userRepository; private readonly IStackRepository _stackRepository; @@ -23,6 +26,7 @@ public RateNotificationsJob( IQueue queue, IMailer mailer, IRateNotificationRuleRepository ruleRepository, + IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IUserRepository userRepository, IStackRepository stackRepository, @@ -32,6 +36,7 @@ public RateNotificationsJob( { _mailer = mailer; _ruleRepository = ruleRepository; + _organizationRepository = organizationRepository; _projectRepository = projectRepository; _userRepository = userRepository; _stackRepository = stackRepository; @@ -44,46 +49,63 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex // Load rule var rule = await _ruleRepository.GetByIdAsync(wi.RuleId); if (rule is null) - return JobResult.SuccessWithMessage($"Rate notification rule {wi.RuleId} not found; skipping."); + return Skip($"Rate notification rule {wi.RuleId} not found; skipping."); if (!rule.IsEnabled) - return JobResult.SuccessWithMessage($"Rule {wi.RuleId} is disabled; skipping."); + return Skip($"Rule {wi.RuleId} is disabled; skipping."); + + string expectedSubjectKey = rule.Subject == RateNotificationSubject.Stack + ? $"stack:{rule.StackId}" + : $"project:{rule.ProjectId}"; + if (!String.Equals(rule.OrganizationId, wi.OrganizationId, StringComparison.Ordinal) || + !String.Equals(rule.ProjectId, wi.ProjectId, StringComparison.Ordinal) || + !String.Equals(rule.UserId, wi.UserId, StringComparison.Ordinal) || + !String.Equals(rule.StackId, wi.StackId, StringComparison.Ordinal) || + !String.Equals(expectedSubjectKey, wi.SubjectKey, StringComparison.Ordinal) || + wi.Threshold != rule.Threshold || wi.ObservedCount < 0 || wi.WindowStartUtc >= wi.WindowEndUtc) + { + _logger.LogWarning("Rate notification payload does not match rule {RuleId}; skipping", wi.RuleId); + return Skip($"Rate notification payload does not match rule {wi.RuleId}; skipping."); + } // Version check — rule was mutated after enqueueing if (rule.Version != wi.RuleVersion) { _logger.LogInformation("Rule {RuleId} version mismatch: expected {Expected}, found {Actual}; skipping stale notification", wi.RuleId, wi.RuleVersion, rule.Version); - return JobResult.Success; + return Skip($"Rate notification rule {wi.RuleId} changed after enqueue; skipping."); } - // Load project + var organization = await _organizationRepository.GetByIdAsync(rule.OrganizationId, o => o.Cache()); + if (organization is null || !organization.HasRateNotifications()) + return Skip($"Organization {rule.OrganizationId} cannot receive rate notifications; skipping."); + var project = await _projectRepository.GetByIdAsync(rule.ProjectId, o => o.Cache()); - if (project is null) - return JobResult.SuccessWithMessage($"Project {rule.ProjectId} not found; skipping."); + if (project is null || !String.Equals(project.OrganizationId, rule.OrganizationId, StringComparison.Ordinal)) + return Skip($"Project {rule.ProjectId} not found; skipping."); // Load user var user = await _userRepository.GetByIdAsync(rule.UserId, o => o.Cache()); if (user is null) - return JobResult.SuccessWithMessage($"User {rule.UserId} not found; skipping."); + return Skip($"User {rule.UserId} not found; skipping."); - // User must still be a member of the org + // User must still be a member of the organization if (!user.OrganizationIds.Contains(rule.OrganizationId)) { - _logger.LogInformation("User {UserId} is no longer a member of org {OrgId}; skipping rate notification", rule.UserId, rule.OrganizationId); - return JobResult.Success; + _logger.LogInformation("User {UserId} is no longer a member of organization {OrganizationId}; skipping rate notification", rule.UserId, rule.OrganizationId); + return Skip($"User {rule.UserId} is no longer a member of organization {rule.OrganizationId}; skipping."); } if (!user.IsEmailAddressVerified) { _logger.LogInformation("User {UserId} email not verified; skipping rate notification", rule.UserId); - return JobResult.Success; + return Skip($"User {rule.UserId} email is not verified; skipping."); } if (!user.EmailNotificationsEnabled) { _logger.LogInformation("User {UserId} has email notifications disabled; skipping rate notification", rule.UserId); - return JobResult.Success; + return Skip($"User {rule.UserId} disabled email notifications; skipping."); } // Load stack if this is a stack-scoped rule @@ -91,15 +113,27 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (rule.Subject == RateNotificationSubject.Stack && !String.IsNullOrEmpty(rule.StackId)) { stack = await _stackRepository.GetByIdAsync(rule.StackId, o => o.Cache()); - if (stack is null) - _logger.LogWarning("Stack {StackId} not found for rate notification rule {RuleId}", rule.StackId, rule.Id); + if (stack is null || + !String.Equals(stack.ProjectId, rule.ProjectId, StringComparison.Ordinal) || + !String.Equals(stack.OrganizationId, rule.OrganizationId, StringComparison.Ordinal) || + !stack.AllowNotifications) + { + return Skip($"Stack {rule.StackId} cannot receive rate notifications; skipping."); + } } await _mailer.SendRateNotificationAsync(user, project, rule, wi.ObservedCount, wi.WindowStartUtc, wi.WindowEndUtc, stack); + AppDiagnostics.RateNotificationsSent.Add(1); _logger.LogInformation("Sent rate notification email: rule={RuleId} user={UserId} project={ProjectId} observed={Observed}", rule.Id, rule.UserId, rule.ProjectId, wi.ObservedCount); return JobResult.Success; } + + private static JobResult Skip(string message) + { + AppDiagnostics.RateNotificationsSkipped.Add(1); + return JobResult.SuccessWithMessage(message); + } } diff --git a/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs index 2e3e9e620b..f9cab7e722 100644 --- a/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs @@ -1,10 +1,8 @@ using System.Text.Json.Serialization; -using Newtonsoft.Json.Converters; namespace Exceptionless.Core.Models; [JsonConverter(typeof(JsonStringEnumConverter))] -[Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] public enum RateNotificationSignal { AllEvents = 0, diff --git a/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs b/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs index 0e3f353c04..4ad179a98f 100644 --- a/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs @@ -1,10 +1,8 @@ using System.Text.Json.Serialization; -using Newtonsoft.Json.Converters; namespace Exceptionless.Core.Models; [JsonConverter(typeof(JsonStringEnumConverter))] -[Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] public enum RateNotificationSubject { Project = 0, diff --git a/src/Exceptionless.Core/Models/Queues/RateNotification.cs b/src/Exceptionless.Core/Models/Queues/RateNotification.cs index 55e81254ef..0658bb51a9 100644 --- a/src/Exceptionless.Core/Models/Queues/RateNotification.cs +++ b/src/Exceptionless.Core/Models/Queues/RateNotification.cs @@ -1,6 +1,8 @@ +using Foundatio.Queues; + namespace Exceptionless.Core.Queues.Models; -public class RateNotification +public class RateNotification : IHaveUniqueIdentifier { public required string RuleId { get; set; } public required int RuleVersion { get; set; } @@ -13,4 +15,6 @@ public class RateNotification public required DateTime WindowEndUtc { get; set; } public required long ObservedCount { get; set; } public required int Threshold { get; set; } + + public string UniqueIdentifier => $"RateNotification:{RuleId}:{RuleVersion}:{WindowEndUtc.Ticks}"; } diff --git a/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs index edbe7c4707..8d4de69ca6 100644 --- a/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs +++ b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs @@ -1,10 +1,10 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Plugins.EventProcessor; using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Microsoft.Extensions.Logging; -using System.Text.Json; namespace Exceptionless.Core.Pipeline; @@ -13,28 +13,22 @@ public class UpdateRateCountersAction : EventPipelineActionBase { private readonly RateNotificationRuleCache _ruleCache; private readonly RateCounterService _counterService; - private readonly UserAgentParser _parser; - private readonly JsonSerializerOptions _jsonOptions; public UpdateRateCountersAction( RateNotificationRuleCache ruleCache, RateCounterService counterService, - UserAgentParser parser, - JsonSerializerOptions jsonOptions, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _ruleCache = ruleCache; _counterService = counterService; - _parser = parser; - _jsonOptions = jsonOptions; ContinueOnError = true; } public override async Task ProcessAsync(EventContext ctx) { // Premium gate — rate notifications require premium features - if (!ctx.Organization.HasPremiumFeatures) + if (!ctx.Organization.HasRateNotifications()) return; // Stack must allow notifications @@ -46,26 +40,22 @@ public override async Task ProcessAsync(EventContext ctx) if (rules.Count == 0) return; - // Bot check — same pattern as EventNotificationsJob - var request = ctx.Event.GetRequestInfo(_jsonOptions); - if (!String.IsNullOrEmpty(request?.UserAgent)) - { - var botPatterns = ctx.Project.Configuration.Settings - .GetStringCollection(SettingsDictionary.KnownKeys.UserAgentBotPatterns).ToList(); - var info = await _parser.ParseAsync(request.UserAgent); - if (info is not null && info.Device.IsSpider || request.UserAgent.AnyWildcardMatches(botPatterns)) - { - _logger.LogTrace("Skipping rate counter update for bot user agent: {UserAgent}", request.UserAgent); - return; - } - } - - // Build the set of signals matched by this event - bool isError = ctx.Event.IsError(); - bool isCritical = ctx.Event.IsCritical(); + // RequestInfoPlugin already performs the user-agent work earlier in the pipeline. + if (ctx.Event.Data?.GetValueOrDefault(Event.KnownDataKeys.RequestInfo) is RequestInfo request && + request.Data?.GetValueOrDefault(RequestInfo.KnownDataKeys.IsBot) is true) + return; + + var counterKeys = GetCounterKeys(ctx.Event, ctx.IsNew, ctx.IsRegression, rules); + AppDiagnostics.RateCounterKeysIncremented.Add(counterKeys.Count); + await Task.WhenAll(counterKeys.Select(key => _counterService.IncrementAsync(key))); + } + + internal static IReadOnlyCollection GetCounterKeys(PersistentEvent ev, bool isNew, bool isRegression, IEnumerable rules) + { + bool isError = ev.IsError(); + bool isCritical = ev.IsCritical(); var matchedSignals = new HashSet(); - // AllEvents always matches matchedSignals.Add(RateNotificationSignal.AllEvents); if (isError) @@ -74,23 +64,19 @@ public override async Task ProcessAsync(EventContext ctx) if (isError && isCritical) matchedSignals.Add(RateNotificationSignal.CriticalErrors); - if (ctx.IsNew && isError) + if (isNew && isError) matchedSignals.Add(RateNotificationSignal.NewErrors); - if (ctx.IsRegression) + if (isRegression) matchedSignals.Add(RateNotificationSignal.Regressions); - // Increment counters for each matching rule - foreach (var rule in rules.Where(r => matchedSignals.Contains(r.Signal))) - { - // For Stack subject, only match if this event belongs to the rule's stack - if (rule.Subject == RateNotificationSubject.Stack && - (String.IsNullOrEmpty(rule.StackId) || !String.Equals(ctx.Event.StackId, rule.StackId, StringComparison.Ordinal))) - continue; - - string counterKey = BuildCounterKey(rule); - await _counterService.IncrementAsync(counterKey); - } + return rules + .Where(r => matchedSignals.Contains(r.Signal)) + .Where(r => r.Subject == RateNotificationSubject.Project || + !String.IsNullOrEmpty(r.StackId) && String.Equals(ev.StackId, r.StackId, StringComparison.Ordinal)) + .Select(BuildCounterKey) + .Distinct(StringComparer.Ordinal) + .ToList(); } internal static string BuildCounterKey(RateNotificationRule rule) diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs index aab51d9184..52dda63b19 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs @@ -1,7 +1,9 @@ +using Elastic.Clients.Elasticsearch.IndexManagement; +using Elastic.Clients.Elasticsearch.Mapping; using Exceptionless.Core.Models; +using Foundatio.Parsers.ElasticQueries.Extensions; using Foundatio.Repositories.Elasticsearch.Configuration; using Foundatio.Repositories.Elasticsearch.Extensions; -using Nest; namespace Exceptionless.Core.Repositories.Configuration; @@ -15,29 +17,29 @@ public RateNotificationRuleIndex(ExceptionlessElasticConfiguration configuration _configuration = configuration; } - public override TypeMappingDescriptor ConfigureIndexMapping(TypeMappingDescriptor map) + public override void ConfigureIndexMapping(TypeMappingDescriptor map) { - return map - .Dynamic(false) + map + .Dynamic(DynamicMapping.False) .Properties(p => p .SetupDefaults() - .Keyword(f => f.Name(e => e.OrganizationId)) - .Keyword(f => f.Name(e => e.ProjectId)) - .Keyword(f => f.Name(e => e.UserId)) - .Keyword(f => f.Name(e => e.StackId)) - .Keyword(f => f.Name(e => e.Signal)) - .Keyword(f => f.Name(e => e.Subject)) - .Boolean(f => f.Name(e => e.IsEnabled)) - .Boolean(f => f.Name(e => e.IsDeleted)) - .Text(f => f.Name(e => e.Name).AddKeywordField()) - ); + .Keyword(e => e.OrganizationId) + .Keyword(e => e.ProjectId) + .Keyword(e => e.UserId) + .Keyword(e => e.StackId) + .Keyword(e => e.Signal) + .Keyword(e => e.Subject) + .Boolean(e => e.IsEnabled) + .Boolean(e => e.IsDeleted) + .Text(e => e.Name, t => t.AddKeywordField())); } - public override CreateIndexDescriptor ConfigureIndex(CreateIndexDescriptor idx) + public override void ConfigureIndex(CreateIndexRequestDescriptor idx) { - return base.ConfigureIndex(idx.Settings(s => s + base.ConfigureIndex(idx); + idx.Settings(s => s .NumberOfShards(_configuration.Options.NumberOfShards) .NumberOfReplicas(_configuration.Options.NumberOfReplicas) - .Priority(5))); + .Priority(5)); } } diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs index f24ef6961f..bf7dd36e23 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs @@ -9,5 +9,5 @@ public interface IRateNotificationRuleRepository : IRepositoryOwnedByOrganizatio Task> GetByProjectIdAndUserIdAsync(string projectId, string userId, CommandOptionsDescriptor? options = null); Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null); Task CountByProjectIdAndUserIdAsync(string projectId, string userId); - Task RemoveByProjectIdAndUserIdAsync(string projectId, string userId); + Task RemoveAllByOrganizationIdAndUserIdAsync(string organizationId, string userId); } diff --git a/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs index 91998a0cdf..6caae70608 100644 --- a/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs +++ b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs @@ -3,7 +3,6 @@ using Exceptionless.Core.Validation; using Foundatio.Repositories; using Foundatio.Repositories.Models; -using Nest; namespace Exceptionless.Core.Repositories; @@ -22,7 +21,7 @@ public Task> GetByProjectIdAndUserIdAsync(stri return FindAsync(q => q .Project(projectId) .FieldEquals(r => r.UserId, userId) - .SortAscending(r => r.Name.Suffix("keyword")), options); + .SortAscending(r => r.Name), options); } public Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null) @@ -45,19 +44,13 @@ public async Task CountByProjectIdAndUserIdAsync(string projectId, string .FieldEquals(r => r.UserId, userId)); } - public async Task RemoveByProjectIdAndUserIdAsync(string projectId, string userId) + public Task RemoveAllByOrganizationIdAndUserIdAsync(string organizationId, string userId) { - ArgumentException.ThrowIfNullOrEmpty(projectId); + ArgumentException.ThrowIfNullOrEmpty(organizationId); ArgumentException.ThrowIfNullOrEmpty(userId); - var results = await FindAsync(q => q - .Project(projectId) - .FieldEquals(r => r.UserId, userId), o => o.PageLimit(1000)); - - if (results.Total is 0) - return 0; - - await RemoveAsync(results.Documents); - return results.Total; + return RemoveAllAsync(q => q + .Organization(organizationId) + .FieldEquals(r => r.UserId, userId)); } } diff --git a/src/Exceptionless.Core/Services/OrganizationService.cs b/src/Exceptionless.Core/Services/OrganizationService.cs index 5413a5cfe7..5b382bfe6d 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -14,6 +14,7 @@ public class OrganizationService : IStartupAction private const int BATCH_SIZE = 50; private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IRateNotificationRuleRepository _rateNotificationRuleRepository; private readonly ISavedViewRepository _savedViewRepository; private readonly ITokenRepository _tokenRepository; private readonly IUserRepository _userRepository; @@ -22,10 +23,11 @@ public class OrganizationService : IStartupAction private readonly UsageService _usageService; private readonly ILogger _logger; - public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, IStripeBillingClient stripeBillingClient, UsageService usageService, ILoggerFactory loggerFactory) + public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IRateNotificationRuleRepository rateNotificationRuleRepository, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, IStripeBillingClient stripeBillingClient, UsageService usageService, ILoggerFactory loggerFactory) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; + _rateNotificationRuleRepository = rateNotificationRuleRepository; _savedViewRepository = savedViewRepository; _tokenRepository = tokenRepository; _userRepository = userRepository; @@ -193,6 +195,24 @@ public Task RemoveUserSavedViewsAsync(string organizationId, string userId return _savedViewRepository.RemovePrivateByUserIdAsync(organizationId, userId); } + public Task RemoveRateNotificationRulesAsync(string organizationId) + { + _logger.LogDebug("Removing rate notification rules for organization {OrganizationId}", organizationId); + return _rateNotificationRuleRepository.RemoveAllByOrganizationIdAsync(organizationId); + } + + public Task RemoveProjectRateNotificationRulesAsync(string organizationId, string projectId) + { + _logger.LogDebug("Removing rate notification rules for project {ProjectId} in organization {OrganizationId}", projectId, organizationId); + return _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(organizationId, projectId); + } + + public Task RemoveUserRateNotificationRulesAsync(string organizationId, string userId) + { + _logger.LogDebug("Removing rate notification rules for user {UserId} in organization {OrganizationId}", userId, organizationId); + return _rateNotificationRuleRepository.RemoveAllByOrganizationIdAndUserIdAsync(organizationId, userId); + } + public async Task SoftDeleteOrganizationAsync(Organization organization, string currentUserId) { if (organization.IsDeleted) @@ -201,6 +221,7 @@ public async Task SoftDeleteOrganizationAsync(Organization organization, string await RemoveTokensAsync(organization); await RemoveWebHooksAsync(organization); await RemoveSavedViewsAsync(organization); + await RemoveRateNotificationRulesAsync(organization.Id); await CancelSubscriptionsAsync(organization); await RemoveUsersAsync(organization, currentUserId); await CleanupProjectNotificationSettingsAsync(organization, []); diff --git a/src/Exceptionless.Core/Services/RateCounterService.cs b/src/Exceptionless.Core/Services/RateCounterService.cs index 5294864e2e..b48023eb31 100644 --- a/src/Exceptionless.Core/Services/RateCounterService.cs +++ b/src/Exceptionless.Core/Services/RateCounterService.cs @@ -1,5 +1,4 @@ using Foundatio.Caching; -using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Services; @@ -8,21 +7,21 @@ namespace Exceptionless.Core.Services; /// Keys: /// rate:v1:count:{epochMinute}:{counterKey} — TTL 3h /// rate:v1:active:{epochMinute} — TTL 3h (list of counter keys) -/// rate:v1:cooldown:{ruleId}:{subjectKey} — TTL = cooldown + 10min +/// rate:v1:cooldown:{ruleId}:{subjectKey} — TTL = configured cooldown +/// rate:v1:evaluator:last-minute — last completed evaluation minute /// public class RateCounterService { private readonly ICacheClient _cache; private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; private static readonly TimeSpan BucketTtl = TimeSpan.FromHours(3); + private const string LastEvaluatedMinuteKey = "rate:v1:evaluator:last-minute"; - public RateCounterService(ICacheClient cache, TimeProvider timeProvider, ILoggerFactory loggerFactory) + public RateCounterService(ICacheClient cache, TimeProvider timeProvider) { _cache = cache; _timeProvider = timeProvider; - _logger = loggerFactory.CreateLogger(); } /// Increments the 1-minute bucket counter for the given counter key at the current UTC minute. @@ -38,22 +37,22 @@ public async Task IncrementAsync(string counterKey, CancellationToken ct = defau await _cache.ListAddAsync(activeKey, counterKey, BucketTtl); } - /// Sums all 1-minute bucket counts for the given counter key in the range [fromUtc, toUtc]. + /// Sums all 1-minute bucket counts for the given counter key in the range [fromUtc, toUtc). public async Task SumBucketsAsync(string counterKey, DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + long fromMinute = GetEpochMinute(fromUtc); long toMinute = GetEpochMinute(toUtc); + if (fromMinute >= toMinute) + return 0; - long total = 0; - for (long minute = fromMinute; minute <= toMinute; minute++) - { - string key = GetCountKey(minute, counterKey); - var value = await _cache.GetAsync(key); - if (value.HasValue) - total += value.Value; - } + var keys = Enumerable.Range(0, checked((int)(toMinute - fromMinute))) + .Select(offset => GetCountKey(fromMinute + offset, counterKey)) + .ToList(); + var values = await _cache.GetAllAsync(keys); - return total; + return values.Values.Where(value => value.HasValue).Sum(value => value.Value); } /// Returns all counter keys that were active during the given minute. @@ -76,12 +75,30 @@ public Task IsOnCooldownAsync(string ruleId, string subjectKey, Cancellati return _cache.ExistsAsync(key); } - /// Sets a cooldown for the given rule/subject combination. - public Task SetCooldownAsync(string ruleId, string subjectKey, TimeSpan duration, CancellationToken ct = default) + /// Atomically claims the cooldown for a rule/subject combination. + public Task TrySetCooldownAsync(string ruleId, string subjectKey, TimeSpan duration, CancellationToken ct = default) { - string key = GetCooldownKey(ruleId, subjectKey); - // TTL = duration + 10 minutes buffer - return _cache.SetAsync(key, true, duration.Add(TimeSpan.FromMinutes(10))); + ct.ThrowIfCancellationRequested(); + return _cache.AddAsync(GetCooldownKey(ruleId, subjectKey), true, duration); + } + + public Task RemoveCooldownAsync(string ruleId, string subjectKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return _cache.RemoveAsync(GetCooldownKey(ruleId, subjectKey)); + } + + public async Task GetLastEvaluatedMinuteAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var value = await _cache.GetAsync(LastEvaluatedMinuteKey); + return value.HasValue ? DateTime.UnixEpoch.AddMinutes(value.Value) : null; + } + + public Task SetLastEvaluatedMinuteAsync(DateTime minute, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return _cache.SetAsync(LastEvaluatedMinuteKey, GetEpochMinute(minute), BucketTtl); } // ---- Key helpers ---- diff --git a/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs index 777b754ac7..2f30c86ee3 100644 --- a/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs +++ b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs @@ -3,7 +3,6 @@ using Foundatio.Caching; using Foundatio.Repositories; using Foundatio.Repositories.Models; -using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Services; @@ -14,16 +13,14 @@ namespace Exceptionless.Core.Services; public class RateNotificationRuleCache { private readonly IRateNotificationRuleRepository _repository; - private readonly ICacheClient _cache; - private readonly ILogger _logger; + private readonly IHybridCacheClient _cache; private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); - public RateNotificationRuleCache(IRateNotificationRuleRepository repository, ICacheClient cache, ILoggerFactory loggerFactory) + public RateNotificationRuleCache(IRateNotificationRuleRepository repository, IHybridCacheClient cache) { _repository = repository; _cache = cache; - _logger = loggerFactory.CreateLogger(); } /// Returns all enabled, non-deleted rules for the given project (cached). diff --git a/src/Exceptionless.Core/Utility/AppDiagnostics.cs b/src/Exceptionless.Core/Utility/AppDiagnostics.cs index c081925b6c..6543fa61f9 100644 --- a/src/Exceptionless.Core/Utility/AppDiagnostics.cs +++ b/src/Exceptionless.Core/Utility/AppDiagnostics.cs @@ -125,6 +125,14 @@ public GaugeInfo(Meter meter, string name) internal static readonly Counter SavedViewsSize = Meter.CreateCounter("ex.savedviews.size", description: "Size of user saved views"); internal static readonly Counter SavedViewsViewTypeSize = Meter.CreateCounter("ex.savedviews.viewtype.size", description: "Size of user saved views by view type"); + + internal static readonly Counter RateCounterKeysIncremented = Meter.CreateCounter("ex.rate_notifications.counter_keys.incremented", description: "Distinct rate notification counter keys incremented"); + internal static readonly Histogram RateNotificationActiveCounterKeys = Meter.CreateHistogram("ex.rate_notifications.active_counter_keys", description: "Active rate notification counter keys per evaluation"); + internal static readonly Histogram RateNotificationActiveProjects = Meter.CreateHistogram("ex.rate_notifications.active_projects", description: "Active rate notification projects per evaluation"); + internal static readonly Histogram RateNotificationEvaluationTime = Meter.CreateHistogram("ex.rate_notifications.evaluation_time", description: "Time to evaluate rate notification rules", unit: "ms"); + internal static readonly Counter RateNotificationsEnqueued = Meter.CreateCounter("ex.rate_notifications.enqueued", description: "Rate notifications enqueued"); + internal static readonly Counter RateNotificationsSent = Meter.CreateCounter("ex.rate_notifications.sent", description: "Rate notifications sent"); + internal static readonly Counter RateNotificationsSkipped = Meter.CreateCounter("ex.rate_notifications.skipped", description: "Rate notifications skipped during delivery"); } public static class MetricsClientExtensions diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index b29f0cd516..15a357737a 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -797,6 +797,7 @@ public async Task RemoveUserAsync(string id, string email) await _organizationService.CleanupProjectNotificationSettingsAsync(organization, [user.Id]); await _organizationService.RemoveUserSavedViewsAsync(organization.Id, user.Id); + await _organizationService.RemoveUserRateNotificationRulesAsync(organization.Id, user.Id); user.OrganizationIds.Remove(organization.Id); await _userRepository.SaveAsync(user, o => o.Cache()); diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs index 6bf7f4c0f3..de1e2db24e 100644 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ b/src/Exceptionless.Web/Controllers/ProjectController.cs @@ -33,6 +33,7 @@ public class ProjectController : RepositoryApiController _workItemQueue; private readonly BillingManager _billingManager; private readonly SlackService _slackService; @@ -48,6 +49,7 @@ public ProjectController( IStackRepository stackRepository, IEventRepository eventRepository, ITokenRepository tokenRepository, + IRateNotificationRuleRepository rateNotificationRuleRepository, IQueue workItemQueue, BillingManager billingManager, SlackService slackService, @@ -67,6 +69,7 @@ ILoggerFactory loggerFactory _stackRepository = stackRepository; _eventRepository = eventRepository; _tokenRepository = tokenRepository; + _rateNotificationRuleRepository = rateNotificationRuleRepository; _workItemQueue = workItemQueue; _billingManager = billingManager; _slackService = slackService; @@ -222,6 +225,7 @@ protected override async Task> DeleteModelsAsync(ICollection _logger.UserDeletingProject(user.Id, project.Name); await _tokenRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); + await _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); } return await base.DeleteModelsAsync(projects); diff --git a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs index c4bf00aa0d..f8dfc196e9 100644 --- a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs +++ b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; using Exceptionless.Core.Repositories; @@ -6,9 +7,10 @@ using Exceptionless.Web.Controllers; using Exceptionless.Web.Extensions; using Exceptionless.Web.Models; +using Foundatio.Lock; +using Foundatio.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Foundatio.Repositories; namespace Exceptionless.App.Controllers.API; @@ -34,23 +36,29 @@ public class RateNotificationRuleController : ExceptionlessApiController private readonly IRateNotificationRuleRepository _ruleRepository; private readonly IProjectRepository _projectRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IUserRepository _userRepository; private readonly IStackRepository _stackRepository; private readonly RateNotificationRuleCache _ruleCache; + private readonly ILockProvider _lockProvider; public RateNotificationRuleController( IRateNotificationRuleRepository ruleRepository, IProjectRepository projectRepository, IOrganizationRepository organizationRepository, + IUserRepository userRepository, IStackRepository stackRepository, RateNotificationRuleCache ruleCache, + ILockProvider lockProvider, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider) { _ruleRepository = ruleRepository; _projectRepository = projectRepository; _organizationRepository = organizationRepository; + _userRepository = userRepository; _stackRepository = stackRepository; _ruleCache = ruleCache; + _lockProvider = lockProvider; } /// Get all rate notification rules for a user/project. @@ -64,7 +72,7 @@ public async Task>> G if (!CanManage(userId)) return NotFound(); - var project = await GetProjectAndCheckAccessAsync(projectId); + var project = await GetProjectAndCheckAccessAsync(projectId, userId); if (project is null) return NotFound(); @@ -88,7 +96,7 @@ public async Task> PostAsync( if (!CanManage(userId)) return NotFound(); - var project = await GetProjectAndCheckAccessAsync(projectId); + var project = await GetProjectAndCheckAccessAsync(projectId, userId); if (project is null) return NotFound(); @@ -115,17 +123,20 @@ public async Task> PostAsync( return ValidationProblem(detail: "StackId must be empty when Subject is Project."); } - // Enforce max rules limit + var organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + bool isEnabled = model.IsEnabled && organization?.HasRateNotifications() == true; + + await using var createLock = await _lockProvider.TryAcquireAsync( + $"rate-notification:create:{projectId}:{userId}", + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(5)); + if (createLock is null) + return Conflict(new ProblemDetails { Detail = "Another rate notification rule is being created. Please retry." }); + long count = await _ruleRepository.CountByProjectIdAndUserIdAsync(projectId, userId); if (count >= MaxRulesPerUserPerProject) return ValidationProblem(detail: $"Maximum of {MaxRulesPerUserPerProject} rate notification rules per user per project."); - // Premium gate — non-premium users can create rules but they start disabled - var org = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); - bool isEnabled = model.IsEnabled; - if (!org?.HasPremiumFeatures ?? false) - isEnabled = false; - var now = _timeProvider.GetUtcNow().UtcDateTime; var rule = new RateNotificationRule { @@ -158,7 +169,11 @@ public async Task> GetByIdAsync(string us if (!CanManage(userId)) return NotFound(); - var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + var project = await GetProjectAndCheckAccessAsync(projectId, userId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); if (rule is null) return NotFound(); @@ -177,11 +192,11 @@ public async Task> PutAsync( if (!CanManage(userId)) return NotFound(); - var project = await GetProjectAndCheckAccessAsync(projectId); + var project = await GetProjectAndCheckAccessAsync(projectId, userId); if (project is null) return NotFound(); - var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); if (rule is null) return NotFound(); @@ -199,7 +214,7 @@ public async Task> PutAsync( rule.Threshold = model.Threshold.Value; // StackId update - var newStackId = model.StackId; + var newStackId = model.StackId ?? (rule.Subject == RateNotificationSubject.Stack ? rule.StackId : null); var newSubject = rule.Subject; if (newSubject == RateNotificationSubject.Stack) @@ -233,8 +248,8 @@ public async Task> PutAsync( if (model.IsEnabled.HasValue) { - var org = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); - rule.IsEnabled = model.IsEnabled.Value && (org?.HasPremiumFeatures ?? false); + var organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + rule.IsEnabled = model.IsEnabled.Value && organization?.HasRateNotifications() == true; } rule.Version++; @@ -253,7 +268,11 @@ public async Task DeleteAsync(string userId, string projectId, st if (!CanManage(userId)) return NotFound(); - var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + var project = await GetProjectAndCheckAccessAsync(projectId, userId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); if (rule is null) return NotFound(); @@ -275,24 +294,24 @@ public async Task> SnoozeAsync( if (!CanManage(userId)) return NotFound(); - var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + var project = await GetProjectAndCheckAccessAsync(projectId, userId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); if (rule is null) return NotFound(); var now = _timeProvider.GetUtcNow().UtcDateTime; - if (request.UntilUtc.HasValue) - { - rule.SnoozedUntilUtc = request.UntilUtc.Value; - } - else if (request.DurationSeconds.HasValue) - { - rule.SnoozedUntilUtc = now.AddSeconds(request.DurationSeconds.Value); - } - else - { + if (request.UntilUtc.HasValue == request.DurationSeconds.HasValue) return ValidationProblem(detail: "Either DurationSeconds or UntilUtc must be provided."); - } + + var snoozedUntilUtc = request.UntilUtc ?? now.AddSeconds(request.DurationSeconds!.Value); + if (snoozedUntilUtc <= now) + return ValidationProblem(detail: "Snooze expiration must be in the future."); + + rule.SnoozedUntilUtc = snoozedUntilUtc; rule.Version++; rule.UpdatedUtc = now; @@ -309,14 +328,19 @@ public async Task> UnsnoozeAsync(string u if (!CanManage(userId)) return NotFound(); - var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, projectId); + var project = await GetProjectAndCheckAccessAsync(projectId, userId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); if (rule is null) return NotFound(); // Set to now (NOT null) so the evaluator uses now as the effective window start — no back-alert - rule.SnoozedUntilUtc = _timeProvider.GetUtcNow().UtcDateTime; + var now = _timeProvider.GetUtcNow().UtcDateTime; + rule.SnoozedUntilUtc = now; rule.Version++; - rule.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + rule.UpdatedUtc = now; await _ruleRepository.SaveAsync(rule, o => o.Cache()); await _ruleCache.InvalidateAsync(projectId); @@ -331,15 +355,23 @@ private bool CanManage(string userId) return String.Equals(CurrentUser.Id, userId, StringComparison.Ordinal) || Request.IsGlobalAdmin(); } - private async Task GetProjectAndCheckAccessAsync(string projectId) + private async Task GetProjectAndCheckAccessAsync(string projectId, string userId) { + if (!CanManage(userId)) + return null; + var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache()); if (project is null || !CanAccessOrganization(project.OrganizationId)) return null; + + var user = await _userRepository.GetByIdAsync(userId, o => o.Cache()); + if (user is null || !user.OrganizationIds.Contains(project.OrganizationId)) + return null; + return project; } - private async Task GetRuleAndCheckAccessAsync(string ruleId, string userId, string projectId) + private async Task GetRuleAndCheckAccessAsync(string ruleId, string userId, Project project) { var rule = await _ruleRepository.GetByIdAsync(ruleId); if (rule is null) @@ -349,11 +381,10 @@ private bool CanManage(string userId) if (!String.Equals(rule.UserId, userId, StringComparison.Ordinal)) return null; - if (!String.Equals(rule.ProjectId, projectId, StringComparison.Ordinal)) + if (!String.Equals(rule.ProjectId, project.Id, StringComparison.Ordinal)) return null; - // Current user must be able to manage this rule - if (!CanManage(userId)) + if (!String.Equals(rule.OrganizationId, project.OrganizationId, StringComparison.Ordinal)) return null; return rule; diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 0ab2d63e52..143d15f50a 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -17,6 +17,7 @@ + diff --git a/tests/Exceptionless.Tests/Controllers/ContactControllerTests.cs b/tests/Exceptionless.Tests/Controllers/ContactControllerTests.cs index 5a6445d784..f6e9fc1199 100644 --- a/tests/Exceptionless.Tests/Controllers/ContactControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ContactControllerTests.cs @@ -153,6 +153,11 @@ public Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable return Task.CompletedTask; } + public Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStart, DateTime windowEnd, Stack? stack = null) + { + return Task.CompletedTask; + } + public Task SendUserEmailVerifyAsync(User user) { return Task.CompletedTask; diff --git a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json b/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json index f6c58c2d7c..15681a9a75 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json +++ b/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json @@ -3195,6 +3195,118 @@ ], "ExcludeFromDescription": false }, + { + "Controller": "RateNotificationRuleController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "RateNotificationRuleController", + "Action": "PostAsync", + "HttpMethod": "POST", + "Route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "RateNotificationRuleController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "RateNotificationRuleController", + "Action": "GetByIdAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "Name": "GetRateNotificationRuleById", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "RateNotificationRuleController", + "Action": "PutAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "RateNotificationRuleController", + "Action": "SnoozeAsync", + "HttpMethod": "POST", + "Route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}/snooze", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "RateNotificationRuleController", + "Action": "UnsnoozeAsync", + "HttpMethod": "POST", + "Route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}/unsnooze", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, { "Controller": "WebHookController", "Action": "PostAsync", diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index a15d8682e1..910bc012bc 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -20,6 +20,388 @@ } ], "paths": { + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications": { + "get": { + "tags": [ + "RateNotificationRule" + ], + "summary": "Get all rate notification rules for a user/project.", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "RateNotificationRule" + ], + "summary": "Create a rate notification rule.", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewRateNotificationRule" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewRateNotificationRule" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + } + } + } + }, + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}": { + "get": { + "tags": [ + "RateNotificationRule" + ], + "summary": "Get a specific rate notification rule.", + "operationId": "GetRateNotificationRuleById", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + } + } + }, + "put": { + "tags": [ + "RateNotificationRule" + ], + "summary": "Update a rate notification rule.", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRateNotificationRule" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateRateNotificationRule" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + } + } + }, + "delete": { + "tags": [ + "RateNotificationRule" + ], + "summary": "Delete a rate notification rule.", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + } + } + } + }, + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}/snooze": { + "post": { + "tags": [ + "RateNotificationRule" + ], + "summary": "Snooze a rate notification rule.", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SnoozeRateNotificationRuleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SnoozeRateNotificationRuleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + } + } + } + }, + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}/unsnooze": { + "post": { + "tags": [ + "RateNotificationRule" + ], + "summary": "Unsnooze a rate notification rule. Sets SnoozedUntilUtc = now to establish a fresh baseline.", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "ruleId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + } + } + } + }, "/api/v2/organizations/{organizationId}/saved-views": { "get": { "tags": [ @@ -673,7 +1055,7 @@ "Token" ], "summary": "Create for organization", - "description": "This is a helper action that makes it easier to create a token for a specific organization.\r\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", + "description": "This is a helper action that makes it easier to create a token for a specific organization.\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", "parameters": [ { "name": "organizationId", @@ -797,7 +1179,7 @@ "Token" ], "summary": "Create for project", - "description": "This is a helper action that makes it easier to create a token for a specific project.\r\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", + "description": "This is a helper action that makes it easier to create a token for a specific project.\nYou may also specify a scope when creating a token. There are three valid scopes: client, user and admin.", "parameters": [ { "name": "projectId", @@ -1304,7 +1686,7 @@ "Auth" ], "summary": "Login", - "description": "Log in with your email address and password to generate a token scoped with your users roles.\r\n\r\n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\r\n\r\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\r\nor append it onto the query string: ?access_token=MY_TOKEN\r\n\r\nPlease note that you can also use this token on the documentation site by placing it in the\r\nheaders api_key input box.", + "description": "Log in with your email address and password to generate a token scoped with your users roles.\n\n```{ \"email\": \"noreply@exceptionless.io\", \"password\": \"exceptionless\" }```\n\nThis token can then be used to access the api. You can use this token in the header (bearer authentication)\nor append it onto the query string: ?access_token=MY_TOKEN\n\nPlease note that you can also use this token on the documentation site by placing it in the\nheaders api_key input box.", "requestBody": { "content": { "application/json": { @@ -2179,7 +2561,7 @@ "Event" ], "summary": "Submit event by POST", - "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\r\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\r\n object into the events data collection.\r\n\r\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\r\n\r\n Simple event:\r\n ```{ \"message\": \"Exceptionless is amazing!\" }```\r\n\r\n Simple log event with user identity:\r\n ```{\r\n \"type\": \"log\",\r\n \"message\": \"Exceptionless is amazing!\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\r\n}```\r\n\r\n Multiple events from string content:\r\n ```Exceptionless is amazing!\r\nExceptionless is really amazing!```\r\n\r\n Simple error:\r\n ```{\r\n \"type\": \"error\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@simple_error\": {\r\n \"message\": \"Simple Exception\",\r\n \"type\": \"System.Exception\",\r\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\r\n }\r\n}```", + "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\n object into the events data collection.\n\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\n Simple event:\n ```{ \"message\": \"Exceptionless is amazing!\" }```\n\n Simple log event with user identity:\n ```{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}```\n\n Multiple events from string content:\n ```Exceptionless is amazing!\nExceptionless is really amazing!```\n\n Simple error:\n ```{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}```", "parameters": [ { "name": "userAgent", @@ -2496,7 +2878,7 @@ "Event" ], "summary": "Submit event by POST for a specific project", - "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\r\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\r\n object into the events data collection.\r\n\r\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\r\n\r\n Simple event:\r\n ```{ \"message\": \"Exceptionless is amazing!\" }```\r\n\r\n Simple log event with user identity:\r\n ```{\r\n \"type\": \"log\",\r\n \"message\": \"Exceptionless is amazing!\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\r\n}```\r\n\r\n Multiple events from string content:\r\n ```Exceptionless is amazing!\r\nExceptionless is really amazing!```\r\n\r\n Simple error:\r\n ```{\r\n \"type\": \"error\",\r\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\r\n \"@simple_error\": {\r\n \"message\": \"Simple Exception\",\r\n \"type\": \"System.Exception\",\r\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\r\n }\r\n}```", + "description": " You can create an event by posting any uncompressed or compressed (gzip or deflate) string or json object. If we know how to handle it\n we will create a new event. If none of the JSON properties match the event object then we will create a new event and place your JSON\n object into the events data collection.\n\n You can also post a multi-line string. We automatically split strings by the \\n character and create a new log event for every line.\n\n Simple event:\n ```{ \"message\": \"Exceptionless is amazing!\" }```\n\n Simple log event with user identity:\n ```{\n \"type\": \"log\",\n \"message\": \"Exceptionless is amazing!\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@user\":{ \"identity\":\"123456789\", \"name\": \"Test User\" }\n}```\n\n Multiple events from string content:\n ```Exceptionless is amazing!\nExceptionless is really amazing!```\n\n Simple error:\n ```{\n \"type\": \"error\",\n \"date\":\"2030-01-01T12:00:00.0000000-05:00\",\n \"@simple_error\": {\n \"message\": \"Simple Exception\",\n \"type\": \"System.Exception\",\n \"stack_trace\": \" at Client.Tests.ExceptionlessClientTests.CanSubmitSimpleException() in ExceptionlessClientTests.cs:line 77\"\n }\n}```", "parameters": [ { "name": "projectId", @@ -3908,7 +4290,7 @@ "Event" ], "summary": "Submit event by GET", - "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters. Any unknown query string parameters will be added to the extended data of the event.\n\nFeature usage named build with a duration of 10:\n```/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "type", @@ -4042,7 +4424,7 @@ "Event" ], "summary": "Submit event type by GET", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage event named build with a value of 10:\r\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\r\n\r\nLog event with message, geo and extended data\r\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage event named build with a value of 10:\n```/events/submit/usage?access_token=YOUR_API_KEY&source=build&value=10```\n\nLog event with message, geo and extended data\n```/events/submit/log?access_token=YOUR_API_KEY&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "type", @@ -4178,7 +4560,7 @@ "Event" ], "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "projectId", @@ -4314,7 +4696,7 @@ "Event" ], "summary": "Submit event type by GET for a specific project", - "description": "You can submit an event using an HTTP GET and query string parameters.\r\n\r\nFeature usage named build with a duration of 10:\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\r\n\r\nLog with message, geo and extended data\r\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", + "description": "You can submit an event using an HTTP GET and query string parameters.\n\nFeature usage named build with a duration of 10:\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=usage&source=build&value=10```\n\nLog with message, geo and extended data\n```/projects/{projectId}/events/submit?access_token=YOUR_API_KEY&type=log&message=Hello World&source=server01&geo=32.85,-96.9613&randomproperty=true```", "parameters": [ { "name": "projectId", @@ -5562,7 +5944,7 @@ "Organization" ], "summary": "Change plan", - "description": "Upgrades or downgrades the organization's plan.\r\nAccepts parameters via JSON body (preferred) or query string (legacy).", + "description": "Upgrades or downgrades the organization's plan.\nAccepts parameters via JSON body (preferred) or query string (legacy).", "parameters": [ { "name": "id", @@ -8935,6 +9317,56 @@ } } }, + "NewRateNotificationRule": { + "required": [ + "name", + "signal", + "subject", + "threshold", + "window", + "cooldown", + "is_enabled" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 100, + "type": "string" + }, + "signal": { + "$ref": "#/components/schemas/RateNotificationSignal" + }, + "subject": { + "$ref": "#/components/schemas/RateNotificationSubject" + }, + "stack_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": [ + "null", + "string" + ] + }, + "threshold": { + "maximum": 2147483647, + "minimum": 1, + "type": "integer", + "format": "int32" + }, + "window": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "cooldown": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "is_enabled": { + "type": "boolean" + } + } + }, "NewSavedView": { "required": [ "name", @@ -9581,7 +10013,7 @@ "null", "string" ], - "description": "The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types.\r\nNullable in transit; the pipeline infers a default before save. Validated as required on repository save." + "description": "The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types.\nNullable in transit; the pipeline infers a default before save. Validated as required on repository save." }, "source": { "maxLength": 2000, @@ -9774,6 +10206,32 @@ } } }, + "RateNotificationSignal": { + "enum": [ + "AllEvents", + "Errors", + "CriticalErrors", + "NewErrors", + "Regressions" + ], + "x-enumNames": [ + "AllEvents", + "Errors", + "CriticalErrors", + "NewErrors", + "Regressions" + ] + }, + "RateNotificationSubject": { + "enum": [ + "Project", + "Stack" + ], + "x-enumNames": [ + "Project", + "Stack" + ] + }, "ResetPasswordModel": { "required": [ "password_reset_token", @@ -9823,6 +10281,29 @@ } } }, + "SnoozeRateNotificationRuleRequest": { + "type": "object", + "properties": { + "duration_seconds": { + "maximum": 2147483647, + "minimum": 1, + "type": [ + "null", + "integer" + ], + "description": "Snooze duration in seconds. Mutually exclusive with UntilUtc.", + "format": "int32" + }, + "until_utc": { + "type": [ + "null", + "string" + ], + "description": "Snooze until this UTC timestamp. Mutually exclusive with DurationSeconds.", + "format": "date-time" + } + } + }, "Stack": { "required": [ "organization_id", @@ -10095,6 +10576,76 @@ }, "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, + "UpdateRateNotificationRule": { + "type": "object", + "properties": { + "name": { + "maxLength": 100, + "type": [ + "null", + "string" + ] + }, + "signal": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RateNotificationSignal" + } + ] + }, + "subject": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RateNotificationSubject" + } + ] + }, + "stack_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": [ + "null", + "string" + ] + }, + "threshold": { + "maximum": 2147483647, + "minimum": 1, + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "window": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "cooldown": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "is_enabled": { + "type": [ + "null", + "boolean" + ] + } + } + }, "UpdateSavedView": { "type": "object", "properties": { @@ -10881,6 +11432,99 @@ } } }, + "ViewRateNotificationRule": { + "required": [ + "id", + "organization_id", + "project_id", + "user_id", + "version", + "name", + "is_enabled", + "signal", + "subject", + "threshold", + "window", + "cooldown", + "is_snoozed", + "created_utc", + "updated_utc" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "is_enabled": { + "type": "boolean" + }, + "signal": { + "$ref": "#/components/schemas/RateNotificationSignal" + }, + "subject": { + "$ref": "#/components/schemas/RateNotificationSubject" + }, + "stack_id": { + "type": [ + "null", + "string" + ] + }, + "threshold": { + "type": "integer", + "format": "int32" + }, + "window": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "cooldown": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "snoozed_until_utc": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "is_snoozed": { + "type": "boolean" + }, + "last_fired_utc": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "created_utc": { + "type": "string", + "format": "date-time" + }, + "updated_utc": { + "type": "string", + "format": "date-time" + } + } + }, "ViewSavedView": { "required": [ "id", @@ -11245,6 +11889,9 @@ } }, "tags": [ + { + "name": "RateNotificationRule" + }, { "name": "SavedView" }, diff --git a/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs index bdcf4a5a57..45f45c13e0 100644 --- a/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs @@ -1,8 +1,10 @@ +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Web.Models; +using Foundatio.Repositories; using Xunit; namespace Exceptionless.Tests.Controllers; @@ -10,11 +12,13 @@ namespace Exceptionless.Tests.Controllers; public sealed class RateNotificationRuleControllerTests : IntegrationTestsBase { private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IUserRepository _userRepository; public RateNotificationRuleControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _ruleRepository = GetService(); + _organizationRepository = GetService(); _userRepository = GetService(); } @@ -23,6 +27,11 @@ protected override async Task ResetDataAsync() await base.ResetDataAsync(); var service = GetService(); await service.CreateDataAsync(); + + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Add(OrganizationExtensions.RateNotificationsFeature); + await _organizationRepository.SaveAsync(organization); } // ---- Helper: get current user via /me endpoint ---- @@ -54,6 +63,27 @@ private string RuleUrl(string userId, string projectId) => private string RuleUrl(string userId, string projectId, string ruleId) => $"users/{userId}/projects/{projectId}/rate-notifications/{ruleId}"; + private async Task CreateProjectRuleAsync(string userId, string name) + { + var rule = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(userId, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = name, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated()); + + Assert.NotNull(rule); + return rule; + } + // ---- CRUD tests ---- [Fact] @@ -73,7 +103,7 @@ public async Task GetAsync_AsOwnUser_ReturnsList() [Fact] public async Task GetAsync_AsDifferentUser_ReturnsNotFound() { - // Using org user ID to try to access another user's rules + // Using organization user ID to try to access another user's rules var adminUser = await GetGlobalAdminUserAsync(); await SendRequestAsync(r => r @@ -89,7 +119,7 @@ public async Task GetAsync_AsGlobalAdmin_CanAccessAnyUsersRules() var orgUser = await GetTestOrganizationUserAsync(); var results = await SendRequestAsAsync>(r => r - .AsGlobalAdminUser() // admin accessing org user's rules + .AsGlobalAdminUser() // admin accessing organization user's rules .AppendPath(RuleUrl(orgUser.Id, SampleDataService.TEST_PROJECT_ID)) .StatusCodeShouldBeOk() ); @@ -402,4 +432,131 @@ await SendRequestAsync(r => r .StatusCodeShouldBeUnprocessableEntity() ); } + + [Fact] + public async Task GetByIdAsync_UserRemovedFromOrganization_ReturnsNotFound() + { + // Arrange + var user = await GetTestOrganizationUserAsync(); + var rule = await CreateProjectRuleAsync(user.Id, "Removed user"); + var storedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(storedUser); + storedUser.OrganizationIds.Remove(SampleDataService.TEST_ORG_ID); + await _userRepository.SaveAsync(storedUser, o => o.Cache()); + + // Act / Assert + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, rule.Id)) + .StatusCodeShouldBeNotFound()); + } + + [Fact] + public async Task PostAsync_FeatureDisabled_CreatesDisabledRule() + { + // Arrange + var user = await GetTestOrganizationUserAsync(); + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Remove(OrganizationExtensions.RateNotificationsFeature); + await _organizationRepository.SaveAsync(organization, o => o.Cache()); + + // Act + var rule = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Feature disabled", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + IsEnabled = true + }) + .StatusCodeShouldBeCreated()); + + // Assert + Assert.NotNull(rule); + Assert.False(rule.IsEnabled); + } + + [Fact] + public async Task PutAsync_ExistingStackRuleWithoutStackId_PreservesStack() + { + // Arrange + var user = await GetTestOrganizationUserAsync(); + var (stacks, _) = await CreateDataAsync(b => b.Event().TestProject().Type(Event.KnownTypes.Error)); + var stack = Assert.Single(stacks); + var created = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Stack rule", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Stack, + StackId = stack.Id, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated()); + Assert.NotNull(created); + + // Act + var updated = await SendRequestAsAsync(r => r + .Put() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)) + .Content(new UpdateRateNotificationRule { IsEnabled = false }) + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(updated); + Assert.Equal(stack.Id, updated.StackId); + Assert.False(updated.IsEnabled); + } + + [Fact] + public async Task SnoozeAsync_BothExpirationValues_Returns422() + { + // Arrange + var user = await GetTestOrganizationUserAsync(); + var rule = await CreateProjectRuleAsync(user.Id, "Invalid snooze"); + + // Act / Assert + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath($"{RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, rule.Id)}/snooze") + .Content(new SnoozeRateNotificationRuleRequest + { + DurationSeconds = 60, + UntilUtc = TimeProvider.GetUtcNow().UtcDateTime.AddMinutes(1) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task SnoozeAsync_PastExpiration_Returns422() + { + // Arrange + var user = await GetTestOrganizationUserAsync(); + var rule = await CreateProjectRuleAsync(user.Id, "Past snooze"); + + // Act / Assert + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath($"{RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, rule.Id)}/snooze") + .Content(new SnoozeRateNotificationRuleRequest + { + UntilUtc = TimeProvider.GetUtcNow().UtcDateTime.AddMinutes(-1) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } } diff --git a/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs b/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs index c7300641a2..9a11a16fe9 100644 --- a/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs @@ -1,8 +1,8 @@ using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Billing; -using Exceptionless.Core.Jobs; using Exceptionless.Core.Extensions; +using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs index 6a55b4653c..21c0e5f050 100644 --- a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs @@ -1,3 +1,4 @@ +using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; using Exceptionless.Core.Queues.Models; @@ -34,6 +35,11 @@ protected override async Task ResetDataAsync() await base.ResetDataAsync(); var service = GetService(); await service.CreateDataAsync(); + + var organization = await _orgRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Add(OrganizationExtensions.RateNotificationsFeature); + await _orgRepository.SaveAsync(organization, o => o.ImmediateConsistency()); } private RateNotificationRule BuildRule(string? projectId = null, RateNotificationSignal signal = RateNotificationSignal.Errors, int threshold = 10, string? window = null, string? cooldown = null) @@ -111,6 +117,25 @@ public async Task RunAsync_WhenBelowThreshold_DoesNotEnqueue() Assert.Equal(queueBefore, queueAfter); } + [Fact] + public async Task RunAsync_WhenEventsAreInCurrentMinute_DoesNotEvaluatePartialBucket() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var now = new DateTime(2024, 6, 1, 12, 0, 30, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now); + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 1), o => o.ImmediateConsistency()); + await _counterService.IncrementAsync(BuildCounterKey(rule), ct); + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + // Act + await _job.RunAsync(ct); + + // Assert + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + Assert.Equal(queueBefore, queueAfter); + } + [Fact] public async Task RunAsync_WhenOnCooldown_DoesNotEnqueueAgain() { @@ -130,7 +155,7 @@ public async Task RunAsync_WhenOnCooldown_DoesNotEnqueueAgain() TimeProvider.Advance(TimeSpan.FromMinutes(2)); // Put rule on cooldown (at "now") - await _counterService.SetCooldownAsync(rule.Id, subjectKey, TimeSpan.FromHours(1), ct); + Assert.True(await _counterService.TrySetCooldownAsync(rule.Id, subjectKey, TimeSpan.FromHours(1), ct)); long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs new file mode 100644 index 0000000000..576e41325a --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs @@ -0,0 +1,182 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Jobs; +using Exceptionless.Core.Mail; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Mail; +using Foundatio.Queues; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Jobs; + +public class RateNotificationsJobTests : IntegrationTestsBase +{ + private readonly RateNotificationsJob _job; + private readonly NullMailer _mailer; + private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; + private readonly IQueue _queue; + private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IUserRepository _userRepository; + + public RateNotificationsJobTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _job = GetService(); + _mailer = Assert.IsType(GetService()); + _organizationRepository = GetService(); + _projectRepository = GetService(); + _queue = GetService>(); + _ruleRepository = GetService(); + _userRepository = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await _queue.DeleteQueueAsync(); + _mailer.RateNotifications.Clear(); + + await GetService().CreateDataAsync(); + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Add(OrganizationExtensions.RateNotificationsFeature); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.IsEmailAddressVerified = true; + user.VerifyEmailAddressToken = null; + user.VerifyEmailAddressTokenExpiration = default; + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + } + + [Fact] + public async Task RunAsync_EnabledValidRule_SendsNotification() + { + // Arrange + var (rule, notification) = await CreateRuleAndNotificationAsync(); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + var call = Assert.Single(_mailer.RateNotifications); + Assert.Equal(rule.Id, call.RuleId); + Assert.Equal(notification.ObservedCount, call.ObservedCount); + } + + [Fact] + public async Task RunAsync_FeatureDisabled_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Remove(OrganizationExtensions.RateNotificationsFeature); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_PayloadDoesNotMatchRule_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + notification.ProjectId = SampleDataService.FREE_PROJECT_ID; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_SubjectKeyDoesNotMatchRule_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + notification.SubjectKey = $"project:{SampleDataService.FREE_PROJECT_ID}"; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task EnqueueAsync_DuplicateEvaluation_EnqueuesOnce() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + + // Act + await _queue.EnqueueAsync(notification); + await _queue.EnqueueAsync(notification); + + // Assert + var stats = await _queue.GetQueueStatsAsync(); + Assert.Equal(1, stats.Enqueued); + } + + private async Task<(RateNotificationRule Rule, RateNotification Notification)> CreateRuleAndNotificationAsync() + { + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + Assert.Contains(SampleDataService.TEST_ORG_ID, user.OrganizationIds); + Assert.True(user.IsEmailAddressVerified); + Assert.True(user.EmailNotificationsEnabled); + + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + + var now = TimeProvider.GetUtcNow().UtcDateTime; + var rule = await _ruleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = SampleDataService.TEST_ORG_ID, + ProjectId = project.Id, + UserId = user.Id, + Name = "Delivery test", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + Version = 1, + CreatedUtc = now, + UpdatedUtc = now + }, o => o.ImmediateConsistency()); + + return (rule, new RateNotification + { + RuleId = rule.Id, + RuleVersion = rule.Version, + OrganizationId = rule.OrganizationId, + ProjectId = rule.ProjectId, + UserId = rule.UserId, + SubjectKey = $"project:{rule.ProjectId}", + WindowStartUtc = now.AddMinutes(-5), + WindowEndUtc = now, + ObservedCount = 10, + Threshold = rule.Threshold + }); + } +} diff --git a/tests/Exceptionless.Tests/Mail/NullMailer.cs b/tests/Exceptionless.Tests/Mail/NullMailer.cs index a977b5bb20..e7077af85a 100644 --- a/tests/Exceptionless.Tests/Mail/NullMailer.cs +++ b/tests/Exceptionless.Tests/Mail/NullMailer.cs @@ -5,6 +5,8 @@ namespace Exceptionless.Tests.Mail; public class NullMailer : IMailer { + public List RateNotifications { get; } = []; + public Task SendContactRequestAsync(string name, string emailAddress, string? company, string? subject, string message, string? clientIpAddress, string? userAgent, string? referrer) { return Task.FromResult(true); @@ -52,6 +54,9 @@ public Task SendUserPasswordResetAsync(User user) public Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStart, DateTime windowEnd, Stack? stack = null) { + RateNotifications.Add(new RateNotificationCall(user.Id, project.Id, rule.Id, observedCount, windowStart, windowEnd, stack?.Id)); return Task.CompletedTask; } } + +public record RateNotificationCall(string UserId, string ProjectId, string RuleId, long ObservedCount, DateTime WindowStart, DateTime WindowEnd, string? StackId); diff --git a/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs new file mode 100644 index 0000000000..f6fc3c4322 --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs @@ -0,0 +1,76 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Pipeline; +using Xunit; + +namespace Exceptionless.Tests.Pipeline; + +public class UpdateRateCountersActionTests +{ + [Fact] + public void GetCounterKeys_DuplicateRules_ReturnsOneCounterKey() + { + // Arrange + var ev = new PersistentEvent + { + ProjectId = "project-1", + StackId = "stack-1", + Type = Event.KnownTypes.Error + }; + var rules = new[] + { + CreateRule("rule-1", RateNotificationSubject.Project), + CreateRule("rule-2", RateNotificationSubject.Project) + }; + + // Act + var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, rules); + + // Assert + Assert.Equal(["project:project-1:signal:Errors"], keys); + } + + [Fact] + public void GetCounterKeys_ProjectAndMatchingStackRules_ReturnsDistinctScopeKeys() + { + // Arrange + var ev = new PersistentEvent + { + ProjectId = "project-1", + StackId = "stack-1", + Type = Event.KnownTypes.Error + }; + var rules = new[] + { + CreateRule("rule-1", RateNotificationSubject.Project), + CreateRule("rule-2", RateNotificationSubject.Stack, "stack-1"), + CreateRule("rule-3", RateNotificationSubject.Stack, "stack-2") + }; + + // Act + var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, rules); + + // Assert + Assert.Equal(2, keys.Count); + Assert.Contains("project:project-1:signal:Errors", keys); + Assert.Contains("project:project-1:stack:stack-1:signal:Errors", keys); + } + + private static RateNotificationRule CreateRule(string id, RateNotificationSubject subject, string? stackId = null) + { + return new RateNotificationRule + { + Id = id, + OrganizationId = "organization-1", + ProjectId = "project-1", + UserId = "user-1", + Name = id, + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = subject, + StackId = stackId, + Threshold = 1, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(5) + }; + } +} diff --git a/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs index ffe40c34f6..1060f94d40 100644 --- a/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/OAuthTokenRepositoryTests.cs @@ -4,8 +4,8 @@ using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; using Exceptionless.Tests.Utility; -using Foundatio.Repositories.Models; using Foundatio.Repositories; +using Foundatio.Repositories.Models; using Foundatio.Repositories.Utility; using Xunit; diff --git a/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs index b7a543e7ba..3994533837 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/OAuthTokenSerializerTests.cs @@ -49,4 +49,4 @@ public void RoundTrip_WithOAuthAccessToken_PreservesValues() Assert.Contains("550000000000000000000002", result.OrganizationIds); Assert.Contains(AuthorizationRoles.ProjectsRead, result.Scopes); } -} \ No newline at end of file +} diff --git a/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs index 8d8a53bbbf..404b84bce5 100644 --- a/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs @@ -1,7 +1,6 @@ using Exceptionless.Core.Services; using Exceptionless.Tests.Utility; using Foundatio.Caching; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Exceptionless.Tests.Services; @@ -21,9 +20,9 @@ private static (RateCounterService service, ProxyTimeProvider timeProvider, InMe var timeProvider = new ProxyTimeProvider(); var cache = new InMemoryCacheClient(new InMemoryCacheClientOptions { - LoggerFactory = NullLoggerFactory.Instance + TimeProvider = timeProvider }); - var service = new RateCounterService(cache, timeProvider, NullLoggerFactory.Instance); + var service = new RateCounterService(cache, timeProvider); return (service, timeProvider, cache); } @@ -126,7 +125,7 @@ public async Task IncrementAsync_SingleIncrement_CreatesCountBucket() await service.IncrementAsync(CounterKey, ct); - long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-1), now, ct); + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-1), now.AddMinutes(1), ct); Assert.Equal(1, count); } @@ -141,7 +140,7 @@ public async Task IncrementAsync_MultipleIncrements_AccumulatesCount() for (int i = 0; i < 7; i++) await service.IncrementAsync(CounterKey, ct); - long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-1), now, ct); + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-1), now.AddMinutes(1), ct); Assert.Equal(7, count); } @@ -167,10 +166,29 @@ public async Task SumBucketsAsync_AcrossMultipleMinutes_SumsAllBuckets() for (int i = 0; i < 2; i++) await service.IncrementAsync(CounterKey, ct); - long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-5), now, ct); + long count = await service.SumBucketsAsync(CounterKey, now.AddMinutes(-5), now.AddMinutes(1), ct); Assert.Equal(10, count); } + [Fact] + public async Task SumBucketsAsync_EndMinuteHasEvents_ExcludesEndMinute() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var end = new DateTime(2024, 1, 15, 10, 5, 0, DateTimeKind.Utc); + timeProvider.SetUtcNow(end.AddMinutes(-1)); + await service.IncrementAsync(CounterKey, ct); + timeProvider.SetUtcNow(end); + await service.IncrementAsync(CounterKey, ct); + + // Act + long count = await service.SumBucketsAsync(CounterKey, end.AddMinutes(-5), end, ct); + + // Assert + Assert.Equal(1, count); + } + [Fact] public async Task SumBucketsAsync_EmptyWindow_ReturnsZero() { @@ -247,23 +265,23 @@ public async Task IsOnCooldownAsync_WhenNoCooldownSet_ReturnsFalse() } [Fact] - public async Task IsOnCooldownAsync_AfterSetCooldown_ReturnsTrue() + public async Task IsOnCooldownAsync_AfterClaimingCooldown_ReturnsTrue() { var ct = TestContext.Current.CancellationToken; var (service, _, _) = Create(); - await service.SetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromHours(1), ct); + Assert.True(await service.TrySetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromHours(1), ct)); bool onCooldown = await service.IsOnCooldownAsync(RuleId, SubjectKey, ct); Assert.True(onCooldown); } [Fact] - public async Task SetCooldownAsync_DifferentRules_IndependentCooldowns() + public async Task TrySetCooldownAsync_DifferentRules_IndependentCooldowns() { var ct = TestContext.Current.CancellationToken; var (service, _, _) = Create(); const string ruleId2 = "rule-002"; - await service.SetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromHours(1), ct); + Assert.True(await service.TrySetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromHours(1), ct)); bool rule1OnCooldown = await service.IsOnCooldownAsync(RuleId, SubjectKey, ct); bool rule2OnCooldown = await service.IsOnCooldownAsync(ruleId2, SubjectKey, ct); @@ -271,4 +289,37 @@ public async Task SetCooldownAsync_DifferentRules_IndependentCooldowns() Assert.True(rule1OnCooldown); Assert.False(rule2OnCooldown); } + + [Fact] + public async Task TrySetCooldownAsync_ConfiguredDuration_ExpiresWithoutBuffer() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var duration = TimeSpan.FromMinutes(5); + Assert.True(await service.TrySetCooldownAsync(RuleId, SubjectKey, duration, ct)); + + // Act + timeProvider.Advance(duration); + bool onCooldown = await service.IsOnCooldownAsync(RuleId, SubjectKey, ct); + + // Assert + Assert.False(onCooldown); + } + + [Fact] + public async Task TrySetCooldownAsync_WhenAlreadyClaimed_ReturnsFalse() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var (service, _, _) = Create(); + + // Act + bool first = await service.TrySetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromMinutes(5), ct); + bool second = await service.TrySetCooldownAsync(RuleId, SubjectKey, TimeSpan.FromMinutes(5), ct); + + // Assert + Assert.True(first); + Assert.False(second); + } } diff --git a/tests/http/rate-notifications.http b/tests/http/rate-notifications.http new file mode 100644 index 0000000000..f5a2ad840f --- /dev/null +++ b/tests/http/rate-notifications.http @@ -0,0 +1,94 @@ +@apiUrl = http://localhost:7110/api/v2 +@email = admin@exceptionless.test +@password = tester +@organizationId = 537650f3b77efe23a47914f3 +@projectId = 537650f3b77efe23a47914f4 +@feature = rate-notifications + +### Login to test account +# @name login +POST {{apiUrl}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +### + +@token = {{login.response.body.$.token}} + +### Get current user +# @name currentUser +GET {{apiUrl}}/users/me +Authorization: Bearer {{token}} + +### + +@userId = {{currentUser.response.body.$.id}} + +### Enable rate notifications feature flag +POST {{apiUrl}}/organizations/{{organizationId}}/features/{{feature}} +Authorization: Bearer {{token}} + +### List rules +GET {{apiUrl}}/users/{{userId}}/projects/{{projectId}}/rate-notifications +Authorization: Bearer {{token}} + +### Create a project rule +# @name newRateNotificationRule +POST {{apiUrl}}/users/{{userId}}/projects/{{projectId}}/rate-notifications +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "Project errors", + "signal": "Error", + "subject": "Project", + "threshold": 10, + "window": "00:05:00", + "cooldown": "01:00:00", + "is_enabled": true +} + +### + +@ruleId = {{newRateNotificationRule.response.body.$.id}} + +### Get rule +GET {{apiUrl}}/users/{{userId}}/projects/{{projectId}}/rate-notifications/{{ruleId}} +Authorization: Bearer {{token}} + +### Update rule +PUT {{apiUrl}}/users/{{userId}}/projects/{{projectId}}/rate-notifications/{{ruleId}} +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "High project error rate", + "threshold": 25, + "window": "00:10:00", + "cooldown": "01:00:00" +} + +### Snooze rule for one hour +POST {{apiUrl}}/users/{{userId}}/projects/{{projectId}}/rate-notifications/{{ruleId}}/snooze +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "duration_seconds": 3600 +} + +### Unsnooze rule +POST {{apiUrl}}/users/{{userId}}/projects/{{projectId}}/rate-notifications/{{ruleId}}/unsnooze +Authorization: Bearer {{token}} + +### Delete rule +DELETE {{apiUrl}}/users/{{userId}}/projects/{{projectId}}/rate-notifications/{{ruleId}} +Authorization: Bearer {{token}} + +### Disable rate notifications feature flag +DELETE {{apiUrl}}/organizations/{{organizationId}}/features/{{feature}} +Authorization: Bearer {{token}} From 6a7f9c3e4a6b8bb28e3f460937d8bf240fa5fb9e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 10:33:38 -0500 Subject: [PATCH 16/18] fix: harden rate notification UI --- .../ClientApp/package-lock.json | 433 +++++++++----- src/Exceptionless.Web/ClientApp/package.json | 1 + .../features/rate-notifications/api.svelte.ts | 235 ++++---- .../features/rate-notifications/api.test.ts | 176 ++++++ .../rate-notification-rule-form.svelte | 528 ++++++++++-------- .../rate-notification-rule-list.svelte | 193 +++---- .../lib/features/rate-notifications/index.ts | 2 +- .../rate-notifications/schemas.test.ts | 92 +++ .../features/rate-notifications/schemas.ts | 63 +++ .../lib/features/rate-notifications/types.ts | 90 +-- .../ClientApp/src/lib/generated/api.ts | 104 ++++ .../ClientApp/src/lib/generated/schemas.ts | 135 +++++ .../(app)/account/notifications/+page.svelte | 75 ++- 13 files changed, 1404 insertions(+), 723 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index a6df1e0951..26998d58c3 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -2117,9 +2117,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -2130,9 +2130,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -2143,9 +2143,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -2156,9 +2156,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -2169,9 +2169,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -2182,9 +2182,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -2195,12 +2195,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2208,12 +2211,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2221,12 +2227,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2234,12 +2243,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2247,12 +2259,31 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2260,12 +2291,31 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2273,12 +2323,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2286,12 +2339,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2299,12 +2355,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2312,12 +2371,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2325,22 +2387,38 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -2351,9 +2429,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -2364,9 +2442,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -2377,9 +2455,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -2390,9 +2468,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -3144,6 +3222,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", @@ -3608,9 +3752,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/geojson": { @@ -4348,9 +4492,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -4667,9 +4811,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz", + "integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==", "devOptional": true, "license": "MIT", "engines": { @@ -5835,9 +5979,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -5914,9 +6058,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -6235,10 +6379,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7020,9 +7174,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "devOptional": true, "funding": [ { @@ -7150,9 +7304,9 @@ } }, "node_modules/oas-linter/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -7180,9 +7334,9 @@ } }, "node_modules/oas-resolver/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -7220,9 +7374,9 @@ } }, "node_modules/oas-validator/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -7498,9 +7652,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "devOptional": true, "license": "MIT", "engines": { @@ -7555,9 +7709,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "devOptional": true, "funding": [ { @@ -7575,7 +7729,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7614,9 +7768,9 @@ } }, "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -8030,13 +8184,13 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "devOptional": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -8046,28 +8200,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, @@ -8891,9 +9048,9 @@ } }, "node_modules/swagger2openapi/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -9214,9 +9371,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 6e1f999707..d14f57e4a6 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -110,6 +110,7 @@ }, "type": "module", "overrides": { + "cookie": "0.7.0", "storybook": "$storybook" } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts index f0224d51d2..d7bd19cf55 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts @@ -7,204 +7,157 @@ import type { import { accessToken } from '$features/auth/index.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; -import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; +import { createMutation, createQuery, type QueryClient, useQueryClient } from '@tanstack/svelte-query'; -export const queryKeys = { - create: (userId: string | undefined, projectId: string | undefined) => - [...queryKeys.list(userId, projectId), 'create'] as const, - delete: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => - [...queryKeys.id(userId, projectId, ruleId), 'delete'] as const, - id: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => - [...queryKeys.list(userId, projectId), ruleId] as const, - list: (userId: string | undefined, projectId: string | undefined) => - ['RateNotificationRule', userId, projectId] as const, - snooze: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => - [...queryKeys.id(userId, projectId, ruleId), 'snooze'] as const, - unsnooze: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => - [...queryKeys.id(userId, projectId, ruleId), 'unsnooze'] as const, - update: (userId: string | undefined, projectId: string | undefined, ruleId: string | undefined) => - [...queryKeys.id(userId, projectId, ruleId), 'update'] as const -}; +const CONSISTENCY_REFRESH_DELAY_MS = 1500; -export async function invalidateRateNotificationQueries( - queryClient: QueryClient, - userId: string | undefined, - projectId: string | undefined, - ruleId?: string | undefined -) { - if (ruleId) { - await queryClient.invalidateQueries({ queryKey: queryKeys.id(userId, projectId, ruleId) }); - } - await queryClient.invalidateQueries({ queryKey: queryKeys.list(userId, projectId) }); +interface RateNotificationRoute { + projectId: string | undefined; + userId: string | undefined; } -function ruleRoute(userId: string, projectId: string, ruleId?: string): string { - const base = `users/${userId}/projects/${projectId}/rate-notifications`; - return ruleId ? `${base}/${ruleId}` : base; +interface RuleMutationVariables { + body: TBody; + ruleId: string; } -// ---- List ---- - -export interface GetRuleListRequest { - params?: { limit?: number; page?: number }; - route: { projectId: string | undefined; userId: string | undefined }; -} +export const queryKeys = { + list: (userId: string | undefined, projectId: string | undefined) => ['RateNotificationRule', userId, projectId] as const +}; -export function getRateNotificationRulesQuery(request: GetRuleListRequest) { +export function deleteRateNotificationRule(request: { route: RateNotificationRoute }) { const queryClient = useQueryClient(); - return createQuery, ProblemDetails>(() => ({ + return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, - onSuccess: (data: FetchClientResponse) => { - data.data?.forEach((rule) => { - queryClient.setQueryData(queryKeys.id(request.route.userId, request.route.projectId, rule.id), rule); - }); + mutationFn: async (ruleId: string) => { + const client = useFetchClient(); + await client.delete(ruleRoute(request.route, ruleId), { expectedStatusCodes: [204] }); }, - queryClient, + onSuccess: (_, ruleId) => { + updateRulesCache(queryClient, request.route, (rules) => rules.filter((rule) => rule.id !== ruleId)); + scheduleConsistencyRefresh(queryClient, request.route); + } + })); +} + +export function getRateNotificationRulesQuery(request: { params?: { limit?: number; page?: number }; route: RateNotificationRoute }) { + return createQuery, ProblemDetails>(() => ({ + enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, queryFn: async ({ signal }: { signal: AbortSignal }) => { const client = useFetchClient(); - const response = await client.getJSON( - ruleRoute(request.route.userId!, request.route.projectId!), - { params: { limit: 50, ...request.params }, signal } - ); - return response; + return client.getJSON(ruleRoute(request.route), { + params: { limit: 50, ...request.params }, + signal + }); }, queryKey: [...queryKeys.list(request.route.userId, request.route.projectId), { params: request.params }] })); } -// ---- Create ---- - -export interface CreateRuleRequest { - route: { projectId: string | undefined; userId: string | undefined }; -} - -export function createRateNotificationRule(request: CreateRuleRequest) { +export function postRateNotificationRule(request: { route: RateNotificationRoute }) { const queryClient = useQueryClient(); return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, mutationFn: async (body: NewRateNotificationRule) => { const client = useFetchClient(); - const response = await client.postJSON( - ruleRoute(request.route.userId!, request.route.projectId!), - body - ); + const response = await client.postJSON(ruleRoute(request.route), body); return response.data!; }, - mutationKey: queryKeys.create(request.route.userId, request.route.projectId), - onSuccess: (rule: ViewRateNotificationRule) => { - queryClient.setQueryData(queryKeys.id(request.route.userId, request.route.projectId, rule.id), rule); - queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + onSuccess: (rule) => { + updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, request.route); } })); } -// ---- Update ---- +export function postSnoozeRateNotificationRule(request: { route: RateNotificationRoute }) { + const queryClient = useQueryClient(); -export interface UpdateRuleRequest { - route: { projectId: string | undefined; ruleId: string | undefined; userId: string | undefined }; + return createMutation>(() => ({ + enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, + mutationFn: async ({ body, ruleId }) => { + const client = useFetchClient(); + const response = await client.postJSON(`${ruleRoute(request.route, ruleId)}/snooze`, body, { + expectedStatusCodes: [200] + }); + return response.data!; + }, + onSuccess: (rule) => { + updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, request.route); + } + })); } -export function updateRateNotificationRule(request: UpdateRuleRequest) { +export function postUnsnoozeRateNotificationRule(request: { route: RateNotificationRoute }) { const queryClient = useQueryClient(); - return createMutation(() => ({ - enabled: () => - !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, - mutationFn: async (body: UpdateRateNotificationRule) => { + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, + mutationFn: async (ruleId: string) => { const client = useFetchClient(); - const response = await client.putJSON( - ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!), - body + const response = await client.postJSON( + `${ruleRoute(request.route, ruleId)}/unsnooze`, + {}, + { + expectedStatusCodes: [200] + } ); return response.data!; }, - mutationKey: queryKeys.update(request.route.userId, request.route.projectId, request.route.ruleId), - onSuccess: (rule: ViewRateNotificationRule) => { - queryClient.setQueryData(queryKeys.id(request.route.userId, request.route.projectId, rule.id), rule); - queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + onSuccess: (rule) => { + updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, request.route); } })); } -// ---- Delete ---- - -export interface DeleteRuleRequest { - route: { projectId: string | undefined; ruleId: string | undefined; userId: string | undefined }; -} - -export function deleteRateNotificationRule(request: DeleteRuleRequest) { +export function putRateNotificationRule(request: { route: RateNotificationRoute }) { const queryClient = useQueryClient(); - return createMutation(() => ({ - enabled: () => - !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, - mutationFn: async () => { + return createMutation>(() => ({ + enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, + mutationFn: async ({ body, ruleId }) => { const client = useFetchClient(); - await client.delete(ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!), { - expectedStatusCodes: [204] - }); + const response = await client.putJSON(ruleRoute(request.route, ruleId), body); + return response.data!; }, - mutationKey: queryKeys.delete(request.route.userId, request.route.projectId, request.route.ruleId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + onSuccess: (rule) => { + updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, request.route); } })); } -// ---- Snooze ---- +function ruleRoute(route: RateNotificationRoute, ruleId?: string): string { + const base = `users/${route.userId}/projects/${route.projectId}/rate-notifications`; + return ruleId ? `${base}/${ruleId}` : base; +} -export interface SnoozeRuleRequest { - route: { projectId: string | undefined; ruleId: string | undefined; userId: string | undefined }; +function scheduleConsistencyRefresh(queryClient: QueryClient, route: RateNotificationRoute): void { + const queryKey = queryKeys.list(route.userId, route.projectId); + setTimeout(() => void queryClient.invalidateQueries({ queryKey }), CONSISTENCY_REFRESH_DELAY_MS); } -export function snoozeRateNotificationRule(request: SnoozeRuleRequest) { - const queryClient = useQueryClient(); +function updateRulesCache(queryClient: QueryClient, route: RateNotificationRoute, update: (rules: ViewRateNotificationRule[]) => ViewRateNotificationRule[]) { + queryClient.setQueriesData | undefined>( + { queryKey: queryKeys.list(route.userId, route.projectId) }, + (response) => { + if (!Array.isArray(response?.data)) { + return response; + } - return createMutation(() => ({ - enabled: () => - !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, - mutationFn: async (body: SnoozeRateNotificationRuleRequest) => { - const client = useFetchClient(); - await client.postJSON( - `${ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!)}/snooze`, - body, - { expectedStatusCodes: [200, 204] } - ); - }, - mutationKey: queryKeys.snooze(request.route.userId, request.route.projectId, request.route.ruleId), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.id(request.route.userId, request.route.projectId, request.route.ruleId) - }); - queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); + return { ...response, data: update(response.data) }; } - })); + ); } -// ---- Unsnooze ---- - -export function unsnoozeRateNotificationRule(request: SnoozeRuleRequest) { - const queryClient = useQueryClient(); +function upsertRule(rules: ViewRateNotificationRule[], rule: ViewRateNotificationRule): ViewRateNotificationRule[] { + const nextRules = rules.some((existingRule) => existingRule.id === rule.id) + ? rules.map((existingRule) => (existingRule.id === rule.id ? rule : existingRule)) + : [...rules, rule]; - return createMutation(() => ({ - enabled: () => - !!accessToken.current && !!request.route.userId && !!request.route.projectId && !!request.route.ruleId, - mutationFn: async () => { - const client = useFetchClient(); - await client.postJSON( - `${ruleRoute(request.route.userId!, request.route.projectId!, request.route.ruleId!)}/unsnooze`, - {}, - { expectedStatusCodes: [200, 204] } - ); - }, - mutationKey: queryKeys.unsnooze(request.route.userId, request.route.projectId, request.route.ruleId), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.id(request.route.userId, request.route.projectId, request.route.ruleId) - }); - queryClient.invalidateQueries({ queryKey: queryKeys.list(request.route.userId, request.route.projectId) }); - } - })); + return nextRules.toSorted((a, b) => a.name.localeCompare(b.name)); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts new file mode 100644 index 0000000000..eea0bed36b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts @@ -0,0 +1,176 @@ +import type { ViewRateNotificationRule } from '$generated/api'; + +import { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + client: { + delete: vi.fn(), + getJSON: vi.fn(), + postJSON: vi.fn(), + putJSON: vi.fn() + }, + invalidateQueries: vi.fn(), + setQueriesData: vi.fn() +})); + +vi.mock('$features/auth/index.svelte', () => ({ + accessToken: { current: 'test-token' } +})); + +vi.mock('@exceptionless/fetchclient', () => ({ + useFetchClient: () => mocks.client +})); + +vi.mock('@tanstack/svelte-query', () => ({ + createMutation: (factory: () => unknown) => factory(), + createQuery: (factory: () => unknown) => factory(), + useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries, setQueriesData: mocks.setQueriesData }) +})); + +import { + deleteRateNotificationRule, + getRateNotificationRulesQuery, + postRateNotificationRule, + postSnoozeRateNotificationRule, + postUnsnoozeRateNotificationRule, + putRateNotificationRule +} from './api.svelte'; + +interface Mutation { + mutationFn: (variables: TVariables) => Promise; + onSuccess: (data: TData, variables: TVariables) => void; +} + +describe('rate notification API', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.client.delete.mockResolvedValue({}); + mocks.client.getJSON.mockResolvedValue({ data: [] }); + mocks.client.postJSON.mockResolvedValue({ data: { id: 'rule-id' } }); + mocks.client.putJSON.mockResolvedValue({ data: { id: 'rule-id' } }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('reacts when route identifiers become available after query creation', async () => { + // Arrange + const routeState: { projectId?: string; userId?: string } = {}; + const query = getRateNotificationRulesQuery({ + route: { + get projectId() { + return routeState.projectId; + }, + get userId() { + return routeState.userId; + } + } + }) as unknown as { enabled: () => boolean; queryFn: (context: { signal: AbortSignal }) => Promise }; + expect(query.enabled()).toBe(false); + + // Act + routeState.projectId = 'project-id'; + routeState.userId = 'user-id'; + await query.queryFn({ signal: new AbortController().signal }); + + // Assert + expect(query.enabled()).toBe(true); + expect(mocks.client.getJSON).toHaveBeenCalledWith('users/user-id/projects/project-id/rate-notifications', { + params: { limit: 50 }, + signal: expect.any(AbortSignal) + }); + }); + + it('uses mutation variables for every rule-specific route', async () => { + // Arrange + const route = { projectId: 'project-id', userId: 'user-id' }; + const body = { + cooldown: '00:30:00', + is_enabled: true, + name: 'Errors', + signal: RateNotificationSignal.Errors, + stack_id: null, + subject: RateNotificationSubject.Project, + threshold: 10, + window: '00:05:00' + }; + const createMutation = postRateNotificationRule({ route }) as unknown as Mutation; + const deleteMutation = deleteRateNotificationRule({ route }) as unknown as Mutation; + const snoozeMutation = postSnoozeRateNotificationRule({ route }) as unknown as Mutation<{ + body: { duration_seconds: number }; + ruleId: string; + }>; + const unsnoozeMutation = postUnsnoozeRateNotificationRule({ route }) as unknown as Mutation; + const updateMutation = putRateNotificationRule({ route }) as unknown as Mutation<{ body: { is_enabled: boolean }; ruleId: string }>; + + // Act + await createMutation.mutationFn(body); + await updateMutation.mutationFn({ body: { is_enabled: false }, ruleId: 'update-rule' }); + await snoozeMutation.mutationFn({ body: { duration_seconds: 3600 }, ruleId: 'snooze-rule' }); + await unsnoozeMutation.mutationFn('unsnooze-rule'); + await deleteMutation.mutationFn('delete-rule'); + + // Assert + expect(mocks.client.postJSON).toHaveBeenCalledWith('users/user-id/projects/project-id/rate-notifications', body); + expect(mocks.client.putJSON).toHaveBeenCalledWith('users/user-id/projects/project-id/rate-notifications/update-rule', { + is_enabled: false + }); + expect(mocks.client.postJSON).toHaveBeenCalledWith( + 'users/user-id/projects/project-id/rate-notifications/snooze-rule/snooze', + { duration_seconds: 3600 }, + { expectedStatusCodes: [200] } + ); + expect(mocks.client.postJSON).toHaveBeenCalledWith( + 'users/user-id/projects/project-id/rate-notifications/unsnooze-rule/unsnooze', + {}, + { expectedStatusCodes: [200] } + ); + expect(mocks.client.delete).toHaveBeenCalledWith('users/user-id/projects/project-id/rate-notifications/delete-rule', { + expectedStatusCodes: [204] + }); + }); + + it('updates snoozed rules immediately and refreshes after Elasticsearch consistency delay', () => { + // Arrange + vi.useFakeTimers(); + const route = { projectId: 'project-id', userId: 'user-id' }; + const mutation = postSnoozeRateNotificationRule({ route }) as unknown as Mutation< + { body: { duration_seconds: number }; ruleId: string }, + ViewRateNotificationRule + >; + const rule: ViewRateNotificationRule = { + cooldown: '00:30:00', + created_utc: '2026-07-10T00:00:00Z', + id: 'rule-id', + is_enabled: true, + is_snoozed: true, + name: 'Errors', + organization_id: 'organization-id', + project_id: route.projectId, + signal: RateNotificationSignal.Errors, + subject: RateNotificationSubject.Project, + threshold: 10, + updated_utc: '2026-07-10T00:01:00Z', + user_id: route.userId, + version: 2, + window: '00:05:00' + }; + + // Act + mutation.onSuccess(rule, { body: { duration_seconds: 3600 }, ruleId: rule.id }); + + // Assert + expect(mocks.setQueriesData).toHaveBeenCalledOnce(); + const update = mocks.setQueriesData.mock.calls[0]?.[1] as (response: { data: ViewRateNotificationRule[] }) => { + data: ViewRateNotificationRule[]; + }; + const updated = update({ data: [{ ...rule, is_snoozed: false, version: 1 }] }); + expect(updated.data).toEqual([rule]); + expect(mocks.invalidateQueries).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1500); + expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['RateNotificationRule', route.userId, route.projectId] }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte index cca7f22d0c..60adf1f614 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte @@ -1,300 +1,344 @@ -
{ e.preventDefault(); handleSubmit(); }}> + { + event.preventDefault(); + form.handleSubmit(); + }} +> + state.errors}> + {#snippet children(errors)} + + {/snippet} + + {#if !hasPremiumFeatures} - - - Upgrade now to enable and create rate notification rules. - + {/if} - + - -
- - - {#if nameError} -

{nameError}

- {/if} -
- - -
- - - - {SIGNAL_LABELS[signal]} - - - {#each Object.entries(SIGNAL_LABELS) as [value, label] (value)} - {label} - {/each} - - -
+ + + {#snippet children(field)} + + Name + field.handleChange(event.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + - -
- - - - {subject} - - - Project - Stack - - -
+ + {#snippet children(field)} + + Signal + value && field.handleChange(value as RateNotificationSignal)} + > + {SIGNAL_LABELS[field.state.value]} + + + {#each Object.entries(SIGNAL_LABELS) as [value, label] (value)} + {label} + {/each} + + + + + + {/snippet} + - - {#if subject === 'Stack'} -
- - - {#if stackIdError} -

{stackIdError}

- {/if} -
- {/if} + + {#snippet children(field)} + + Subject + { + if (value) { + field.handleChange(value as RateNotificationSubject); + if (value === RateNotificationSubject.Project) { + form.setFieldValue('stack_id', ''); + } + } + }} + > + {SUBJECT_LABELS[field.state.value]} + + + {#each Object.entries(SUBJECT_LABELS) as [value, label] (value)} + {label} + {/each} + + + + + + {/snippet} + - -
- - - {#if thresholdError} -

{thresholdError}

- {/if} -
+ state.values.subject}> + {#snippet children(subject)} + {#if subject === RateNotificationSubject.Stack} + + {#snippet children(field)} + + Stack + field.handleChange(value ?? '')}> + + {stacks.find((stack) => stack.id === field.state.value)?.title ?? + (stacksQuery.isLoading ? 'Loading stacks...' : 'Select a stack')} + + + + {#each stacks as stack (stack.id)} + {stack.title} + {/each} + + + + Choose from the 100 most recently active stacks in this project. + + + {/snippet} + + {/if} + {/snippet} + - -
- - - - {WINDOW_OPTIONS.find((o) => o.value === window)?.label ?? window} - - - {#each WINDOW_OPTIONS as option (option.value)} - {option.label} - {/each} - - -
+ + {#snippet children(field)} + + Threshold (events) + field.handleChange(event.currentTarget.valueAsNumber)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + - -
- - - - {WINDOW_OPTIONS.find((o) => o.value === cooldown)?.label ?? cooldown} - - - {#each WINDOW_OPTIONS as option (option.value)} - {option.label} - {/each} - - 2 hours - 4 hours - 8 hours - 24 hours - - - {#if cooldownError} -

{cooldownError}

- {/if} - Further notifications for this rule are suppressed during the cooldown period. -
+ + {#snippet children(field)} + + Window + value && field.handleChange(value)}> + + {WINDOW_OPTIONS.find((option) => option.value === field.state.value)?.label} + + + + {#each WINDOW_OPTIONS as option (option.value)} + {option.label} + {/each} + + + + + + {/snippet} + - -
- - -
+ + {#snippet children(field)} + + Cooldown + value && field.handleChange(value)}> + + {COOLDOWN_OPTIONS.find((option) => option.value === field.state.value)?.label} + + + + {#each COOLDOWN_OPTIONS as option (option.value)} + {option.label} + {/each} + + + + Further notifications are suppressed during this period. + + + {/snippet} + - {#if formError} -

{formError}

- {/if} + + {#snippet children(field)} + +
+ Enabled + Start evaluating this rule immediately. +
+ field.handleChange(value)} /> +
+ {/snippet} +
+
-
+
{#if onCancel} - + {/if} - + state.isSubmitting}> + {#snippet children(isSubmitting)} + + {/snippet} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte index afee9f76d5..2b444d441c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte @@ -1,70 +1,65 @@ -
+
{#if !hasPremiumFeatures} - - - Upgrade now to enable personal rate notifications! - + {/if} {#if listQuery.isLoading} -
- {#each { length: 2 } as _} -
+
+ {#each [0, 1] as index (index)} + {/each}
+ {:else if listQuery.isError} + {:else if rules.length === 0} -
- -

No rate notification rules yet.

+
+
{:else} -
+
{#each rules as rule (rule.id)}
- +
{SIGNAL_LABELS[rule.signal]} - - ≥{rule.threshold} in {formatWindow(rule.window)} - + ≥{rule.threshold} in {windowLabel(rule.window)} {#if rule.is_snoozed} - - - Snoozed - + {/if} {#if rule.subject === 'Stack' && rule.stack_id} Stack-scoped @@ -162,28 +146,32 @@
- +
@@ -192,25 +180,22 @@ {#if hasPremiumFeatures && rules.length < MAX_RULES_PER_PROJECT} {/if} {/if}
- - !open && (confirmDeleteRuleId = undefined)}> - - - Delete rule? - - This action cannot be undone. The rate notification rule will be permanently deleted. - - - - - - - - + !open && (confirmDeleteRuleId = undefined)}> + + + Delete rule? + This action cannot be undone. The rate notification rule will be permanently deleted. + + + Cancel + Delete + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts index 8e3c411379..b565bbac10 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts @@ -1,5 +1,5 @@ +export * from './api.svelte'; // Rate notifications feature barrel export export { default as RateNotificationRuleForm } from './components/rate-notification-rule-form.svelte'; export { default as RateNotificationRuleList } from './components/rate-notification-rule-list.svelte'; -export * from './api.svelte'; export * from './types'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts new file mode 100644 index 0000000000..c8ac94de03 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts @@ -0,0 +1,92 @@ +import { RateNotificationSignal, RateNotificationSubject, type ViewRateNotificationRule } from '$generated/api'; +import { describe, expect, it } from 'vitest'; + +import { getRateNotificationRuleFormData, RateNotificationRuleSchema, toRateNotificationRuleRequest } from './schemas'; + +describe('RateNotificationRuleSchema', () => { + it('requires a stack for stack-scoped rules', () => { + const result = RateNotificationRuleSchema.safeParse({ + cooldown: '00:30:00', + is_enabled: true, + name: 'Stack errors', + signal: RateNotificationSignal.Errors, + stack_id: '', + subject: RateNotificationSubject.Stack, + threshold: 10, + window: '00:05:00' + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toContainEqual(expect.objectContaining({ path: ['stack_id'] })); + }); + + it('rejects a cooldown shorter than the window', () => { + const result = RateNotificationRuleSchema.safeParse({ + cooldown: '00:05:00', + is_enabled: true, + name: 'Slow cooldown', + signal: RateNotificationSignal.Errors, + stack_id: '', + subject: RateNotificationSubject.Project, + threshold: 10, + window: '00:30:00' + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toContainEqual(expect.objectContaining({ path: ['cooldown'] })); + }); +}); + +describe('rate notification form mapping', () => { + it('resets all editable values from the selected rule', () => { + const rule: ViewRateNotificationRule = { + cooldown: '01:00:00', + created_utc: '2026-07-10T12:00:00Z', + id: 'rule-id', + is_enabled: false, + is_snoozed: false, + name: 'Selected rule', + organization_id: 'organization-id', + project_id: 'project-id', + signal: RateNotificationSignal.CriticalErrors, + stack_id: 'stack-id', + subject: RateNotificationSubject.Stack, + threshold: 25, + updated_utc: '2026-07-10T12:00:00Z', + user_id: 'user-id', + version: 2, + window: '00:15:00' + }; + + expect(getRateNotificationRuleFormData(rule)).toEqual({ + cooldown: '01:00:00', + is_enabled: false, + name: 'Selected rule', + signal: RateNotificationSignal.CriticalErrors, + stack_id: 'stack-id', + subject: RateNotificationSubject.Stack, + threshold: 25, + window: '00:15:00' + }); + }); + + it('clears stack scope and disables the request when unavailable', () => { + const request = toRateNotificationRuleRequest( + { + cooldown: '00:30:00', + is_enabled: true, + name: ' Project errors ', + signal: RateNotificationSignal.Errors, + stack_id: 'stale-stack-id', + subject: RateNotificationSubject.Project, + threshold: 10, + window: '00:05:00' + }, + false + ); + + expect(request.is_enabled).toBe(false); + expect(request.name).toBe('Project errors'); + expect(request.stack_id).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts new file mode 100644 index 0000000000..8ba12649de --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts @@ -0,0 +1,63 @@ +import type { NewRateNotificationRule, ViewRateNotificationRule } from '$generated/api'; + +import { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; +import { NewRateNotificationRuleSchema } from '$generated/schemas'; +import { type infer as Infer, string } from 'zod'; + +function durationSeconds(value: string): number { + const [hours = '0', minutes = '0', seconds = '0'] = value.split(':'); + return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds); +} + +export const RateNotificationRuleSchema = NewRateNotificationRuleSchema.extend({ + stack_id: string().optional() +}).superRefine((value, context) => { + if (value.subject === RateNotificationSubject.Stack && !value.stack_id) { + context.addIssue({ code: 'custom', message: 'Select a stack.', path: ['stack_id'] }); + } + + if (durationSeconds(value.cooldown) < durationSeconds(value.window)) { + context.addIssue({ code: 'custom', message: 'Cooldown must be at least as long as the window.', path: ['cooldown'] }); + } +}); + +export type RateNotificationRuleFormData = Infer; + +export const DEFAULT_RATE_NOTIFICATION_RULE: RateNotificationRuleFormData = { + cooldown: '00:30:00', + is_enabled: true, + name: '', + signal: RateNotificationSignal.Errors, + stack_id: '', + subject: RateNotificationSubject.Project, + threshold: 10, + window: '00:05:00' +}; + +export function getRateNotificationRuleFormData(value: undefined | ViewRateNotificationRule): RateNotificationRuleFormData { + return value + ? { + cooldown: value.cooldown, + is_enabled: value.is_enabled, + name: value.name, + signal: value.signal, + stack_id: value.stack_id ?? '', + subject: value.subject, + threshold: value.threshold, + window: value.window + } + : { ...DEFAULT_RATE_NOTIFICATION_RULE }; +} + +export function toRateNotificationRuleRequest(value: RateNotificationRuleFormData, enabled: boolean): NewRateNotificationRule { + return { + cooldown: value.cooldown, + is_enabled: enabled && value.is_enabled, + name: value.name.trim(), + signal: value.signal as RateNotificationSignal, + stack_id: value.subject === RateNotificationSubject.Stack ? value.stack_id || null : null, + subject: value.subject as RateNotificationSubject, + threshold: value.threshold, + window: value.window + }; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts index 7f56f5bbbd..2ab3370c2b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts @@ -1,78 +1,36 @@ -// Types matching the RateNotificationRule API DTOs +import { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; -export type RateNotificationSignal = 'AllEvents' | 'Errors' | 'CriticalErrors' | 'NewErrors' | 'Regressions'; -export type RateNotificationSubject = 'Project' | 'Stack'; +export type { NewRateNotificationRule, SnoozeRateNotificationRuleRequest, UpdateRateNotificationRule, ViewRateNotificationRule } from '$generated/api'; +export { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; -export interface ViewRateNotificationRule { - id: string; - organization_id: string; - project_id: string; - user_id: string; - version: number; - name: string; - is_enabled: boolean; - signal: RateNotificationSignal; - subject: RateNotificationSubject; - stack_id?: string; - threshold: number; - /** ISO 8601 duration string (e.g. "00:05:00") */ - window: string; - /** ISO 8601 duration string */ - cooldown: string; - snoozed_until_utc?: string; - last_fired_utc?: string; - created_utc: string; - updated_utc: string; - /** Computed: snoozed_until_utc is in the future */ - is_snoozed: boolean; -} - -export interface NewRateNotificationRule { - name: string; - signal: RateNotificationSignal; - subject: RateNotificationSubject; - stack_id?: string; - threshold: number; - /** ISO 8601 duration string (e.g. "00:05:00") */ - window: string; - /** ISO 8601 duration string */ - cooldown: string; - is_enabled: boolean; -} - -export interface UpdateRateNotificationRule { - name?: string; - signal?: RateNotificationSignal; - subject?: RateNotificationSubject; - stack_id?: string; - threshold?: number; - window?: string; - cooldown?: string; - is_enabled?: boolean; -} - -export interface SnoozeRateNotificationRuleRequest { - duration_seconds?: number; - until_utc?: string; -} +export const MAX_RULES_PER_PROJECT = 20; -/** Friendly labels for signal enum values */ export const SIGNAL_LABELS: Record = { - AllEvents: 'All Events', - CriticalErrors: 'Critical Errors', - Errors: 'Errors', - NewErrors: 'New Errors', - Regressions: 'Regressions' + [RateNotificationSignal.AllEvents]: 'All Events', + [RateNotificationSignal.CriticalErrors]: 'Critical Errors', + [RateNotificationSignal.Errors]: 'Errors', + [RateNotificationSignal.NewErrors]: 'New Errors', + [RateNotificationSignal.Regressions]: 'Regressions' }; -/** Allowed window durations (as ISO 8601) mapped to friendly labels */ -export const WINDOW_OPTIONS: { label: string; value: string }[] = [ +export const SUBJECT_LABELS: Record = { + [RateNotificationSubject.Project]: 'Project', + [RateNotificationSubject.Stack]: 'Stack' +}; + +export const WINDOW_OPTIONS = [ { label: '1 minute', value: '00:01:00' }, { label: '5 minutes', value: '00:05:00' }, { label: '10 minutes', value: '00:10:00' }, { label: '15 minutes', value: '00:15:00' }, { label: '30 minutes', value: '00:30:00' }, { label: '1 hour', value: '01:00:00' } -]; - -export const MAX_RULES_PER_PROJECT = 20; +] as const; + +export const COOLDOWN_OPTIONS = [ + ...WINDOW_OPTIONS, + { label: '2 hours', value: '02:00:00' }, + { label: '4 hours', value: '04:00:00' }, + { label: '8 hours', value: '08:00:00' }, + { label: '24 hours', value: '24:00:00' } +] as const; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 1e8554a074..4ab15d7446 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -7,6 +7,19 @@ export enum StackStatus { Discarded = "discarded", } +export enum RateNotificationSubject { + Project = "Project", + Stack = "Stack", +} + +export enum RateNotificationSignal { + AllEvents = "AllEvents", + Errors = "Errors", + CriticalErrors = "CriticalErrors", + NewErrors = "NewErrors", + Regressions = "Regressions", +} + export enum BillingStatus { Trialing = 0, Active = 1, @@ -133,6 +146,25 @@ export interface NewProject { promoted_tabs?: string[] | null; } +export interface NewRateNotificationRule { + name: string; + signal: RateNotificationSignal; + subject: RateNotificationSubject; + /** @pattern ^[a-fA-F0-9]{24}$ */ + stack_id?: null | string; + /** + * @format int32 + * @min 1 + * @max 2147483647 + */ + threshold: number; + /** @pattern ^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$ */ + window: string; + /** @pattern ^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$ */ + cooldown: string; + is_enabled: boolean; +} + export interface NewSavedView { /** @pattern ^[a-fA-F0-9]{24}$ */ organization_id: string; @@ -350,6 +382,15 @@ export interface PredefinedSavedViewDefinition { showChart?: null | boolean; } +export interface ProblemDetails { + type?: null | string; + title?: null | string; + /** @format int32 */ + status?: null | number; + detail?: null | string; + instance?: null | string; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -363,6 +404,21 @@ export interface Signup { invite_token?: null | string; } +export interface SnoozeRateNotificationRuleRequest { + /** + * Snooze duration in seconds. Mutually exclusive with UntilUtc. + * @format int32 + * @min 1 + * @max 2147483647 + */ + duration_seconds?: null | number; + /** + * Snooze until this UTC timestamp. Mutually exclusive with DurationSeconds. + * @format date-time + */ + until_utc?: null | string; +} + export interface Stack { /** * Unique id that identifies a stack. @@ -465,6 +521,25 @@ export interface UpdateProject { promoted_tabs?: string[] | null; } +export interface UpdateRateNotificationRule { + name?: null | string; + signal?: null | RateNotificationSignal; + subject?: null | RateNotificationSubject; + /** @pattern ^[a-fA-F0-9]{24}$ */ + stack_id?: null | string; + /** + * @format int32 + * @min 1 + * @max 2147483647 + */ + threshold?: null | number; + /** @pattern ^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$ */ + window?: null | string; + /** @pattern ^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$ */ + cooldown?: null | string; + is_enabled?: null | boolean; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateSavedView { name?: null | string; @@ -685,6 +760,35 @@ export interface ViewProject { usage: UsageInfo[]; } +export interface ViewRateNotificationRule { + id: string; + organization_id: string; + project_id: string; + user_id: string; + /** @format int32 */ + version: number; + name: string; + is_enabled: boolean; + signal: RateNotificationSignal; + subject: RateNotificationSubject; + stack_id?: null | string; + /** @format int32 */ + threshold: number; + /** @pattern ^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$ */ + window: string; + /** @pattern ^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$ */ + cooldown: string; + /** @format date-time */ + snoozed_until_utc?: null | string; + is_snoozed: boolean; + /** @format date-time */ + last_fired_utc?: null | string; + /** @format date-time */ + created_utc: string; + /** @format date-time */ + updated_utc: string; +} + export interface ViewSavedView { /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 32ef74643c..2bcabed1d7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -27,6 +27,14 @@ export const StackStatusSchema = zodEnum([ "ignored", "discarded", ]); +export const RateNotificationSubjectSchema = zodEnum(["Project", "Stack"]); +export const RateNotificationSignalSchema = zodEnum([ + "AllEvents", + "Errors", + "CriticalErrors", + "NewErrors", + "Regressions", +]); export const BillingStatusSchema = union([ literal(0), literal(1), @@ -173,6 +181,38 @@ export const NewProjectSchema = object({ }); export type NewProjectFormData = Infer; +export const NewRateNotificationRuleSchema = object({ + name: string() + .min(1, "Name is required") + .max(100, "Name must be at most 100 characters"), + signal: RateNotificationSignalSchema, + subject: RateNotificationSubjectSchema, + stack_id: string() + .length(24, "Stack id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Stack id has invalid format") + .nullable() + .optional(), + threshold: int32() + .min(1, "Threshold must be at least 1") + .max(2147483647, "Threshold must be at most 2147483647"), + window: string() + .min(1, "Window is required") + .regex( + /^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$/, + "Window has invalid format", + ), + cooldown: string() + .min(1, "Cooldown is required") + .regex( + /^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$/, + "Cooldown has invalid format", + ), + is_enabled: boolean(), +}); +export type NewRateNotificationRuleFormData = Infer< + typeof NewRateNotificationRuleSchema +>; + export const NewSavedViewSchema = object({ organization_id: string() .length(24, "Organization id must be exactly 24 characters") @@ -441,6 +481,15 @@ export type PredefinedSavedViewDefinitionFormData = Infer< typeof PredefinedSavedViewDefinitionSchema >; +export const ProblemDetailsSchema = object({ + type: string().min(1, "Type is required").nullable().optional(), + title: string().min(1, "Title is required").nullable().optional(), + status: int32().nullable().optional(), + detail: string().min(1, "Detail is required").nullable().optional(), + instance: string().min(1, "Instance is required").nullable().optional(), +}); +export type ProblemDetailsFormData = Infer; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -465,6 +514,18 @@ export const SignupSchema = object({ }); export type SignupFormData = Infer; +export const SnoozeRateNotificationRuleRequestSchema = object({ + duration_seconds: int32() + .min(1, "Duration seconds must be at least 1") + .max(2147483647, "Duration seconds must be at most 2147483647") + .nullable() + .optional(), + until_utc: iso.datetime().nullable().optional(), +}); +export type SnoozeRateNotificationRuleRequestFormData = Infer< + typeof SnoozeRateNotificationRuleRequestSchema +>; + export const StackSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") @@ -542,6 +603,46 @@ export const UpdateProjectSchema = object({ }); export type UpdateProjectFormData = Infer; +export const UpdateRateNotificationRuleSchema = object({ + name: string() + .min(1, "Name is required") + .max(100, "Name must be at most 100 characters") + .nullable() + .optional(), + signal: RateNotificationSignalSchema.optional(), + subject: RateNotificationSubjectSchema.optional(), + stack_id: string() + .length(24, "Stack id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Stack id has invalid format") + .nullable() + .optional(), + threshold: int32() + .min(1, "Threshold must be at least 1") + .max(2147483647, "Threshold must be at most 2147483647") + .nullable() + .optional(), + window: string() + .min(1, "Window is required") + .regex( + /^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$/, + "Window has invalid format", + ) + .nullable() + .optional(), + cooldown: string() + .min(1, "Cooldown is required") + .regex( + /^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$/, + "Cooldown has invalid format", + ) + .nullable() + .optional(), + is_enabled: boolean().nullable().optional(), +}); +export type UpdateRateNotificationRuleFormData = Infer< + typeof UpdateRateNotificationRuleSchema +>; + export const UpdateSavedViewSchema = object({ name: string().min(1, "Name is required").nullable().optional(), filter: string().min(1, "Filter is required").nullable().optional(), @@ -751,6 +852,40 @@ export const ViewProjectSchema = object({ }); export type ViewProjectFormData = Infer; +export const ViewRateNotificationRuleSchema = object({ + id: string().min(1, "Id is required"), + organization_id: string().min(1, "Organization id is required"), + project_id: string().min(1, "Project id is required"), + user_id: string().min(1, "User id is required"), + version: int32(), + name: string().min(1, "Name is required"), + is_enabled: boolean(), + signal: RateNotificationSignalSchema, + subject: RateNotificationSubjectSchema, + stack_id: string().min(1, "Stack id is required").nullable().optional(), + threshold: int32(), + window: string() + .min(1, "Window is required") + .regex( + /^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$/, + "Window has invalid format", + ), + cooldown: string() + .min(1, "Cooldown is required") + .regex( + /^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$/, + "Cooldown has invalid format", + ), + snoozed_until_utc: iso.datetime().nullable().optional(), + is_snoozed: boolean(), + last_fired_utc: iso.datetime().nullable().optional(), + created_utc: iso.datetime(), + updated_utc: iso.datetime(), +}); +export type ViewRateNotificationRuleFormData = Infer< + typeof ViewRateNotificationRuleSchema +>; + export const ViewSavedViewSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte index c23b8fe355..429a1f066b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte @@ -9,6 +9,7 @@ import { Skeleton } from '$comp/ui/skeleton'; import { Switch } from '$comp/ui/switch'; import { showUpgradeDialog } from '$features/billing/upgrade-required.svelte'; + import { getOrganizationQuery } from '$features/organizations/api.svelte'; import { getProjectsQuery, getProjectUserNotificationSettings, postProjectUserNotificationSettings } from '$features/projects/api.svelte'; import UserNotificationSettingsForm from '$features/projects/components/user-notification-settings-form.svelte'; import RateNotificationRuleForm from '$features/rate-notifications/components/rate-notification-rule-form.svelte'; @@ -26,7 +27,7 @@ // Rate notification dialog state let rateRuleDialogOpen = $state(false); - let editingRateRule = $state(undefined); + let editingRateRule = $state(undefined); const meQuery = getMeQuery(); const queryParams = queryParamsState({ @@ -57,6 +58,14 @@ }); const selectedProject = $derived(allProjects.find((p) => p.id === queryParams.project) ?? allProjects[0]); + const selectedOrganizationQuery = getOrganizationQuery({ + route: { + get id() { + return selectedProject?.organization_id; + } + } + }); + const rateNotificationsEnabled = $derived(selectedOrganizationQuery.data?.features.includes('rate-notifications') ?? false); const selectedProjectNotificationSettings = getProjectUserNotificationSettings({ route: { get id() { @@ -231,40 +240,44 @@ hasPremiumFeatures={selectedProject.has_premium_features} /> -
-

Rate Notifications

- Get notified when event rates for this project exceed your custom thresholds. -
- - - {/if} -
+ {#if rateNotificationsEnabled} +
+

Rate Notifications

+ Get notified when event rates for this project exceed your custom thresholds. +
- !open && closeRateRuleDialog()}> - - - {editingRateRule ? 'Edit Rate Notification Rule' : 'Create Rate Notification Rule'} - - {editingRateRule ? 'Update the rule settings below.' : 'Configure when you want to receive an email notification based on event rates.'} - - - {#if selectedProject} - {/if} - - + {/if} +
+ +{#if rateNotificationsEnabled} + !open && closeRateRuleDialog()}> + + + {editingRateRule ? 'Edit Rate Notification Rule' : 'Create Rate Notification Rule'} + + {editingRateRule ? 'Update the rule settings below.' : 'Configure when you want to receive an email notification based on event rates.'} + + + {#if selectedProject} + + {/if} + + +{/if} From 1ef9c39bc52b41ff99ba089dd2c8be6f02c9913e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 11:34:55 -0500 Subject: [PATCH 17/18] fix: finish rate notification production hardening --- .../add-personal-rate-notifications/design.md | 2 +- .../add-personal-rate-notifications/tasks.md | 58 ++++---- .../Jobs/RateNotificationEvaluatorJob.cs | 17 ++- .../IRateNotificationRuleRepository.cs | 1 + .../RateNotificationRuleRepository.cs | 14 +- .../Services/OrganizationService.cs | 40 ++++- .../Services/RateNotificationRuleCache.cs | 11 +- .../src/pages/rate-notification.html | 37 +++++ .../Controllers/ProjectController.cs | 8 +- .../RateNotificationRuleController.cs | 26 +++- .../OrganizationControllerTests.cs | 14 ++ .../Controllers/ProjectControllerTests.cs | 14 ++ .../RateNotificationRuleControllerTests.cs | 108 ++++++++++++++ .../Jobs/CleanupDataJobTests.cs | 24 +++ .../Jobs/RateNotificationEvaluatorJobTests.cs | 32 +++- .../Jobs/RateNotificationsJobTests.cs | 83 +++++++++++ ...pdateRateCountersActionIntegrationTests.cs | 138 ++++++++++++++++++ 17 files changed, 571 insertions(+), 56 deletions(-) create mode 100644 src/Exceptionless.EmailTemplates/src/pages/rate-notification.html create mode 100644 tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionIntegrationTests.cs diff --git a/openspec/changes/add-personal-rate-notifications/design.md b/openspec/changes/add-personal-rate-notifications/design.md index 416337c9dc..a00491a121 100644 --- a/openspec/changes/add-personal-rate-notifications/design.md +++ b/openspec/changes/add-personal-rate-notifications/design.md @@ -171,7 +171,7 @@ project:{projectId}:stack:{stackId}:signal:AllEvents - Counter bucket TTL: 3 hours - Active bucket TTL: 3 hours -- Cooldown TTL: cooldown duration + 10 minutes +- Cooldown TTL: configured cooldown duration ### Signal matching diff --git a/openspec/changes/add-personal-rate-notifications/tasks.md b/openspec/changes/add-personal-rate-notifications/tasks.md index 2744258d0a..47f1445c33 100644 --- a/openspec/changes/add-personal-rate-notifications/tasks.md +++ b/openspec/changes/add-personal-rate-notifications/tasks.md @@ -2,36 +2,36 @@ ## Backend - Models and Repositories -- [ ] **Add RateNotificationRule model and enums** +- [x] **Add RateNotificationRule model and enums** - Create `RateNotificationRule` model with all fields (Id, OrganizationId, ProjectId, UserId, Version, Name, IsEnabled, Signal, Subject, StackId, Threshold, Window, Cooldown, SnoozedUntilUtc, CreatedUtc, UpdatedUtc, IsDeleted) - Create `RateNotificationSignal` enum (AllEvents, Errors, CriticalErrors, NewErrors, Regressions) - Create `RateNotificationSubject` enum (Project, Stack) -- [ ] **Add IRateNotificationRuleRepository and implementation** +- [x] **Add IRateNotificationRuleRepository and implementation** - Create interface extending `IRepositoryOwnedByOrganizationAndProject` - Add methods: `GetByProjectIdAndUserIdAsync`, `GetEnabledByProjectIdAsync`, `CountByProjectIdAndUserIdAsync` - Create Elasticsearch-backed implementation following existing repository patterns -- [ ] **Register repository in DI** +- [x] **Register repository in DI** - Register `IRateNotificationRuleRepository` / `RateNotificationRuleRepository` in `Bootstrapper.cs` ## Backend - Countering and Evaluation -- [ ] **Add RateNotificationRuleIndex service** +- [x] **Add RateNotificationRuleCache service** - Load enabled rules for a project from repository - - Cache compiled counter definitions with short TTL + - Cache enabled project rules with a short TTL - Invalidate on rule create/update/delete/snooze/unsnooze - Cache key: `rate:v1:rules:project:{projectId}` -- [ ] **Add RateCounterService** +- [x] **Add RateCounterService** - Implement 1-minute UTC bucket counters via `ICacheClient` - Methods: increment counter, sum buckets for window, check/set cooldown - Counter key format: `rate:v1:count:{epochMinute}:{counterKey}` - Active bucket tracking: `rate:v1:active:{epochMinute}` - Cooldown key format: `rate:v1:cooldown:{ruleId}:{subjectKey}` - - TTLs: counter/active = 3h, cooldown = cooldown + 10m + - TTLs: counter/active = 3h, cooldown = configured cooldown -- [ ] **Add UpdateRateCountersAction (event pipeline)** +- [x] **Add UpdateRateCountersAction (event pipeline)** - Priority after stack assignment (e.g., 75) - Exit fast if organization lacks premium features - Load rule index for project; exit fast if no enabled rules @@ -41,7 +41,7 @@ - Increment matching counters; add to active bucket set - Never query Elasticsearch per event; never send notifications directly -- [ ] **Add RateNotificationEvaluatorJob** +- [x] **Add RateNotificationEvaluatorJob** - Periodic job (60s interval) with distributed lock - Skip organizations without premium features - Inspect recently active counters @@ -52,71 +52,71 @@ - Set cooldown key on successful enqueue - Structured logging for fired/skipped reasons -- [ ] **Add RateNotification queue model** +- [x] **Add RateNotification queue model** - Fields: RuleId, RuleVersion, OrganizationId, ProjectId, UserId, SubjectKey, StackId, WindowStartUtc, WindowEndUtc, ObservedCount, Threshold -- [ ] **Register counter/evaluator services in DI** - - Register `RateNotificationRuleIndex`, `RateCounterService`, `RateNotificationEvaluatorJob` +- [x] **Register counter/evaluator services in DI** + - Register `RateNotificationRuleCache`, `RateCounterService`, `RateNotificationEvaluatorJob` - Register `IQueue` in Core and Insulation bootstrappers ## Backend - Delivery -- [ ] **Add RateNotificationsJob (delivery)** +- [x] **Add RateNotificationsJob (delivery)** - Load rule, project, user - Load stack for stack-scoped rules - Validate: rule exists, enabled, version compatible, user in org, email verified, notifications enabled, project/org exists - Send email via `IMailer.SendRateNotificationAsync` - Skip with structured logs on validation failure -- [ ] **Add IMailer.SendRateNotificationAsync** +- [x] **Add IMailer.SendRateNotificationAsync** - Add method to `IMailer` interface and `Mailer` implementation - Email includes: rule name, project name, observed count, threshold, window, subject type, stack title (when applicable), link, cooldown explanation - Subject format: `[ProjectName] Error rate exceeded` - No "everything is fine" messaging -- [ ] **Update Insulation queue registration** +- [x] **Update Insulation queue registration** - Register `IQueue` for Redis/Azure/SQS providers in `Exceptionless.Insulation/Bootstrapper.cs` ## Backend - API -- [ ] **Add RateNotificationRuleController** +- [x] **Add RateNotificationRuleController** - Routes: GET list, POST create, GET by id, PUT update, DELETE, POST snooze, POST unsnooze - Authorization: current user manages own rules; global admin manages any user's rules; user must access project org - Validation: threshold > 0, supported windows, cooldown ≥ window, subject/stack consistency, max 20 rules per user per project, name non-empty and ≤ 100 chars - Preserve persisted rules across plan downgrade, but do not allow the runtime or Svelte UI to treat non-premium orgs as active rate-notification senders -- [ ] **Add request/response DTOs** +- [x] **Add request/response DTOs** - Create/update request models with validation attributes - Snooze request model (duration or until timestamp) -- [ ] **Update tests/http files** +- [x] **Update tests/http files** - Add HTTP sample requests for all rate notification endpoints ## Frontend -- [ ] **Add rate-notifications feature module** +- [x] **Add rate-notifications feature module** - Create `src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/` - Models/types matching API DTOs - TanStack Query API wrappers (list, create, update, delete, snooze, unsnooze) -- [ ] **Add rate notification rule list component** +- [x] **Add rate notification rule list component** - List rules for current user/project - Show enable/disable toggle, snooze status - Show premium-gated disabled state / upgrade messaging when org lacks premium features - Delete confirmation -- [ ] **Add rate notification rule form component** +- [x] **Add rate notification rule form component** - Fields: Name, Signal, Subject, Stack selector, Threshold, Window, Cooldown, Enabled - Validation matching backend constraints - Noise warning copy: "This rule may be noisy. Use a cooldown to avoid repeated emails." -- [ ] **Integrate into project settings** - - Add rate notifications section/tab in project notification settings - - Route and navigation entry +- [x] **Integrate into account notification settings** + - Add a project-scoped rate notifications section to account notification settings + - Reuse the existing account notifications route and project selector ## Tests -- [ ] **Add unit tests** +- [x] **Add unit tests** - Rule validation (threshold, window, cooldown, subject/stack, name) - Counter key builder - Counter increments and bucket summing @@ -128,7 +128,7 @@ - Fresh-baseline behavior after snooze/unsnooze - Evaluator threshold crossing -- [ ] **Add integration tests** +- [x] **Add integration tests** - User CRUD own rules (list, create, get, update, delete) - User cannot manage another user's rules - Global admin can manage another user's rules @@ -148,16 +148,16 @@ - Delivery loads stack context for stack-scoped emails - Membership/project/org cleanup removes orphaned rules -- [ ] **Add lifecycle cleanup** +- [x] **Add lifecycle cleanup** - Remove/invalidate rules when user membership changes - Remove/invalidate rules when project or organization is deleted ## Validation -- [ ] **Run OpenSpec validation** +- [x] **Run OpenSpec validation** - `openspec validate add-personal-rate-notifications --strict` -- [ ] **Run relevant builds and tests** +- [x] **Run relevant builds and tests** - `dotnet build` - `dotnet test` - Frontend build and lint diff --git a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs index c3f2faccca..0308b1b0dc 100644 --- a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs +++ b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs @@ -99,16 +99,25 @@ protected override async Task RunInternalAsync(JobContext context) private async Task EvaluateProjectAsync(string projectId, IEnumerable counterKeys, DateTime evaluationEndUtc, CancellationToken ct) { - var allProjectRules = await _ruleRepository.GetEnabledByProjectIdAsync(projectId, o => o.PageLimit(1000)); - if (allProjectRules.Documents.Count == 0) + var ruleResults = await _ruleRepository.GetEnabledByProjectIdAsync(projectId, o => o.SearchAfterPaging().PageLimit(1000)); + var allProjectRules = new List(); + while (ruleResults.Documents.Count > 0) + { + ct.ThrowIfCancellationRequested(); + allProjectRules.AddRange(ruleResults.Documents); + if (!await ruleResults.NextPageAsync()) + break; + } + + if (allProjectRules.Count == 0) return; - string organizationId = allProjectRules.Documents.First().OrganizationId; + string organizationId = allProjectRules[0].OrganizationId; var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); if (organization is null || !organization.HasRateNotifications()) return; - var rulesByCounterKey = allProjectRules.Documents + var rulesByCounterKey = allProjectRules .GroupBy(UpdateRateCountersAction.BuildCounterKey, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal); diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs index bf7dd36e23..3e575c60c1 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs @@ -7,6 +7,7 @@ namespace Exceptionless.Core.Repositories; public interface IRateNotificationRuleRepository : IRepositoryOwnedByOrganizationAndProject { Task> GetByProjectIdAndUserIdAsync(string projectId, string userId, CommandOptionsDescriptor? options = null); + Task> GetByOrganizationIdAndUserIdAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null); Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null); Task CountByProjectIdAndUserIdAsync(string projectId, string userId); Task RemoveAllByOrganizationIdAndUserIdAsync(string organizationId, string userId); diff --git a/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs index 6caae70608..94526419af 100644 --- a/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs +++ b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs @@ -24,6 +24,17 @@ public Task> GetByProjectIdAndUserIdAsync(stri .SortAscending(r => r.Name), options); } + public Task> GetByOrganizationIdAndUserIdAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(organizationId); + ArgumentException.ThrowIfNullOrEmpty(userId); + + return FindAsync(q => q + .Organization(organizationId) + .FieldEquals(r => r.UserId, userId) + .SortAscending(r => r.Id), options); + } + public Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null) { ArgumentException.ThrowIfNullOrEmpty(projectId); @@ -31,7 +42,8 @@ public Task> GetEnabledByProjectIdAsync(string return FindAsync(q => q .Project(projectId) .FieldEquals(r => r.IsEnabled, true) - .FieldEquals(r => r.IsDeleted, false), options); + .FieldEquals(r => r.IsDeleted, false) + .SortAscending(r => r.Id), options); } public async Task CountByProjectIdAndUserIdAsync(string projectId, string userId) diff --git a/src/Exceptionless.Core/Services/OrganizationService.cs b/src/Exceptionless.Core/Services/OrganizationService.cs index 5b382bfe6d..a2b995ac46 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -15,6 +15,7 @@ public class OrganizationService : IStartupAction private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly IRateNotificationRuleRepository _rateNotificationRuleRepository; + private readonly RateNotificationRuleCache _rateNotificationRuleCache; private readonly ISavedViewRepository _savedViewRepository; private readonly ITokenRepository _tokenRepository; private readonly IUserRepository _userRepository; @@ -23,11 +24,12 @@ public class OrganizationService : IStartupAction private readonly UsageService _usageService; private readonly ILogger _logger; - public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IRateNotificationRuleRepository rateNotificationRuleRepository, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, IStripeBillingClient stripeBillingClient, UsageService usageService, ILoggerFactory loggerFactory) + public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IRateNotificationRuleRepository rateNotificationRuleRepository, RateNotificationRuleCache rateNotificationRuleCache, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, IStripeBillingClient stripeBillingClient, UsageService usageService, ILoggerFactory loggerFactory) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; _rateNotificationRuleRepository = rateNotificationRuleRepository; + _rateNotificationRuleCache = rateNotificationRuleCache; _savedViewRepository = savedViewRepository; _tokenRepository = tokenRepository; _userRepository = userRepository; @@ -195,22 +197,46 @@ public Task RemoveUserSavedViewsAsync(string organizationId, string userId return _savedViewRepository.RemovePrivateByUserIdAsync(organizationId, userId); } - public Task RemoveRateNotificationRulesAsync(string organizationId) + public async Task RemoveRateNotificationRulesAsync(string organizationId) { _logger.LogDebug("Removing rate notification rules for organization {OrganizationId}", organizationId); - return _rateNotificationRuleRepository.RemoveAllByOrganizationIdAsync(organizationId); + var projectResults = await _projectRepository.GetByOrganizationIdAsync(organizationId, o => o.SearchAfterPaging().PageLimit(BATCH_SIZE)); + var projectIds = new HashSet(StringComparer.Ordinal); + while (projectResults.Documents.Count > 0) + { + projectIds.UnionWith(projectResults.Documents.Select(project => project.Id)); + if (!await projectResults.NextPageAsync()) + break; + } + + long removed = await _rateNotificationRuleRepository.RemoveAllByOrganizationIdAsync(organizationId); + await Task.WhenAll(projectIds.Select(_rateNotificationRuleCache.InvalidateAsync)); + return removed; } - public Task RemoveProjectRateNotificationRulesAsync(string organizationId, string projectId) + public async Task RemoveProjectRateNotificationRulesAsync(string organizationId, string projectId) { _logger.LogDebug("Removing rate notification rules for project {ProjectId} in organization {OrganizationId}", projectId, organizationId); - return _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(organizationId, projectId); + long removed = await _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(organizationId, projectId); + await _rateNotificationRuleCache.InvalidateAsync(projectId); + return removed; } - public Task RemoveUserRateNotificationRulesAsync(string organizationId, string userId) + public async Task RemoveUserRateNotificationRulesAsync(string organizationId, string userId) { _logger.LogDebug("Removing rate notification rules for user {UserId} in organization {OrganizationId}", userId, organizationId); - return _rateNotificationRuleRepository.RemoveAllByOrganizationIdAndUserIdAsync(organizationId, userId); + var ruleResults = await _rateNotificationRuleRepository.GetByOrganizationIdAndUserIdAsync(organizationId, userId, o => o.SearchAfterPaging().PageLimit(BATCH_SIZE)); + var projectIds = new HashSet(StringComparer.Ordinal); + while (ruleResults.Documents.Count > 0) + { + projectIds.UnionWith(ruleResults.Documents.Select(rule => rule.ProjectId)); + if (!await ruleResults.NextPageAsync()) + break; + } + + long removed = await _rateNotificationRuleRepository.RemoveAllByOrganizationIdAndUserIdAsync(organizationId, userId); + await Task.WhenAll(projectIds.Select(_rateNotificationRuleCache.InvalidateAsync)); + return removed; } public async Task SoftDeleteOrganizationAsync(Organization organization, string currentUserId) diff --git a/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs index 2f30c86ee3..139b284ff8 100644 --- a/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs +++ b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs @@ -32,8 +32,15 @@ public async Task> GetEnabledRulesAsync(stri if (cached.HasValue && cached.Value is not null) return cached.Value; - var results = await _repository.GetEnabledByProjectIdAsync(projectId, o => o.PageLimit(1000)); - var rules = results.Documents.ToList(); + var results = await _repository.GetEnabledByProjectIdAsync(projectId, o => o.SearchAfterPaging().PageLimit(1000)); + var rules = new List(); + while (results.Documents.Count > 0) + { + ct.ThrowIfCancellationRequested(); + rules.AddRange(results.Documents); + if (!await results.NextPageAsync()) + break; + } await _cache.SetAsync(cacheKey, rules, CacheTtl); return rules; diff --git a/src/Exceptionless.EmailTemplates/src/pages/rate-notification.html b/src/Exceptionless.EmailTemplates/src/pages/rate-notification.html new file mode 100644 index 0000000000..6aa0dc386a --- /dev/null +++ b/src/Exceptionless.EmailTemplates/src/pages/rate-notification.html @@ -0,0 +1,37 @@ + + + + +

+ Your rate notification rule “\{{RuleName}}” has fired for the \{{ProjectName}} project. +

+ + +

Events Observed
\{{ObservedCount}} events (threshold: \{{Threshold}}) in the last \{{Window}}

+
+

Signal
\{{Signal}}

+
+

+ Scope
+ \{{Subject_Type}}\{{#if HasStack}} — \{{StackTitle}}\{{/if}} +

+
+

Window
\{{WindowStartUtc}} – \{{WindowEndUtc}}

+
+ +
+ \{{#if HasStack}} + + \{{else}} + + \{{/if}} +
+ +

This notification will not be sent again for another \{{Cooldown}} (cooldown period).

+
+
+
diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs index de1e2db24e..a55c23c5e1 100644 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ b/src/Exceptionless.Web/Controllers/ProjectController.cs @@ -33,7 +33,7 @@ public class ProjectController : RepositoryApiController _workItemQueue; private readonly BillingManager _billingManager; private readonly SlackService _slackService; @@ -49,7 +49,7 @@ public ProjectController( IStackRepository stackRepository, IEventRepository eventRepository, ITokenRepository tokenRepository, - IRateNotificationRuleRepository rateNotificationRuleRepository, + OrganizationService organizationService, IQueue workItemQueue, BillingManager billingManager, SlackService slackService, @@ -69,7 +69,7 @@ ILoggerFactory loggerFactory _stackRepository = stackRepository; _eventRepository = eventRepository; _tokenRepository = tokenRepository; - _rateNotificationRuleRepository = rateNotificationRuleRepository; + _organizationService = organizationService; _workItemQueue = workItemQueue; _billingManager = billingManager; _slackService = slackService; @@ -225,7 +225,7 @@ protected override async Task> DeleteModelsAsync(ICollection _logger.UserDeletingProject(user.Id, project.Name); await _tokenRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); - await _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); + await _organizationService.RemoveProjectRateNotificationRulesAsync(project.OrganizationId, project.Id); } return await base.DeleteModelsAsync(projects); diff --git a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs index f8dfc196e9..039af60870 100644 --- a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs +++ b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs @@ -49,8 +49,7 @@ public RateNotificationRuleController( IStackRepository stackRepository, RateNotificationRuleCache ruleCache, ILockProvider lockProvider, - TimeProvider timeProvider, - ILoggerFactory loggerFactory) : base(timeProvider) + TimeProvider timeProvider) : base(timeProvider) { _ruleRepository = ruleRepository; _projectRepository = projectRepository; @@ -100,6 +99,12 @@ public async Task> PostAsync( if (project is null) return NotFound(); + if (String.IsNullOrWhiteSpace(model.Name)) + return ValidationProblem(detail: "Name cannot be empty."); + + if (!Enum.IsDefined(model.Signal) || !Enum.IsDefined(model.Subject)) + return ValidationProblem(detail: "Signal and Subject must be supported values."); + // Validate window if (!ValidWindows.Contains(model.Window)) return ValidationProblem(detail: $"Window must be one of: {String.Join(", ", ValidWindows.Select(w => w.ToString()))}"); @@ -115,7 +120,9 @@ public async Task> PostAsync( return ValidationProblem(detail: "StackId is required when Subject is Stack."); var stack = await _stackRepository.GetByIdAsync(model.StackId, o => o.Cache()); - if (stack is null || !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal)) + if (stack is null || + !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal) || + !String.Equals(stack.OrganizationId, project.OrganizationId, StringComparison.Ordinal)) return ValidationProblem(detail: "The specified StackId does not belong to this project."); } else if (!String.IsNullOrEmpty(model.StackId)) @@ -200,6 +207,15 @@ public async Task> PutAsync( if (rule is null) return NotFound(); + if (model.Name is not null && String.IsNullOrWhiteSpace(model.Name)) + return ValidationProblem(detail: "Name cannot be empty."); + + if ((model.Signal.HasValue && !Enum.IsDefined(model.Signal.Value)) || + (model.Subject.HasValue && !Enum.IsDefined(model.Subject.Value))) + { + return ValidationProblem(detail: "Signal and Subject must be supported values."); + } + // Apply updates if (model.Name is not null) rule.Name = model.Name; @@ -223,7 +239,9 @@ public async Task> PutAsync( return ValidationProblem(detail: "StackId is required when Subject is Stack."); var stack = await _stackRepository.GetByIdAsync(newStackId, o => o.Cache()); - if (stack is null || !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal)) + if (stack is null || + !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal) || + !String.Equals(stack.OrganizationId, project.OrganizationId, StringComparison.Ordinal)) return ValidationProblem(detail: "The specified StackId does not belong to this project."); rule.StackId = newStackId; diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 6e99232bcc..bf72d60d17 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -4,6 +4,7 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Utility; @@ -22,6 +23,7 @@ public sealed class OrganizationControllerTests : IntegrationTestsBase { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IRateNotificationRuleRepository _rateNotificationRuleRepository; private readonly IUserRepository _userRepository; private readonly BillingManager _billingManager; private readonly BillingPlans _plans; @@ -31,6 +33,7 @@ public OrganizationControllerTests(ITestOutputHelper output, AppWebHostFactory f { _organizationRepository = GetService(); _projectRepository = GetService(); + _rateNotificationRuleRepository = GetService(); _userRepository = GetService(); _billingManager = GetService(); _plans = GetService(); @@ -912,6 +915,15 @@ public async Task RemoveUserAsync_UserWithNotificationSettings_CleansUpNotificat project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); Assert.NotNull(project); Assert.True(project.NotificationSettings.ContainsKey(organizationAdminUser.Id)); + var rule = await _rateNotificationRuleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = SampleDataService.TEST_ORG_ID, + ProjectId = project.Id, + UserId = organizationAdminUser.Id, + Name = "Membership cleanup test" + }, o => o.ImmediateConsistency()); + var ruleCache = GetService(); + Assert.Contains(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken), cachedRule => cachedRule.Id == rule.Id); // Act await SendRequestAsync(r => r @@ -931,6 +943,8 @@ await SendRequestAsync(r => r organizationAdminUser = await _userRepository.GetByIdAsync(organizationAdminUser.Id); Assert.NotNull(organizationAdminUser); Assert.DoesNotContain(SampleDataService.TEST_ORG_ID, organizationAdminUser.OrganizationIds); + Assert.Null(await _rateNotificationRuleRepository.GetByIdAsync(rule.Id, o => o.IncludeSoftDeletes())); + Assert.Empty(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken)); } [Fact] diff --git a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs index d8843620cd..688624b7b7 100644 --- a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs @@ -4,6 +4,7 @@ using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Utility; @@ -22,6 +23,7 @@ public sealed class ProjectControllerTests : IntegrationTestsBase private readonly IEventRepository _eventRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IRateNotificationRuleRepository _rateNotificationRuleRepository; private readonly IStackRepository _stackRepository; public ProjectControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) @@ -29,6 +31,7 @@ public ProjectControllerTests(ITestOutputHelper output, AppWebHostFactory factor _eventRepository = GetService(); _organizationRepository = GetService(); _projectRepository = GetService(); + _rateNotificationRuleRepository = GetService(); _stackRepository = GetService(); } @@ -108,6 +111,15 @@ public async Task DeleteAsync_ExistingProject_RemovesProject() .StatusCodeShouldBeCreated() ); Assert.NotNull(project); + var rule = await _rateNotificationRuleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = project.OrganizationId, + ProjectId = project.Id, + UserId = TestConstants.UserId, + Name = "Project cleanup test" + }, o => o.ImmediateConsistency()); + var ruleCache = GetService(); + Assert.Contains(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken), cachedRule => cachedRule.Id == rule.Id); // Act var workItems = await SendRequestAsAsync(r => r @@ -126,6 +138,8 @@ public async Task DeleteAsync_ExistingProject_RemovesProject() // Assert var deleted = await _projectRepository.GetByIdAsync(project.Id); Assert.Null(deleted); + Assert.Null(await _rateNotificationRuleRepository.GetByIdAsync(rule.Id, o => o.IncludeSoftDeletes())); + Assert.Empty(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken)); } [Fact] diff --git a/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs index 45f45c13e0..f27b5a595e 100644 --- a/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs @@ -182,6 +182,90 @@ await SendRequestAsync(r => r ); } + [Fact] + public async Task PostAsync_EmptyName_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = String.Empty, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task PostAsync_WhitespaceName_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = " ", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task PostAsync_UnsupportedSignal_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Invalid signal", + Signal = (RateNotificationSignal)999, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task PostAsync_ZeroThreshold_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Invalid threshold", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 0, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + [Fact] public async Task PostAsync_CooldownLessThanWindow_Returns422() { @@ -233,6 +317,30 @@ await SendRequestAsync(r => r ); } + [Fact] + public async Task PostAsync_StackFromAnotherProject_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + var (stacks, _) = await CreateDataAsync(builder => builder.Event().FreeProject()); + var stack = Assert.Single(stacks); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Wrong project stack", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Stack, + StackId = stack.Id, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + [Fact] public async Task GetByIdAsync_ExistingRule_ReturnsRule() { diff --git a/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs b/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs index 9a11a16fe9..fda8414d8d 100644 --- a/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs @@ -25,6 +25,7 @@ public class CleanupDataJobTests : IntegrationTestsBase private readonly IOrganizationRepository _organizationRepository; private readonly ProjectData _projectData; private readonly IProjectRepository _projectRepository; + private readonly IRateNotificationRuleRepository _rateNotificationRuleRepository; private readonly StackData _stackData; private readonly IStackRepository _stackRepository; private readonly EventData _eventData; @@ -46,6 +47,7 @@ public CleanupDataJobTests(ITestOutputHelper output, AppWebHostFactory factory) _organizationRepository = GetService(); _projectData = GetService(); _projectRepository = GetService(); + _rateNotificationRuleRepository = GetService(); _stackData = GetService(); _stackRepository = GetService(); _eventData = GetService(); @@ -145,6 +147,15 @@ public async Task CanCleanupSoftDeletedOrganization() var project = await _projectRepository.AddAsync(_projectData.GenerateSampleProject(), o => o.ImmediateConsistency()); var stack = await _stackRepository.AddAsync(_stackData.GenerateSampleStack(), o => o.ImmediateConsistency()); var persistentEvent = await _eventRepository.AddAsync(_eventData.GenerateEvent(organization.Id, project.Id, stack.Id), o => o.ImmediateConsistency()); + var rule = await _rateNotificationRuleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = organization.Id, + ProjectId = project.Id, + UserId = TestConstants.UserId, + Name = "Organization cleanup test" + }, o => o.ImmediateConsistency()); + var ruleCache = GetService(); + Assert.Contains(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken), cachedRule => cachedRule.Id == rule.Id); string iconPath = OrganizationStoragePaths.GetProfileImagePath(organization.Id, "icon.png"); using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); await _fileStorage.SaveFileAsync(iconPath, stream, TestCancellationToken); @@ -155,6 +166,8 @@ public async Task CanCleanupSoftDeletedOrganization() Assert.Null(await _projectRepository.GetByIdAsync(project.Id, o => o.IncludeSoftDeletes())); Assert.Null(await _stackRepository.GetByIdAsync(stack.Id, o => o.IncludeSoftDeletes())); Assert.Null(await _eventRepository.GetByIdAsync(persistentEvent.Id, o => o.IncludeSoftDeletes())); + Assert.Null(await _rateNotificationRuleRepository.GetByIdAsync(rule.Id, o => o.IncludeSoftDeletes())); + Assert.Empty(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken)); Assert.False(await _fileStorage.ExistsAsync(iconPath)); } @@ -248,6 +261,15 @@ public async Task CanCleanupSoftDeletedProject() var stack = await _stackRepository.AddAsync(_stackData.GenerateSampleStack(), o => o.ImmediateConsistency()); var persistentEvent = await _eventRepository.AddAsync(_eventData.GenerateEvent(organization.Id, project.Id, stack.Id), o => o.ImmediateConsistency()); + var rule = await _rateNotificationRuleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = organization.Id, + ProjectId = project.Id, + UserId = TestConstants.UserId, + Name = "Project cleanup test" + }, o => o.ImmediateConsistency()); + var ruleCache = GetService(); + Assert.Contains(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken), cachedRule => cachedRule.Id == rule.Id); await _job.RunAsync(TestCancellationToken); @@ -255,6 +277,8 @@ public async Task CanCleanupSoftDeletedProject() Assert.Null(await _projectRepository.GetByIdAsync(project.Id, o => o.IncludeSoftDeletes())); Assert.Null(await _stackRepository.GetByIdAsync(stack.Id, o => o.IncludeSoftDeletes())); Assert.Null(await _eventRepository.GetByIdAsync(persistentEvent.Id, o => o.IncludeSoftDeletes())); + Assert.Null(await _rateNotificationRuleRepository.GetByIdAsync(rule.Id, o => o.IncludeSoftDeletes())); + Assert.Empty(await ruleCache.GetEnabledRulesAsync(project.Id, TestCancellationToken)); } [Fact] diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs index 21c0e5f050..5463fc51ed 100644 --- a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs @@ -16,7 +16,7 @@ public class RateNotificationEvaluatorJobTests : IntegrationTestsBase private readonly RateNotificationEvaluatorJob _job; private readonly RateCounterService _counterService; private readonly IRateNotificationRuleRepository _ruleRepository; - private readonly IOrganizationRepository _orgRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly IQueue _notificationQueue; @@ -25,7 +25,7 @@ public RateNotificationEvaluatorJobTests(ITestOutputHelper output, AppWebHostFac _job = GetService(); _counterService = GetService(); _ruleRepository = GetService(); - _orgRepository = GetService(); + _organizationRepository = GetService(); _projectRepository = GetService(); _notificationQueue = GetService>(); } @@ -36,10 +36,10 @@ protected override async Task ResetDataAsync() var service = GetService(); await service.CreateDataAsync(); - var organization = await _orgRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); Assert.NotNull(organization); organization.Features.Add(OrganizationExtensions.RateNotificationsFeature); - await _orgRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); } private RateNotificationRule BuildRule(string? projectId = null, RateNotificationSignal signal = RateNotificationSignal.Errors, int threshold = 10, string? window = null, string? cooldown = null) @@ -117,6 +117,30 @@ public async Task RunAsync_WhenBelowThreshold_DoesNotEnqueue() Assert.Equal(queueBefore, queueAfter); } + [Fact] + public async Task RunAsync_WhenRateNotificationsFeatureDisabled_DoesNotEnqueue() + { + var ct = TestContext.Current.CancellationToken; + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-2)); + + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 1), o => o.ImmediateConsistency()); + await _counterService.IncrementAsync(BuildCounterKey(rule), ct); + + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Remove(OrganizationExtensions.RateNotificationsFeature); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); + + TimeProvider.Advance(TimeSpan.FromMinutes(2)); + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + await _job.RunAsync(ct); + + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + Assert.Equal(queueBefore, queueAfter); + } + [Fact] public async Task RunAsync_WhenEventsAreInCurrentMinute_DoesNotEvaluatePartialBucket() { diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs index 576e41325a..e837b9dab9 100644 --- a/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs @@ -6,6 +6,7 @@ using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Mail; +using Exceptionless.Tests.Utility; using Foundatio.Queues; using Foundatio.Repositories; using Xunit; @@ -20,6 +21,7 @@ public class RateNotificationsJobTests : IntegrationTestsBase private readonly IProjectRepository _projectRepository; private readonly IQueue _queue; private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IStackRepository _stackRepository; private readonly IUserRepository _userRepository; public RateNotificationsJobTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) @@ -30,6 +32,7 @@ public RateNotificationsJobTests(ITestOutputHelper output, AppWebHostFactory fac _projectRepository = GetService(); _queue = GetService>(); _ruleRepository = GetService(); + _stackRepository = GetService(); _userRepository = GetService(); } @@ -89,6 +92,86 @@ public async Task RunAsync_FeatureDisabled_SkipsNotification() Assert.Empty(_mailer.RateNotifications); } + [Fact] + public async Task RunAsync_DisabledRule_SkipsNotification() + { + // Arrange + var (rule, notification) = await CreateRuleAndNotificationAsync(); + rule.IsEnabled = false; + await _ruleRepository.SaveAsync(rule, o => o.ImmediateConsistency()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_UnverifiedEmail_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + var user = await _userRepository.GetByIdAsync(notification.UserId); + Assert.NotNull(user); + user.IsEmailAddressVerified = false; + user.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_UserRemovedFromOrganization_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + var user = await _userRepository.GetByIdAsync(notification.UserId); + Assert.NotNull(user); + user.OrganizationIds.Remove(notification.OrganizationId); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_StackRule_LoadsStackContext() + { + // Arrange + var (rule, notification) = await CreateRuleAndNotificationAsync(); + var stack = GetService().GenerateStack(generateId: true, organizationId: rule.OrganizationId, projectId: rule.ProjectId); + await _stackRepository.AddAsync(stack, o => o.ImmediateConsistency()); + + rule.Subject = RateNotificationSubject.Stack; + rule.StackId = stack.Id; + await _ruleRepository.SaveAsync(rule, o => o.ImmediateConsistency()); + notification.StackId = stack.Id; + notification.SubjectKey = $"stack:{stack.Id}"; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + var call = Assert.Single(_mailer.RateNotifications); + Assert.Equal(stack.Id, call.StackId); + } + [Fact] public async Task RunAsync_PayloadDoesNotMatchRule_SkipsNotification() { diff --git a/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionIntegrationTests.cs b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionIntegrationTests.cs new file mode 100644 index 0000000000..5a110436d3 --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionIntegrationTests.cs @@ -0,0 +1,138 @@ +using Exceptionless.Core; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Utility; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Pipeline; + +public class UpdateRateCountersActionIntegrationTests : IntegrationTestsBase +{ + private const string CounterKey = $"project:{SampleDataService.TEST_PROJECT_ID}:signal:Errors"; + + private readonly UpdateRateCountersAction _action; + private readonly IOrganizationRepository _organizationRepository; + private readonly RateCounterService _counterService; + private readonly IRateNotificationRuleRepository _ruleRepository; + + public UpdateRateCountersActionIntegrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _organizationRepository = GetService(); + _counterService = GetService(); + _ruleRepository = GetService(); + _action = new UpdateRateCountersAction( + GetService(), + _counterService, + GetService(), + GetService()); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Add(OrganizationExtensions.RateNotificationsFeature); + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); + + var user = await GetService().GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + await _ruleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = SampleDataService.TEST_ORG_ID, + ProjectId = SampleDataService.TEST_PROJECT_ID, + UserId = user.Id, + Name = "Pipeline test", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 1, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(5), + CreatedUtc = TimeProvider.GetUtcNow().UtcDateTime, + UpdatedUtc = TimeProvider.GetUtcNow().UtcDateTime + }, o => o.ImmediateConsistency()); + } + + [Fact] + public async Task ProcessAsync_EligibleEvent_IncrementsCounter() + { + var context = await CreateContextAsync(); + + await _action.ProcessAsync(context); + + Assert.Equal(1, await GetCurrentBucketCountAsync()); + } + + [Fact] + public async Task ProcessAsync_FeatureDisabled_DoesNotIncrementCounter() + { + var context = await CreateContextAsync(); + context.Organization.Features.Remove(OrganizationExtensions.RateNotificationsFeature); + + await _action.ProcessAsync(context); + + Assert.Equal(0, await GetCurrentBucketCountAsync()); + } + + [Fact] + public async Task ProcessAsync_StackDisallowsNotifications_DoesNotIncrementCounter() + { + var context = await CreateContextAsync(); + context.Stack!.Status = StackStatus.Fixed; + + await _action.ProcessAsync(context); + + Assert.Equal(0, await GetCurrentBucketCountAsync()); + } + + [Fact] + public async Task ProcessAsync_BotMarkedRequest_DoesNotIncrementCounter() + { + var context = await CreateContextAsync(); + context.Event.Data = new DataDictionary + { + [Event.KnownDataKeys.RequestInfo] = new RequestInfo + { + Data = new DataDictionary { [RequestInfo.KnownDataKeys.IsBot] = true } + } + }; + + await _action.ProcessAsync(context); + + Assert.Equal(0, await GetCurrentBucketCountAsync()); + } + + private async Task CreateContextAsync() + { + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + var project = await GetService().GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(organization); + Assert.NotNull(project); + + var stack = GetService().GenerateStack(generateId: true, organizationId: organization.Id, projectId: project.Id); + var ev = new PersistentEvent + { + StackId = stack.Id, + Type = Event.KnownTypes.Error + }; + + return new EventContext(ev, organization, project) { Stack = stack }; + } + + private Task GetCurrentBucketCountAsync() + { + var now = TimeProvider.GetUtcNow().UtcDateTime; + var minute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc); + return _counterService.SumBucketsAsync(CounterKey, minute, minute.AddMinutes(1), TestContext.Current.CancellationToken); + } +} From 0ba67271f2ecf511d8f117437274456ce9d2fe8c Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 12:45:44 -0500 Subject: [PATCH 18/18] fix: complete rate notification production hardening --- .../add-personal-rate-notifications/design.md | 56 +++--- .../proposal.md | 14 +- .../specs/rate-notifications/spec.md | 32 ++- .../add-personal-rate-notifications/tasks.md | 77 ++++---- src/Exceptionless.Core/Bootstrapper.cs | 1 + .../Jobs/RateNotificationEvaluatorJob.cs | 61 ++++-- .../Jobs/RateNotificationsJob.cs | 28 ++- src/Exceptionless.Core/Mail/Mailer.cs | 4 +- .../Models/RateNotificationRule.cs | 5 + .../Pipeline/075_UpdateRateCountersAction.cs | 53 ++--- .../IRateNotificationRuleRepository.cs | 1 + .../RateNotificationRuleRepository.cs | 29 ++- .../Services/OrganizationService.cs | 39 +++- .../Services/RateCounterService.cs | 20 +- .../Services/RateNotificationCounterPlan.cs | 105 ++++++++++ .../Services/RateNotificationRuleCache.cs | 82 ++++++-- src/Exceptionless.Job/JobRunnerOptions.cs | 10 + src/Exceptionless.Job/Program.cs | 7 + .../ClientApp/playwright.config.ts | 2 +- .../organization-notifications.stories.svelte | 1 + .../features/rate-notifications/api.svelte.ts | 89 +++++---- .../features/rate-notifications/api.test.ts | 33 ++-- .../rate-notification-rule-form.svelte | 32 +-- .../rate-notification-rule-list.svelte | 37 ++-- .../rate-notifications/schemas.test.ts | 63 ++++++ .../features/rate-notifications/schemas.ts | 20 +- .../lib/features/rate-notifications/types.ts | 2 +- .../ClientApp/src/lib/generated/api.ts | 1 + .../ClientApp/src/lib/generated/schemas.ts | 1 + .../(app)/account/notifications/+page.svelte | 16 +- .../src/routes/(app)/event/+page.svelte | 9 +- .../Controllers/ProjectController.cs | 9 +- .../RateNotificationRuleController.cs | 52 +++-- .../Mapping/ProjectMapper.cs | 1 + .../Models/Project/ViewProject.cs | 1 + .../Exceptionless.Tests/AppWebHostFactory.cs | 24 ++- .../Controllers/Data/openapi.json | 4 + .../Controllers/ProjectControllerTests.cs | 45 +++++ .../RateNotificationRuleControllerTests.cs | 131 ++++++++++++- .../Extensions/RequestExtensions.cs | 5 +- .../IntegrationTestsBase.cs | 3 + .../Jobs/RateNotificationEvaluatorJobTests.cs | 72 +++++++ .../Jobs/RateNotificationsJobTests.cs | 182 ++++++++++++++++++ .../Pipeline/UpdateRateCountersActionTests.cs | 158 ++++++++++++++- .../Services/RateCounterServiceTests.cs | 22 +++ .../RateNotificationRuleCacheTests.cs | 174 +++++++++++++++++ tests/http/rate-notifications.http | 2 +- 47 files changed, 1533 insertions(+), 282 deletions(-) create mode 100644 src/Exceptionless.Core/Services/RateNotificationCounterPlan.cs create mode 100644 tests/Exceptionless.Tests/Services/RateNotificationRuleCacheTests.cs diff --git a/openspec/changes/add-personal-rate-notifications/design.md b/openspec/changes/add-personal-rate-notifications/design.md index 416337c9dc..5e4b8a3845 100644 --- a/openspec/changes/add-personal-rate-notifications/design.md +++ b/openspec/changes/add-personal-rate-notifications/design.md @@ -35,6 +35,7 @@ public class RateNotificationRule : IOwnedByOrganizationAndProjectWithIdentity public TimeSpan Window { get; set; } public TimeSpan Cooldown { get; set; } public DateTime? SnoozedUntilUtc { get; set; } + public DateTime? LastFiredUtc { get; set; } public DateTime CreatedUtc { get; set; } public DateTime UpdatedUtc { get; set; } public bool IsDeleted { get; set; } @@ -75,8 +76,10 @@ public enum RateNotificationSubject public interface IRateNotificationRuleRepository : IRepositoryOwnedByOrganizationAndProject { Task> GetByProjectIdAndUserIdAsync(string projectId, string userId, CommandOptionsDescriptor? options = null); + Task> GetByOrganizationIdAndUserIdAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null); Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null); Task CountByProjectIdAndUserIdAsync(string projectId, string userId); + Task RemoveAllByOrganizationIdAndUserIdAsync(string organizationId, string userId); } ``` @@ -105,16 +108,18 @@ Elasticsearch-backed repository following existing patterns (e.g., `StackReposit - User must have access to the project's organization. - External recipients are not supported in v1. -### Premium gating +### Dark-launch gating -- Rate notifications follow the current occurrence-notification premium model. -- Rules may remain persisted across plan downgrade, but countering, evaluation, delivery, and Svelte create/enable controls MUST treat the feature as unavailable until premium is restored. +- Rate notifications require both the current occurrence-notification premium entitlement and the `rate-notifications` organization feature. +- `ViewProject.has_rate_notifications` exposes the combined capability without requiring the Svelte page to fetch organization flags separately. +- Rules remain persisted when either gate is removed, but countering, evaluation, delivery, and the Svelte feature MUST remain inactive until both gates are restored. ### Validation - `threshold > 0` - `window` must be one of: 1m, 5m, 10m, 15m, 30m, 1h - `cooldown` must be at least the window duration +- `cooldown` must not exceed 24 hours - Recommended default cooldown: 30m - Project subject must not specify `stack_id` - Stack subject must specify `stack_id` @@ -124,18 +129,19 @@ Elasticsearch-backed repository following existing patterns (e.g., `StackReposit ## Rule Index -### RateNotificationRuleIndex service +### RateNotificationRuleCache service Purpose: - Load enabled rules for a project. - Cache compiled counter definitions briefly. - Ensure event pipeline can cheaply determine which counters to increment. -- Invalidate project rule index on create/update/delete/snooze/unsnooze. +- Load every enabled rule with search-after pagination, then compile rules into unique project/stack counters grouped by signal. +- Invalidate the project plan through hybrid-cache invalidation on rule-management changes and lifecycle cleanup; the evaluator's `LastFiredUtc` bookkeeping suppresses repository notifications so deliveries do not churn the hot-path plan. **Important:** The event pipeline must not increment every possible counter. It must increment only counters required by enabled rules for that project. -Cache key: `rate:v1:rules:project:{projectId}` +Cache key: `rate:v2:counter-plan:project:{projectId}` ## Counter Architecture @@ -149,7 +155,7 @@ Uses 1-minute UTC buckets. rate:v1:count:{epochMinute}:{counterKey} rate:v1:active:{epochMinute} rate:v1:cooldown:{ruleId}:{subjectKey} -rate:v1:rules:project:{projectId} +rate:v2:counter-plan:project:{projectId} ``` ### Counter key examples @@ -171,7 +177,7 @@ project:{projectId}:stack:{stackId}:signal:AllEvents - Counter bucket TTL: 3 hours - Active bucket TTL: 3 hours -- Cooldown TTL: cooldown duration + 10 minutes +- Cooldown TTL: configured cooldown duration ### Signal matching @@ -188,15 +194,14 @@ project:{projectId}:stack:{stackId}:signal:AllEvents ### UpdateRateCountersAction - Runs after stack assignment (priority > 70, e.g., 75) -- Exits fast when the organization does not have premium features -- Loads `RateNotificationRuleIndex` for project +- Exits fast when the organization does not have premium features or the rollout flag +- Loads the cached compiled counter plan for the project - Exits fast if no enabled rules - Skips events on stacks where `!ctx.Stack.AllowNotifications` - Skips canceled/discarded events that would not produce occurrence notifications - Skips requests already marked as bots by request-info enrichment - Matches event against compiled counter definitions -- Increments matching counters via `RateCounterService` -- Adds counter key to active bucket list/set +- Performs one increment per unique matching counter and one batched active-key update per event - Never sends notifications directly - Never queries Elasticsearch per event @@ -206,8 +211,11 @@ project:{projectId}:stack:{stackId}:signal:AllEvents - Runs periodically (recommended: every 60 seconds) - Acquires distributed lock so only one evaluator runs per cluster +- Renews the distributed lock during recovery scans, pagination, and large rule evaluations - Inspects recently active counters from active bucket sets -- Skips organizations without premium features +- Skips organizations without premium features or the rollout flag +- Loads all enabled project rules with search-after pagination +- Ignores malformed persisted rule definitions instead of failing the evaluator checkpoint - Sums buckets for each rule's configured window - Uses `max(windowStartUtc, rule.SnoozedUntilUtc)` as the lower bound when a snooze boundary falls inside the evaluation window so a rule resumes from a fresh baseline - Skips disabled rules @@ -258,7 +266,9 @@ public class RateNotification - Validates: - Rule still exists - Rule is enabled - - Rule version matches or is compatible + - Rule version matches exactly + - Persisted subject/signal state is valid + - Queued ownership, subject, threshold, count, and window state matches the current rule - User belongs to organization - User email is verified - User email notifications are enabled @@ -320,7 +330,7 @@ UI supports: - Delete rule - Enable/disable rule - Snooze/unsnooze rule -- Disabled/upgrade state when the organization lacks premium features +- Entire feature hidden unless `ViewProject.has_rate_notifications` exposes the combined premium-plus-rollout capability ### Form fields @@ -353,13 +363,13 @@ Display when creating/editing: "This rule may be noisy. Use a cooldown to avoid ### Metrics -- `rate_notification.rules.loaded` -- `rate_notification.counters.incremented` -- `rate_notification.evaluator.runs` -- `rate_notification.evaluator.rules_evaluated` -- `rate_notification.evaluator.notifications_enqueued` -- `rate_notification.delivery.sent` -- `rate_notification.delivery.skipped` +- `ex.rate_notifications.counter_keys.incremented` +- `ex.rate_notifications.active_counter_keys` +- `ex.rate_notifications.active_projects` +- `ex.rate_notifications.evaluation_time` +- `ex.rate_notifications.enqueued` +- `ex.rate_notifications.sent` +- `ex.rate_notifications.skipped` ### Structured log fields @@ -373,7 +383,7 @@ Display when creating/editing: "This rule may be noisy. Use a cooldown to avoid ## Bootstrap / DI - Register `IRateNotificationRuleRepository` / `RateNotificationRuleRepository` -- Register `RateNotificationRuleIndex` +- Register the persistent `RateNotificationRuleIndex` and compiled `RateNotificationRuleCache` - Register `RateCounterService` - Register `RateNotificationEvaluatorJob` - Register `IQueue` notification queue diff --git a/openspec/changes/add-personal-rate-notifications/proposal.md b/openspec/changes/add-personal-rate-notifications/proposal.md index 6f215db58f..6dd4c4ebaa 100644 --- a/openspec/changes/add-personal-rate-notifications/proposal.md +++ b/openspec/changes/add-personal-rate-notifications/proposal.md @@ -13,7 +13,7 @@ The implementation is intentionally small and focused: - Asynchronous evaluator job - Email delivery - Cooldown and snooze to reduce noise -- Premium-gated runtime behavior that matches existing occurrence notifications +- Dark-launched runtime behavior requiring premium status plus the `rate-notifications` organization feature - Existing notification suppression semantics so muted traffic does not reappear as rate noise This feature is informed by [issue #177](https://github.com/exceptionless/Exceptionless/issues/177), but intentionally does not implement the full notification wishlist. Issue #177's core goal — notifications should keep users informed without overwhelming them — is addressed through mandatory cooldowns, snooze support, and cache-only hot paths. @@ -45,6 +45,7 @@ This feature is informed by [issue #177](https://github.com/exceptionless/Except - Keep event ingestion cheap. - Support horizontal scaling with distributed cache and queues. - Preserve current premium-only occurrence-notification behavior. +- Keep the feature hidden and inert unless an organization has both premium status and the rollout feature. - Honor existing notification suppression so ignored, snoozed, discarded, and fixed stacks — and bot traffic already excluded from occurrence emails — do not generate rate alerts. - Validate user/project/org state before sending. - Support snoozing a noisy rule. @@ -108,7 +109,7 @@ The first release should prove the cheap counter architecture and noise-control | Existing `EventNotificationsJob` behavior remains unchanged | Rate counters are a new pipeline action; existing notification queueing is unaffected | | Existing `DailySummaryJob` behavior remains unchanged | No changes to daily summary logic | | Existing Slack/webhook integrations are not changed | New delivery path is email-only; existing `WebHookNotification` queue untouched | -| Existing premium-only occurrence notification behavior could drift | Countering, evaluation, delivery, and Svelte enablement follow the same premium gating model as existing occurrence notifications | +| Existing premium-only occurrence notification behavior could drift | Countering, evaluation, delivery, and Svelte enablement require premium status plus the `rate-notifications` rollout feature | | Rate counters depend on distributed cache in production | In-memory cache/queues remain development-only; production requires Redis/Azure providers | | Muted stacks or bot traffic could reappear as new rate noise | Countering honors `Stack.AllowNotifications`, canceled/discarded contexts, and request-info bot markers before incrementing counters | | Snooze could defer a notification instead of suppressing it | Evaluation resumes from a fresh baseline using the snooze boundary so activity gathered during snooze does not fire immediately on resume | @@ -118,9 +119,8 @@ The first release should prove the cheap counter architecture and noise-control ## Rollback Plan -1. Disable the rate notification evaluator job. -2. Disable the `UpdateRateCountersAction` in the event pipeline. +1. Remove the `rate-notifications` organization feature to stop countering, evaluation, delivery, and Svelte exposure immediately while preserving rules. +2. If a code rollback is required, disable the evaluator job and `UpdateRateCountersAction`. 3. Existing event notifications and daily summaries continue to operate unchanged. -4. Delete or ignore persisted rate notification rules if needed. -5. Remove new UI route/feature module. -6. Remove new queues and cache keys if rolling back fully. +4. Retain persisted rules unless the feature is permanently removed. +5. Remove new queues and cache keys only when rolling back the implementation fully. diff --git a/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md b/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md index 5c71129d33..dbdfabc5e0 100644 --- a/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md +++ b/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md @@ -85,6 +85,12 @@ Given cooldown < window When user creates rule Then response is 400 with validation error. +#### Scenario: Cooldown longer than 24 hours is rejected + +Given cooldown > 24 hours +When user creates or updates a rule +Then response is 400 with validation error. + #### Scenario: Project subject with stack_id is rejected Given subject = Project and stack_id is set @@ -135,9 +141,9 @@ Given no enabled rate notification rules for project When event is processed Then no rate counter is incremented. -### Requirement: Rate notifications honor premium feature gating +### Requirement: Rate notifications honor premium and rollout gating -Rate notifications MUST follow the existing premium-only occurrence-notification model and MUST NOT become a free notification channel. +Rate notifications MUST require premium status plus the `rate-notifications` organization feature and MUST NOT become a free or accidentally exposed notification channel. #### Scenario: Non-premium organizations do not activate rate notifications @@ -147,6 +153,15 @@ Then no rate counters are incremented And no rate notification work item is enqueued And no rate notification email is sent. +#### Scenario: Flag-disabled organizations do not activate rate notifications + +Given project organization does not have the `rate-notifications` feature +When matching events are processed, the evaluator runs, or queued delivery is attempted +Then no rate counters are incremented +And no rate notification work item is enqueued +And no rate notification email is sent +And existing rules remain persisted. + ### Requirement: Event pipeline increments only required counters The pipeline MUST only increment counters that are required by at least one enabled rule, not all possible signal counters. @@ -348,11 +363,11 @@ The Svelte UI MUST provide a full CRUD interface for rate notification rules wit Given user opens project notification settings Then they can list, create, edit, delete, enable/disable, snooze, and unsnooze rate rules. -#### Scenario: Non-premium organizations show rate notifications as unavailable +#### Scenario: Organizations without the combined capability do not see rate notifications -Given the organization does not have premium features +Given the organization lacks premium status or the `rate-notifications` feature When the user opens project notification settings -Then the UI shows the feature as unavailable +Then the rate notification feature is not rendered And the user cannot create or enable active rate notification rules. ### Requirement: UI avoids advanced/deferred features @@ -408,3 +423,10 @@ Given a project is deleted When cleanup runs Then rate notification rules for that project are removed or invalidated And cached rule indexes are invalidated. + +#### Scenario: Organization deletion cleans up organization rules + +Given an organization is deleted +When cleanup runs +Then rate notification rules for that organization are removed or invalidated +And cached rule indexes are invalidated. diff --git a/openspec/changes/add-personal-rate-notifications/tasks.md b/openspec/changes/add-personal-rate-notifications/tasks.md index 2744258d0a..9197bcb2f4 100644 --- a/openspec/changes/add-personal-rate-notifications/tasks.md +++ b/openspec/changes/add-personal-rate-notifications/tasks.md @@ -2,48 +2,49 @@ ## Backend - Models and Repositories -- [ ] **Add RateNotificationRule model and enums** - - Create `RateNotificationRule` model with all fields (Id, OrganizationId, ProjectId, UserId, Version, Name, IsEnabled, Signal, Subject, StackId, Threshold, Window, Cooldown, SnoozedUntilUtc, CreatedUtc, UpdatedUtc, IsDeleted) +- [x] **Add RateNotificationRule model and enums** + - Create `RateNotificationRule` model with all fields (Id, OrganizationId, ProjectId, UserId, Version, Name, IsEnabled, Signal, Subject, StackId, Threshold, Window, Cooldown, SnoozedUntilUtc, LastFiredUtc, CreatedUtc, UpdatedUtc, IsDeleted) - Create `RateNotificationSignal` enum (AllEvents, Errors, CriticalErrors, NewErrors, Regressions) - Create `RateNotificationSubject` enum (Project, Stack) -- [ ] **Add IRateNotificationRuleRepository and implementation** +- [x] **Add IRateNotificationRuleRepository and implementation** - Create interface extending `IRepositoryOwnedByOrganizationAndProject` - Add methods: `GetByProjectIdAndUserIdAsync`, `GetEnabledByProjectIdAsync`, `CountByProjectIdAndUserIdAsync` - Create Elasticsearch-backed implementation following existing repository patterns -- [ ] **Register repository in DI** +- [x] **Register repository in DI** - Register `IRateNotificationRuleRepository` / `RateNotificationRuleRepository` in `Bootstrapper.cs` ## Backend - Countering and Evaluation -- [ ] **Add RateNotificationRuleIndex service** - - Load enabled rules for a project from repository - - Cache compiled counter definitions with short TTL - - Invalidate on rule create/update/delete/snooze/unsnooze - - Cache key: `rate:v1:rules:project:{projectId}` +- [x] **Add compiled RateNotificationRuleCache service** + - Load all enabled rules for a project with search-after pagination + - Cache unique compiled project/stack counters grouped by signal with short TTL + - Invalidate through the hybrid cache on rule-management changes and lifecycle cleanup without churning the plan for evaluator bookkeeping + - Cache key: `rate:v2:counter-plan:project:{projectId}` -- [ ] **Add RateCounterService** +- [x] **Add RateCounterService** - Implement 1-minute UTC bucket counters via `ICacheClient` - Methods: increment counter, sum buckets for window, check/set cooldown - Counter key format: `rate:v1:count:{epochMinute}:{counterKey}` - Active bucket tracking: `rate:v1:active:{epochMinute}` - Cooldown key format: `rate:v1:cooldown:{ruleId}:{subjectKey}` - - TTLs: counter/active = 3h, cooldown = cooldown + 10m + - TTLs: counter/active = 3h, cooldown = configured cooldown -- [ ] **Add UpdateRateCountersAction (event pipeline)** +- [x] **Add UpdateRateCountersAction (event pipeline)** - Priority after stack assignment (e.g., 75) - - Exit fast if organization lacks premium features + - Exit fast if organization lacks premium features or the rollout flag - Load rule index for project; exit fast if no enabled rules - Skip events on stacks where `AllowNotifications` is false - Skip canceled/discarded events and requests already marked as bots - Match event against compiled counter definitions (signal matching) - - Increment matching counters; add to active bucket set + - Perform N unique counter increments plus one batched active-key update - Never query Elasticsearch per event; never send notifications directly -- [ ] **Add RateNotificationEvaluatorJob** +- [x] **Add RateNotificationEvaluatorJob** - Periodic job (60s interval) with distributed lock - - Skip organizations without premium features + - Skip organizations without premium features or the rollout flag + - Load all enabled rules with search-after pagination - Inspect recently active counters - Sum buckets for each rule's window using a fresh baseline after snooze/unsnooze - Skip disabled/snoozed rules @@ -52,71 +53,71 @@ - Set cooldown key on successful enqueue - Structured logging for fired/skipped reasons -- [ ] **Add RateNotification queue model** +- [x] **Add RateNotification queue model** - Fields: RuleId, RuleVersion, OrganizationId, ProjectId, UserId, SubjectKey, StackId, WindowStartUtc, WindowEndUtc, ObservedCount, Threshold -- [ ] **Register counter/evaluator services in DI** +- [x] **Register counter/evaluator services in DI** - Register `RateNotificationRuleIndex`, `RateCounterService`, `RateNotificationEvaluatorJob` - Register `IQueue` in Core and Insulation bootstrappers ## Backend - Delivery -- [ ] **Add RateNotificationsJob (delivery)** +- [x] **Add RateNotificationsJob (delivery)** - Load rule, project, user - Load stack for stack-scoped rules - Validate: rule exists, enabled, version compatible, user in org, email verified, notifications enabled, project/org exists - Send email via `IMailer.SendRateNotificationAsync` - Skip with structured logs on validation failure -- [ ] **Add IMailer.SendRateNotificationAsync** +- [x] **Add IMailer.SendRateNotificationAsync** - Add method to `IMailer` interface and `Mailer` implementation - Email includes: rule name, project name, observed count, threshold, window, subject type, stack title (when applicable), link, cooldown explanation - Subject format: `[ProjectName] Error rate exceeded` - No "everything is fine" messaging -- [ ] **Update Insulation queue registration** +- [x] **Update Insulation queue registration** - Register `IQueue` for Redis/Azure/SQS providers in `Exceptionless.Insulation/Bootstrapper.cs` ## Backend - API -- [ ] **Add RateNotificationRuleController** +- [x] **Add RateNotificationRuleController** - Routes: GET list, POST create, GET by id, PUT update, DELETE, POST snooze, POST unsnooze - Authorization: current user manages own rules; global admin manages any user's rules; user must access project org - - Validation: threshold > 0, supported windows, cooldown ≥ window, subject/stack consistency, max 20 rules per user per project, name non-empty and ≤ 100 chars - - Preserve persisted rules across plan downgrade, but do not allow the runtime or Svelte UI to treat non-premium orgs as active rate-notification senders + - Validation: defined enums, threshold > 0, supported windows, window ≤ cooldown ≤ 24h, subject/stack consistency, max 20 rules per user per project, name non-empty and ≤ 100 chars + - Preserve persisted rules when premium or the rollout flag is removed, but keep runtime and Svelte behavior inactive -- [ ] **Add request/response DTOs** +- [x] **Add request/response DTOs** - Create/update request models with validation attributes - Snooze request model (duration or until timestamp) -- [ ] **Update tests/http files** +- [x] **Update tests/http files** - Add HTTP sample requests for all rate notification endpoints ## Frontend -- [ ] **Add rate-notifications feature module** +- [x] **Add rate-notifications feature module** - Create `src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/` - Models/types matching API DTOs - TanStack Query API wrappers (list, create, update, delete, snooze, unsnooze) -- [ ] **Add rate notification rule list component** +- [x] **Add rate notification rule list component** - List rules for current user/project - Show enable/disable toggle, snooze status - - Show premium-gated disabled state / upgrade messaging when org lacks premium features + - Hide the feature unless the project exposes the combined premium-plus-rollout capability - Delete confirmation -- [ ] **Add rate notification rule form component** +- [x] **Add rate notification rule form component** - Fields: Name, Signal, Subject, Stack selector, Threshold, Window, Cooldown, Enabled - Validation matching backend constraints - Noise warning copy: "This rule may be noisy. Use a cooldown to avoid repeated emails." -- [ ] **Integrate into project settings** +- [x] **Integrate into project settings** - Add rate notifications section/tab in project notification settings - - Route and navigation entry + - Gate the section with additive `ViewProject.has_rate_notifications` ## Tests -- [ ] **Add unit tests** +- [x] **Add unit tests** - Rule validation (threshold, window, cooldown, subject/stack, name) - Counter key builder - Counter increments and bucket summing @@ -128,7 +129,7 @@ - Fresh-baseline behavior after snooze/unsnooze - Evaluator threshold crossing -- [ ] **Add integration tests** +- [x] **Add integration tests** - User CRUD own rules (list, create, get, update, delete) - User cannot manage another user's rules - Global admin can manage another user's rules @@ -148,16 +149,16 @@ - Delivery loads stack context for stack-scoped emails - Membership/project/org cleanup removes orphaned rules -- [ ] **Add lifecycle cleanup** +- [x] **Add lifecycle cleanup** - Remove/invalidate rules when user membership changes - Remove/invalidate rules when project or organization is deleted ## Validation -- [ ] **Run OpenSpec validation** - - `openspec validate add-personal-rate-notifications --strict` +- [x] **Run OpenSpec validation** + - `openspec validate add-personal-rate-notifications --strict --no-interactive` -- [ ] **Run relevant builds and tests** +- [x] **Run relevant builds and tests** - `dotnet build` - `dotnet test` - Frontend build and lint diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 712b07636f..45bec0e0c5 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -211,6 +211,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddStartupAction(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs index c3f2faccca..49f75a7ba4 100644 --- a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs +++ b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs @@ -10,6 +10,7 @@ using Foundatio.Lock; using Foundatio.Queues; using Foundatio.Repositories; +using Foundatio.Repositories.Utility; using Foundatio.Resilience; using Microsoft.Extensions.Logging; @@ -21,6 +22,7 @@ public class RateNotificationEvaluatorJob : JobWithLockBase private readonly RateCounterService _counterService; private readonly IRateNotificationRuleRepository _ruleRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; private readonly IQueue _notificationQueue; private readonly ILockProvider _lockProvider; @@ -31,6 +33,7 @@ public RateNotificationEvaluatorJob( RateCounterService counterService, IRateNotificationRuleRepository ruleRepository, IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, IQueue notificationQueue, ILockProvider lockProvider, TimeProvider timeProvider, @@ -40,6 +43,7 @@ public RateNotificationEvaluatorJob( _counterService = counterService; _ruleRepository = ruleRepository; _organizationRepository = organizationRepository; + _projectRepository = projectRepository; _notificationQueue = notificationQueue; _lockProvider = lockProvider; } @@ -68,11 +72,15 @@ protected override async Task RunInternalAsync(JobContext context) // Collect all unique counter keys across all minutes in the scan window var allCounterKeys = new HashSet(); + int scannedMinutes = 0; for (var minute = fromMinute; minute <= toMinute; minute = minute.AddMinutes(1)) { var keys = await _counterService.GetActiveCounterKeysAsync(minute, context.CancellationToken); foreach (var key in keys) allCounterKeys.Add(key); + + if (++scannedMinutes % 30 == 0) + await context.RenewLockAsync(); } var counterKeysByProject = allCounterKeys @@ -89,7 +97,8 @@ protected override async Task RunInternalAsync(JobContext context) if (context.CancellationToken.IsCancellationRequested) return JobResult.Cancelled; - await EvaluateProjectAsync(projectCounterKeys.Key, projectCounterKeys, currentMinute, context.CancellationToken); + await context.RenewLockAsync(); + await EvaluateProjectAsync(projectCounterKeys.Key, projectCounterKeys, currentMinute, context); } await _counterService.SetLastEvaluatedMinuteAsync(toMinute, context.CancellationToken); @@ -97,21 +106,33 @@ protected override async Task RunInternalAsync(JobContext context) return JobResult.Success; } - private async Task EvaluateProjectAsync(string projectId, IEnumerable counterKeys, DateTime evaluationEndUtc, CancellationToken ct) + private async Task EvaluateProjectAsync(string projectId, IEnumerable counterKeys, DateTime evaluationEndUtc, JobContext context) { - var allProjectRules = await _ruleRepository.GetEnabledByProjectIdAsync(projectId, o => o.PageLimit(1000)); - if (allProjectRules.Documents.Count == 0) + var ct = context.CancellationToken; + var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache()); + if (project is null) return; - string organizationId = allProjectRules.Documents.First().OrganizationId; - var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); + var organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); if (organization is null || !organization.HasRateNotifications()) return; - var rulesByCounterKey = allProjectRules.Documents - .GroupBy(UpdateRateCountersAction.BuildCounterKey, StringComparer.Ordinal) + var allProjectRules = await GetAllEnabledRulesAsync(projectId, context); + var validProjectRules = allProjectRules + .Where(rule => String.Equals(rule.OrganizationId, project.OrganizationId, StringComparison.Ordinal) && + RateNotificationCounterPlan.IsValidRuntimeDefinition(rule, projectId)) + .ToList(); + if (validProjectRules.Count != allProjectRules.Count) + _logger.LogWarning("Skipping {InvalidRuleCount} invalid rate notification rules for project {ProjectId}", allProjectRules.Count - validProjectRules.Count, projectId); + + if (validProjectRules.Count == 0) + return; + + var rulesByCounterKey = validProjectRules + .GroupBy(RateNotificationCounterPlan.BuildCounterKey, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.Ordinal); + int evaluatedRules = 0; foreach (string counterKey in counterKeys) { if (!rulesByCounterKey.TryGetValue(counterKey, out var matchingRules)) @@ -123,10 +144,27 @@ private async Task EvaluateProjectAsync(string projectId, IEnumerable co return; await EvaluateRuleAsync(rule, counterKey, evaluationEndUtc, ct); + if (++evaluatedRules % 100 == 0) + await context.RenewLockAsync(); } } } + private async Task> GetAllEnabledRulesAsync(string projectId, JobContext context) + { + var ct = context.CancellationToken; + var rules = new List(); + var results = await _ruleRepository.GetEnabledByProjectIdAsync(projectId, o => o.SearchAfterPaging().PageLimit(500)); + do + { + ct.ThrowIfCancellationRequested(); + rules.AddRange(results.Documents); + await context.RenewLockAsync(); + } while (await results.NextPageAsync()); + + return rules; + } + private async Task EvaluateRuleAsync(RateNotificationRule rule, string counterKey, DateTime evaluationEndUtc, CancellationToken ct) { if (!rule.IsEnabled) @@ -193,7 +231,7 @@ await _notificationQueue.EnqueueAsync(new RateNotification // Update LastFiredUtc rule.LastFiredUtc = evaluationEndUtc; - await _ruleRepository.SaveAsync(rule); + await _ruleRepository.SaveAsync(rule, o => o.Notifications(false)); _logger.LogInformation("Rate notification fired: rule={RuleId} project={ProjectId} observed={Observed} threshold={Threshold}", rule.Id, rule.ProjectId, observedCount, rule.Threshold); @@ -203,7 +241,7 @@ await _notificationQueue.EnqueueAsync(new RateNotification private static string? ParseProjectIdFromCounterKey(string counterKey) { const string prefix = "project:"; - if (!counterKey.StartsWith(prefix)) + if (!counterKey.StartsWith(prefix, StringComparison.Ordinal)) return null; int start = prefix.Length; @@ -211,7 +249,8 @@ await _notificationQueue.EnqueueAsync(new RateNotification if (end < 0) return null; - return counterKey[start..end]; + string projectId = counterKey[start..end]; + return ObjectId.IsValid(projectId) ? projectId : null; } private static string BuildSubjectKey(RateNotificationRule rule) diff --git a/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs index 03cd8792b9..295c1ced80 100644 --- a/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs +++ b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs @@ -3,10 +3,12 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Foundatio.Jobs; using Foundatio.Queues; using Foundatio.Repositories; +using Foundatio.Repositories.Utility; using Foundatio.Resilience; using Microsoft.Extensions.Logging; @@ -46,6 +48,21 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex { var wi = context.QueueEntry.Value; + if (String.IsNullOrEmpty(wi.RuleId) || + String.IsNullOrEmpty(wi.OrganizationId) || + String.IsNullOrEmpty(wi.ProjectId) || + String.IsNullOrEmpty(wi.UserId) || + String.IsNullOrEmpty(wi.SubjectKey) || + !ObjectId.IsValid(wi.RuleId) || + !ObjectId.IsValid(wi.OrganizationId) || + !ObjectId.IsValid(wi.ProjectId) || + !ObjectId.IsValid(wi.UserId) || + (!String.IsNullOrEmpty(wi.StackId) && !ObjectId.IsValid(wi.StackId))) + { + _logger.LogWarning("Rate notification payload is missing required identifiers; skipping"); + return Skip("Rate notification payload is missing required identifiers; skipping."); + } + // Load rule var rule = await _ruleRepository.GetByIdAsync(wi.RuleId); if (rule is null) @@ -54,6 +71,12 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (!rule.IsEnabled) return Skip($"Rule {wi.RuleId} is disabled; skipping."); + if (!RateNotificationCounterPlan.IsValidRuntimeDefinition(rule, rule.ProjectId)) + { + _logger.LogWarning("Rate notification rule {RuleId} has an invalid definition; skipping", wi.RuleId); + return Skip($"Rate notification rule {wi.RuleId} has an invalid definition; skipping."); + } + string expectedSubjectKey = rule.Subject == RateNotificationSubject.Stack ? $"stack:{rule.StackId}" : $"project:{rule.ProjectId}"; @@ -62,7 +85,10 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex !String.Equals(rule.UserId, wi.UserId, StringComparison.Ordinal) || !String.Equals(rule.StackId, wi.StackId, StringComparison.Ordinal) || !String.Equals(expectedSubjectKey, wi.SubjectKey, StringComparison.Ordinal) || - wi.Threshold != rule.Threshold || wi.ObservedCount < 0 || wi.WindowStartUtc >= wi.WindowEndUtc) + wi.Threshold != rule.Threshold || + wi.ObservedCount < rule.Threshold || + wi.WindowStartUtc >= wi.WindowEndUtc || + wi.WindowEndUtc - wi.WindowStartUtc > rule.Window) { _logger.LogWarning("Rate notification payload does not match rule {RuleId}; skipping", wi.RuleId); return Skip($"Rate notification payload does not match rule {wi.RuleId}; skipping."); diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 640a1c40f8..098d36d683 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -323,7 +323,7 @@ public Task SendRateNotificationAsync(User user, Project project, RateNotificati string windowDescription = FormatWindow(rule.Window); var data = new Dictionary { - { "Subject", $"Error rate exceeded: {rule.Name}" }, + { "Subject", "Error rate exceeded" }, { "BaseUrl", _appOptions.BaseURL }, { "ProjectName", project.Name }, { "ProjectId", project.Id }, @@ -344,7 +344,7 @@ public Task SendRateNotificationAsync(User user, Project project, RateNotificati return QueueMessageAsync(new MailMessage { To = user.EmailAddress, - Subject = $"[{project.Name}] Error rate exceeded: {rule.Name}", + Subject = $"[{project.Name}] Error rate exceeded", Body = RenderTemplate(template, data) }, template); } diff --git a/src/Exceptionless.Core/Models/RateNotificationRule.cs b/src/Exceptionless.Core/Models/RateNotificationRule.cs index 0ffa6ad49a..4ad5e43932 100644 --- a/src/Exceptionless.Core/Models/RateNotificationRule.cs +++ b/src/Exceptionless.Core/Models/RateNotificationRule.cs @@ -6,6 +6,9 @@ namespace Exceptionless.Core.Models; public class RateNotificationRule : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates { + public static TimeSpan MaximumWindow { get; } = TimeSpan.FromHours(1); + public static TimeSpan MaximumCooldown { get; } = TimeSpan.FromDays(1); + [ObjectId] public string Id { get; set; } = null!; @@ -29,8 +32,10 @@ public class RateNotificationRule : IOwnedByOrganizationAndProjectWithIdentity, public bool IsEnabled { get; set; } = true; + [EnumDataType(typeof(RateNotificationSignal))] public RateNotificationSignal Signal { get; set; } + [EnumDataType(typeof(RateNotificationSubject))] public RateNotificationSubject Subject { get; set; } [ObjectId] diff --git a/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs index 8d4de69ca6..5ace30c48e 100644 --- a/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs +++ b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs @@ -27,30 +27,33 @@ public UpdateRateCountersAction( public override async Task ProcessAsync(EventContext ctx) { - // Premium gate — rate notifications require premium features - if (!ctx.Organization.HasRateNotifications()) + if (!ShouldIncrement(ctx)) return; - // Stack must allow notifications - if (ctx.Stack is null || !ctx.Stack.AllowNotifications) + // Load the compiled counter plan for this project. Cache misses perform the only rule scan. + var counterPlan = await _ruleCache.GetCounterPlanAsync(ctx.Event.ProjectId); + if (!counterPlan.HasCounters) return; - // Load enabled rules for this project - var rules = await _ruleCache.GetEnabledRulesAsync(ctx.Event.ProjectId); - if (rules.Count == 0) - return; + var counterKeys = GetCounterKeys(ctx.Event, ctx.IsNew, ctx.IsRegression, counterPlan); + AppDiagnostics.RateCounterKeysIncremented.Add(counterKeys.Count); + await _counterService.IncrementAsync(counterKeys); + } - // RequestInfoPlugin already performs the user-agent work earlier in the pipeline. - if (ctx.Event.Data?.GetValueOrDefault(Event.KnownDataKeys.RequestInfo) is RequestInfo request && - request.Data?.GetValueOrDefault(RequestInfo.KnownDataKeys.IsBot) is true) - return; + internal static bool ShouldIncrement(EventContext ctx) + { + if (ctx.IsCancelled || ctx.IsDiscarded || !ctx.Organization.HasRateNotifications()) + return false; - var counterKeys = GetCounterKeys(ctx.Event, ctx.IsNew, ctx.IsRegression, rules); - AppDiagnostics.RateCounterKeysIncremented.Add(counterKeys.Count); - await Task.WhenAll(counterKeys.Select(key => _counterService.IncrementAsync(key))); + if (ctx.Stack is null || !ctx.Stack.AllowNotifications) + return false; + + // RequestInfoPlugin already performs the user-agent work earlier in the pipeline. + return ctx.Event.Data?.GetValueOrDefault(Event.KnownDataKeys.RequestInfo) is not RequestInfo request || + request.Data?.GetValueOrDefault(RequestInfo.KnownDataKeys.IsBot) is not true; } - internal static IReadOnlyCollection GetCounterKeys(PersistentEvent ev, bool isNew, bool isRegression, IEnumerable rules) + internal static IReadOnlyCollection GetCounterKeys(PersistentEvent ev, bool isNew, bool isRegression, RateNotificationCounterPlan counterPlan) { bool isError = ev.IsError(); bool isCritical = ev.IsCritical(); @@ -70,22 +73,6 @@ internal static IReadOnlyCollection GetCounterKeys(PersistentEvent ev, b if (isRegression) matchedSignals.Add(RateNotificationSignal.Regressions); - return rules - .Where(r => matchedSignals.Contains(r.Signal)) - .Where(r => r.Subject == RateNotificationSubject.Project || - !String.IsNullOrEmpty(r.StackId) && String.Equals(ev.StackId, r.StackId, StringComparison.Ordinal)) - .Select(BuildCounterKey) - .Distinct(StringComparer.Ordinal) - .ToList(); - } - - internal static string BuildCounterKey(RateNotificationRule rule) - { - return rule.Subject switch - { - RateNotificationSubject.Project => $"project:{rule.ProjectId}:signal:{rule.Signal}", - RateNotificationSubject.Stack => $"project:{rule.ProjectId}:stack:{rule.StackId}:signal:{rule.Signal}", - _ => $"project:{rule.ProjectId}:signal:{rule.Signal}" - }; + return counterPlan.GetCounterKeys(ev.StackId, matchedSignals); } } diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs index bf7dd36e23..3e575c60c1 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs @@ -7,6 +7,7 @@ namespace Exceptionless.Core.Repositories; public interface IRateNotificationRuleRepository : IRepositoryOwnedByOrganizationAndProject { Task> GetByProjectIdAndUserIdAsync(string projectId, string userId, CommandOptionsDescriptor? options = null); + Task> GetByOrganizationIdAndUserIdAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null); Task> GetEnabledByProjectIdAsync(string projectId, CommandOptionsDescriptor? options = null); Task CountByProjectIdAndUserIdAsync(string projectId, string userId); Task RemoveAllByOrganizationIdAndUserIdAsync(string organizationId, string userId); diff --git a/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs index 6caae70608..af1ad51956 100644 --- a/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs +++ b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs @@ -31,7 +31,19 @@ public Task> GetEnabledByProjectIdAsync(string return FindAsync(q => q .Project(projectId) .FieldEquals(r => r.IsEnabled, true) - .FieldEquals(r => r.IsDeleted, false), options); + .FieldEquals(r => r.IsDeleted, false) + .SortAscending(r => r.Id), options); + } + + public Task> GetByOrganizationIdAndUserIdAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(organizationId); + ArgumentException.ThrowIfNullOrEmpty(userId); + + return FindAsync(q => q + .Organization(organizationId) + .FieldEquals(r => r.UserId, userId) + .SortAscending(r => r.Id), options); } public async Task CountByProjectIdAndUserIdAsync(string projectId, string userId) @@ -44,6 +56,19 @@ public async Task CountByProjectIdAndUserIdAsync(string projectId, string .FieldEquals(r => r.UserId, userId)); } + public override Task RemoveAllByOrganizationIdAsync(string organizationId) + { + ArgumentException.ThrowIfNullOrEmpty(organizationId); + return RemoveAllAsync(q => q.Organization(organizationId), o => o.ImmediateConsistency()); + } + + public override Task RemoveAllByProjectIdAsync(string organizationId, string projectId) + { + ArgumentException.ThrowIfNullOrEmpty(organizationId); + ArgumentException.ThrowIfNullOrEmpty(projectId); + return RemoveAllAsync(q => q.Organization(organizationId).Project(projectId), o => o.ImmediateConsistency()); + } + public Task RemoveAllByOrganizationIdAndUserIdAsync(string organizationId, string userId) { ArgumentException.ThrowIfNullOrEmpty(organizationId); @@ -51,6 +76,6 @@ public Task RemoveAllByOrganizationIdAndUserIdAsync(string organizationId, return RemoveAllAsync(q => q .Organization(organizationId) - .FieldEquals(r => r.UserId, userId)); + .FieldEquals(r => r.UserId, userId), o => o.ImmediateConsistency()); } } diff --git a/src/Exceptionless.Core/Services/OrganizationService.cs b/src/Exceptionless.Core/Services/OrganizationService.cs index 5b382bfe6d..0b7f797ea6 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -15,6 +15,7 @@ public class OrganizationService : IStartupAction private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly IRateNotificationRuleRepository _rateNotificationRuleRepository; + private readonly RateNotificationRuleCache _rateNotificationRuleCache; private readonly ISavedViewRepository _savedViewRepository; private readonly ITokenRepository _tokenRepository; private readonly IUserRepository _userRepository; @@ -23,11 +24,12 @@ public class OrganizationService : IStartupAction private readonly UsageService _usageService; private readonly ILogger _logger; - public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IRateNotificationRuleRepository rateNotificationRuleRepository, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, IStripeBillingClient stripeBillingClient, UsageService usageService, ILoggerFactory loggerFactory) + public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IRateNotificationRuleRepository rateNotificationRuleRepository, RateNotificationRuleCache rateNotificationRuleCache, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, IStripeBillingClient stripeBillingClient, UsageService usageService, ILoggerFactory loggerFactory) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; _rateNotificationRuleRepository = rateNotificationRuleRepository; + _rateNotificationRuleCache = rateNotificationRuleCache; _savedViewRepository = savedViewRepository; _tokenRepository = tokenRepository; _userRepository = userRepository; @@ -195,22 +197,30 @@ public Task RemoveUserSavedViewsAsync(string organizationId, string userId return _savedViewRepository.RemovePrivateByUserIdAsync(organizationId, userId); } - public Task RemoveRateNotificationRulesAsync(string organizationId) + public async Task RemoveRateNotificationRulesAsync(string organizationId) { _logger.LogDebug("Removing rate notification rules for organization {OrganizationId}", organizationId); - return _rateNotificationRuleRepository.RemoveAllByOrganizationIdAsync(organizationId); + var projectIds = await GetRateNotificationProjectIdsAsync(organizationId); + long removed = await _rateNotificationRuleRepository.RemoveAllByOrganizationIdAsync(organizationId); + await Task.WhenAll(projectIds.Select(_rateNotificationRuleCache.InvalidateAsync)); + return removed; } - public Task RemoveProjectRateNotificationRulesAsync(string organizationId, string projectId) + public async Task RemoveProjectRateNotificationRulesAsync(string organizationId, string projectId) { _logger.LogDebug("Removing rate notification rules for project {ProjectId} in organization {OrganizationId}", projectId, organizationId); - return _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(organizationId, projectId); + long removed = await _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(organizationId, projectId); + await _rateNotificationRuleCache.InvalidateAsync(projectId); + return removed; } - public Task RemoveUserRateNotificationRulesAsync(string organizationId, string userId) + public async Task RemoveUserRateNotificationRulesAsync(string organizationId, string userId) { _logger.LogDebug("Removing rate notification rules for user {UserId} in organization {OrganizationId}", userId, organizationId); - return _rateNotificationRuleRepository.RemoveAllByOrganizationIdAndUserIdAsync(organizationId, userId); + var projectIds = await GetRateNotificationProjectIdsAsync(organizationId, userId); + long removed = await _rateNotificationRuleRepository.RemoveAllByOrganizationIdAndUserIdAsync(organizationId, userId); + await Task.WhenAll(projectIds.Select(_rateNotificationRuleCache.InvalidateAsync)); + return removed; } public async Task SoftDeleteOrganizationAsync(Organization organization, string currentUserId) @@ -247,6 +257,21 @@ private async Task> GetValidNotificationUserIdsAsync(string orga return validUserIds; } + private async Task> GetRateNotificationProjectIdsAsync(string organizationId, string? userId = null) + { + var projectIds = new HashSet(StringComparer.Ordinal); + var results = userId is null + ? await _rateNotificationRuleRepository.GetByOrganizationIdAsync(organizationId, o => o.SearchAfterPaging().PageLimit(BATCH_SIZE)) + : await _rateNotificationRuleRepository.GetByOrganizationIdAndUserIdAsync(organizationId, userId, o => o.SearchAfterPaging().PageLimit(BATCH_SIZE)); + + do + { + projectIds.UnionWith(results.Documents.Select(rule => rule.ProjectId)); + } while (await results.NextPageAsync()); + + return projectIds; + } + private static int RemoveInvalidNotificationSettings(Project project, IReadOnlySet validUserIds, IReadOnlySet userIdsToRemove) { var keysToRemove = project.NotificationSettings.Keys diff --git a/src/Exceptionless.Core/Services/RateCounterService.cs b/src/Exceptionless.Core/Services/RateCounterService.cs index b48023eb31..40ae586845 100644 --- a/src/Exceptionless.Core/Services/RateCounterService.cs +++ b/src/Exceptionless.Core/Services/RateCounterService.cs @@ -25,16 +25,28 @@ public RateCounterService(ICacheClient cache, TimeProvider timeProvider) } /// Increments the 1-minute bucket counter for the given counter key at the current UTC minute. - public async Task IncrementAsync(string counterKey, CancellationToken ct = default) + public Task IncrementAsync(string counterKey, CancellationToken ct = default) + => IncrementAsync([counterKey], ct); + + /// Increments all matching counters and records their active keys with one list operation. + public async Task IncrementAsync(IReadOnlyCollection counterKeys, CancellationToken ct = default) { + if (counterKeys.Count == 0) + return; + + ct.ThrowIfCancellationRequested(); var now = _timeProvider.GetUtcNow().UtcDateTime; long epochMinute = GetEpochMinute(now); + var distinctCounterKeys = counterKeys.Distinct(StringComparer.Ordinal).ToList(); - string countKey = GetCountKey(epochMinute, counterKey); - await _cache.IncrementAsync(countKey, 1, BucketTtl); + var increments = distinctCounterKeys.Select(async counterKey => + { + await _cache.IncrementAsync(GetCountKey(epochMinute, counterKey), 1, BucketTtl); + }); string activeKey = GetActiveKey(epochMinute); - await _cache.ListAddAsync(activeKey, counterKey, BucketTtl); + Task updateActiveKeys = _cache.ListAddAsync(activeKey, distinctCounterKeys, BucketTtl); + await Task.WhenAll(increments.Append(updateActiveKeys)); } /// Sums all 1-minute bucket counts for the given counter key in the range [fromUtc, toUtc). diff --git a/src/Exceptionless.Core/Services/RateNotificationCounterPlan.cs b/src/Exceptionless.Core/Services/RateNotificationCounterPlan.cs new file mode 100644 index 0000000000..31cf5d9bc4 --- /dev/null +++ b/src/Exceptionless.Core/Services/RateNotificationCounterPlan.cs @@ -0,0 +1,105 @@ +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Services; + +/// +/// Compiled, project-scoped counter plan used by the event ingestion hot path. +/// Multiple rules that observe the same signal and subject share one counter. +/// +public sealed class RateNotificationCounterPlan +{ + public string ProjectId { get; set; } = String.Empty; + public int RuleCount { get; set; } + public Dictionary ProjectCounters { get; set; } = []; + public Dictionary> StackCounters { get; set; } = new(StringComparer.Ordinal); + + public bool HasCounters => ProjectCounters.Count > 0 || StackCounters.Count > 0; + + public static RateNotificationCounterPlan Compile(string projectId, IEnumerable rules) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + + var plan = new RateNotificationCounterPlan { ProjectId = projectId }; + foreach (var rule in rules) + { + if (!IsValidRuntimeDefinition(rule, projectId)) + continue; + + plan.RuleCount++; + + if (rule.Subject == RateNotificationSubject.Project) + { + plan.ProjectCounters.TryAdd(rule.Signal, BuildCounterKey(rule)); + continue; + } + + if (rule.Subject != RateNotificationSubject.Stack || String.IsNullOrEmpty(rule.StackId)) + continue; + + if (!plan.StackCounters.TryGetValue(rule.StackId, out var counters)) + { + counters = []; + plan.StackCounters.Add(rule.StackId, counters); + } + + counters.TryAdd(rule.Signal, BuildCounterKey(rule)); + } + + return plan; + } + + public static bool IsValidRuntimeDefinition(RateNotificationRule rule, string projectId) + { + if (String.IsNullOrEmpty(projectId) || + String.IsNullOrEmpty(rule.Id) || + String.IsNullOrEmpty(rule.OrganizationId) || + String.IsNullOrEmpty(rule.ProjectId) || + String.IsNullOrEmpty(rule.UserId) || + String.IsNullOrWhiteSpace(rule.Name) || + rule.Version <= 0 || + !rule.IsEnabled || rule.IsDeleted || + !String.Equals(rule.ProjectId, projectId, StringComparison.Ordinal) || + rule.Threshold <= 0 || + rule.Window <= TimeSpan.Zero || rule.Window > RateNotificationRule.MaximumWindow || + rule.Cooldown < rule.Window || rule.Cooldown > RateNotificationRule.MaximumCooldown || + !Enum.IsDefined(rule.Signal) || + !Enum.IsDefined(rule.Subject)) + { + return false; + } + + return rule.Subject switch + { + RateNotificationSubject.Project => String.IsNullOrEmpty(rule.StackId), + RateNotificationSubject.Stack => !String.IsNullOrEmpty(rule.StackId), + _ => false + }; + } + + public IReadOnlyCollection GetCounterKeys(string? stackId, IEnumerable signals) + { + StackCounters.TryGetValue(stackId ?? String.Empty, out var stackCounters); + var keys = new List(); + + foreach (var signal in signals) + { + if (ProjectCounters.TryGetValue(signal, out string? projectCounter)) + keys.Add(projectCounter); + + if (stackCounters?.TryGetValue(signal, out string? stackCounter) == true) + keys.Add(stackCounter); + } + + return keys; + } + + public static string BuildCounterKey(RateNotificationRule rule) + { + return rule.Subject switch + { + RateNotificationSubject.Project => $"project:{rule.ProjectId}:signal:{rule.Signal}", + RateNotificationSubject.Stack => $"project:{rule.ProjectId}:stack:{rule.StackId}:signal:{rule.Signal}", + _ => throw new ArgumentOutOfRangeException(nameof(rule), rule.Subject, "Unsupported rate notification subject.") + }; + } +} diff --git a/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs index 2f30c86ee3..ff84cd7525 100644 --- a/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs +++ b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs @@ -1,19 +1,21 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Foundatio.Caching; +using Foundatio.Extensions.Hosting.Startup; using Foundatio.Repositories; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Services; /// -/// Cache layer over IRateNotificationRuleRepository. -/// Cache key: rate:v1:rules:project:{projectId} TTL: 5 minutes +/// Compiles and caches the project counter plan used by event ingestion. +/// Cache key: rate:v2:counter-plan:project:{projectId} TTL: 5 minutes /// -public class RateNotificationRuleCache +public class RateNotificationRuleCache : IStartupAction { private readonly IRateNotificationRuleRepository _repository; private readonly IHybridCacheClient _cache; + private readonly SemaphoreSlim[] _cacheGates = Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); @@ -23,28 +25,80 @@ public RateNotificationRuleCache(IRateNotificationRuleRepository repository, IHy _cache = cache; } - /// Returns all enabled, non-deleted rules for the given project (cached). - public async Task> GetEnabledRulesAsync(string projectId, CancellationToken ct = default) + public Task RunAsync(CancellationToken shutdownToken = default) { + _repository.DocumentsChanged.AddHandler(OnRulesChangedAsync); + return Task.CompletedTask; + } + + /// Returns the compiled counter plan for all enabled rules in the project. + public async Task GetCounterPlanAsync(string projectId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); string cacheKey = GetCacheKey(projectId); - var cached = await _cache.GetAsync>(cacheKey); + var cached = await _cache.GetAsync(cacheKey); if (cached.HasValue && cached.Value is not null) return cached.Value; - var results = await _repository.GetEnabledByProjectIdAsync(projectId, o => o.PageLimit(1000)); - var rules = results.Documents.ToList(); + var cacheGate = GetCacheGate(projectId); + await cacheGate.WaitAsync(ct); + try + { + cached = await _cache.GetAsync(cacheKey); + if (cached.HasValue && cached.Value is not null) + return cached.Value; - await _cache.SetAsync(cacheKey, rules, CacheTtl); - return rules; + var rules = new List(); + var results = await _repository.GetEnabledByProjectIdAsync(projectId, o => o.SearchAfterPaging().PageLimit(500)); + do + { + ct.ThrowIfCancellationRequested(); + rules.AddRange(results.Documents); + } while (await results.NextPageAsync()); + + var plan = RateNotificationCounterPlan.Compile(projectId, rules); + await _cache.SetAsync(cacheKey, plan, CacheTtl); + return plan; + } + finally + { + cacheGate.Release(); + } } /// Invalidates the cache for the given project. - public Task InvalidateAsync(string projectId) + public async Task InvalidateAsync(string projectId) { - string cacheKey = GetCacheKey(projectId); - return _cache.RemoveAsync(cacheKey); + ArgumentException.ThrowIfNullOrEmpty(projectId); + var cacheGate = GetCacheGate(projectId); + await cacheGate.WaitAsync(); + try + { + await _cache.RemoveAsync(GetCacheKey(projectId)); + } + finally + { + cacheGate.Release(); + } + } + + private Task OnRulesChangedAsync(object sender, DocumentsChangeEventArgs args) + { + var projectIds = args.Documents + .SelectMany(document => new[] { document.Original?.ProjectId, document.Value?.ProjectId }) + .Where(projectId => !String.IsNullOrEmpty(projectId)) + .Distinct(StringComparer.Ordinal) + .Select(projectId => InvalidateAsync(projectId!)); + + return Task.WhenAll(projectIds); + } + + private SemaphoreSlim GetCacheGate(string projectId) + { + uint hash = unchecked((uint)StringComparer.Ordinal.GetHashCode(projectId)); + return _cacheGates[hash % _cacheGates.Length]; } - private static string GetCacheKey(string projectId) => $"rate:v1:rules:project:{projectId}"; + private static string GetCacheKey(string projectId) => $"rate:v2:counter-plan:project:{projectId}"; } diff --git a/src/Exceptionless.Job/JobRunnerOptions.cs b/src/Exceptionless.Job/JobRunnerOptions.cs index a8faf591e8..2f21123381 100644 --- a/src/Exceptionless.Job/JobRunnerOptions.cs +++ b/src/Exceptionless.Job/JobRunnerOptions.cs @@ -53,6 +53,14 @@ public JobRunnerOptions(string[] args) if (MailMessage && args.Length != 0) JobName = nameof(MailMessage); + RateNotificationEvaluator = args.Length == 0 || args.Contains(nameof(RateNotificationEvaluator), StringComparer.OrdinalIgnoreCase); + if (RateNotificationEvaluator && args.Length != 0) + JobName = nameof(RateNotificationEvaluator); + + RateNotifications = args.Length == 0 || args.Contains(nameof(RateNotifications), StringComparer.OrdinalIgnoreCase); + if (RateNotifications && args.Length != 0) + JobName = nameof(RateNotifications); + MaintainIndexes = args.Length == 0 || args.Contains(nameof(MaintainIndexes), StringComparer.OrdinalIgnoreCase); if (MaintainIndexes && args.Length != 0) JobName = nameof(MaintainIndexes); @@ -91,6 +99,8 @@ public JobRunnerOptions(string[] args) public bool EventUsage { get; } public bool EventUserDescriptions { get; } public bool MailMessage { get; } + public bool RateNotificationEvaluator { get; } + public bool RateNotifications { get; } public bool MaintainIndexes { get; } public bool Migration { get; } public bool StackStatus { get; } diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 5d2191ab4f..5f54f5771c 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -169,6 +169,13 @@ private static void AddJobs(IServiceCollection services, JobRunnerOptions option if (options.MailMessage) services.AddJob(o => o.WaitForStartupActions()); + if (options is { RateNotificationEvaluator: true, AllJobs: true }) + services.AddCronJob(Cron.Minutely()); + if (options is { RateNotificationEvaluator: true, AllJobs: false }) + services.AddJob(o => o.WaitForStartupActions()); + if (options.RateNotifications) + services.AddJob(o => o.WaitForStartupActions()); + if (options is { MaintainIndexes: true, AllJobs: true }) services.AddCronJob("10 */2 * * *"); if (options is { MaintainIndexes: true, AllJobs: false }) diff --git a/src/Exceptionless.Web/ClientApp/playwright.config.ts b/src/Exceptionless.Web/ClientApp/playwright.config.ts index 80170cf9c0..e09e4d80f7 100644 --- a/src/Exceptionless.Web/ClientApp/playwright.config.ts +++ b/src/Exceptionless.Web/ClientApp/playwright.config.ts @@ -37,5 +37,5 @@ export default defineConfig({ video: 'retain-on-failure' }, - workers: isCi ? 1 : undefined + workers: 1 }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte index eae4237066..03cac78fd4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte @@ -22,6 +22,7 @@ delete_bot_data_enabled: false, event_count: 150, has_premium_features: false, + has_rate_notifications: false, has_slack_integration: false, id: '1', is_configured: false, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts index d7bd19cf55..209ee28dcb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts @@ -11,13 +11,25 @@ import { createMutation, createQuery, type QueryClient, useQueryClient } from '@ const CONSISTENCY_REFRESH_DELAY_MS = 1500; +interface BodyMutationVariables extends MutationRoute { + body: TBody; +} + +interface MutationRoute { + projectId: string; + userId: string; +} + interface RateNotificationRoute { projectId: string | undefined; userId: string | undefined; } -interface RuleMutationVariables { +interface RuleBodyMutationVariables extends RuleMutationVariables { body: TBody; +} + +interface RuleMutationVariables extends MutationRoute { ruleId: string; } @@ -25,18 +37,17 @@ export const queryKeys = { list: (userId: string | undefined, projectId: string | undefined) => ['RateNotificationRule', userId, projectId] as const }; -export function deleteRateNotificationRule(request: { route: RateNotificationRoute }) { +export function deleteRateNotificationRule() { const queryClient = useQueryClient(); - return createMutation(() => ({ - enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, - mutationFn: async (ruleId: string) => { + return createMutation(() => ({ + mutationFn: async (variables) => { const client = useFetchClient(); - await client.delete(ruleRoute(request.route, ruleId), { expectedStatusCodes: [204] }); + await client.delete(ruleRoute(variables, variables.ruleId), { expectedStatusCodes: [204] }); }, - onSuccess: (_, ruleId) => { - updateRulesCache(queryClient, request.route, (rules) => rules.filter((rule) => rule.id !== ruleId)); - scheduleConsistencyRefresh(queryClient, request.route); + onSuccess: (_, variables) => { + updateRulesCache(queryClient, variables, (rules) => rules.filter((rule) => rule.id !== variables.ruleId)); + scheduleConsistencyRefresh(queryClient, variables); } })); } @@ -55,51 +66,48 @@ export function getRateNotificationRulesQuery(request: { params?: { limit?: numb })); } -export function postRateNotificationRule(request: { route: RateNotificationRoute }) { +export function postRateNotificationRule() { const queryClient = useQueryClient(); - return createMutation(() => ({ - enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, - mutationFn: async (body: NewRateNotificationRule) => { + return createMutation>(() => ({ + mutationFn: async ({ body, ...route }) => { const client = useFetchClient(); - const response = await client.postJSON(ruleRoute(request.route), body); + const response = await client.postJSON(ruleRoute(route), body); return response.data!; }, - onSuccess: (rule) => { - updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); - scheduleConsistencyRefresh(queryClient, request.route); + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); } })); } -export function postSnoozeRateNotificationRule(request: { route: RateNotificationRoute }) { +export function postSnoozeRateNotificationRule() { const queryClient = useQueryClient(); - return createMutation>(() => ({ - enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, - mutationFn: async ({ body, ruleId }) => { + return createMutation>(() => ({ + mutationFn: async ({ body, ruleId, ...route }) => { const client = useFetchClient(); - const response = await client.postJSON(`${ruleRoute(request.route, ruleId)}/snooze`, body, { + const response = await client.postJSON(`${ruleRoute(route, ruleId)}/snooze`, body, { expectedStatusCodes: [200] }); return response.data!; }, - onSuccess: (rule) => { - updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); - scheduleConsistencyRefresh(queryClient, request.route); + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); } })); } -export function postUnsnoozeRateNotificationRule(request: { route: RateNotificationRoute }) { +export function postUnsnoozeRateNotificationRule() { const queryClient = useQueryClient(); - return createMutation(() => ({ - enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, - mutationFn: async (ruleId: string) => { + return createMutation(() => ({ + mutationFn: async (variables) => { const client = useFetchClient(); const response = await client.postJSON( - `${ruleRoute(request.route, ruleId)}/unsnooze`, + `${ruleRoute(variables, variables.ruleId)}/unsnooze`, {}, { expectedStatusCodes: [200] @@ -107,26 +115,25 @@ export function postUnsnoozeRateNotificationRule(request: { route: RateNotificat ); return response.data!; }, - onSuccess: (rule) => { - updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); - scheduleConsistencyRefresh(queryClient, request.route); + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); } })); } -export function putRateNotificationRule(request: { route: RateNotificationRoute }) { +export function putRateNotificationRule() { const queryClient = useQueryClient(); - return createMutation>(() => ({ - enabled: () => !!accessToken.current && !!request.route.userId && !!request.route.projectId, - mutationFn: async ({ body, ruleId }) => { + return createMutation>(() => ({ + mutationFn: async ({ body, ruleId, ...route }) => { const client = useFetchClient(); - const response = await client.putJSON(ruleRoute(request.route, ruleId), body); + const response = await client.putJSON(ruleRoute(route, ruleId), body); return response.data!; }, - onSuccess: (rule) => { - updateRulesCache(queryClient, request.route, (rules) => upsertRule(rules, rule)); - scheduleConsistencyRefresh(queryClient, request.route); + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); } })); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts index eea0bed36b..8569867d26 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts @@ -96,21 +96,28 @@ describe('rate notification API', () => { threshold: 10, window: '00:05:00' }; - const createMutation = postRateNotificationRule({ route }) as unknown as Mutation; - const deleteMutation = deleteRateNotificationRule({ route }) as unknown as Mutation; - const snoozeMutation = postSnoozeRateNotificationRule({ route }) as unknown as Mutation<{ + const createMutation = postRateNotificationRule() as unknown as Mutation<{ body: typeof body; projectId: string; userId: string }>; + const deleteMutation = deleteRateNotificationRule() as unknown as Mutation<{ projectId: string; ruleId: string; userId: string }>; + const snoozeMutation = postSnoozeRateNotificationRule() as unknown as Mutation<{ body: { duration_seconds: number }; + projectId: string; ruleId: string; + userId: string; + }>; + const unsnoozeMutation = postUnsnoozeRateNotificationRule() as unknown as Mutation<{ projectId: string; ruleId: string; userId: string }>; + const updateMutation = putRateNotificationRule() as unknown as Mutation<{ + body: { is_enabled: boolean }; + projectId: string; + ruleId: string; + userId: string; }>; - const unsnoozeMutation = postUnsnoozeRateNotificationRule({ route }) as unknown as Mutation; - const updateMutation = putRateNotificationRule({ route }) as unknown as Mutation<{ body: { is_enabled: boolean }; ruleId: string }>; // Act - await createMutation.mutationFn(body); - await updateMutation.mutationFn({ body: { is_enabled: false }, ruleId: 'update-rule' }); - await snoozeMutation.mutationFn({ body: { duration_seconds: 3600 }, ruleId: 'snooze-rule' }); - await unsnoozeMutation.mutationFn('unsnooze-rule'); - await deleteMutation.mutationFn('delete-rule'); + await createMutation.mutationFn({ ...route, body }); + await updateMutation.mutationFn({ ...route, body: { is_enabled: false }, ruleId: 'update-rule' }); + await snoozeMutation.mutationFn({ ...route, body: { duration_seconds: 3600 }, ruleId: 'snooze-rule' }); + await unsnoozeMutation.mutationFn({ ...route, ruleId: 'unsnooze-rule' }); + await deleteMutation.mutationFn({ ...route, ruleId: 'delete-rule' }); // Assert expect(mocks.client.postJSON).toHaveBeenCalledWith('users/user-id/projects/project-id/rate-notifications', body); @@ -136,8 +143,8 @@ describe('rate notification API', () => { // Arrange vi.useFakeTimers(); const route = { projectId: 'project-id', userId: 'user-id' }; - const mutation = postSnoozeRateNotificationRule({ route }) as unknown as Mutation< - { body: { duration_seconds: number }; ruleId: string }, + const mutation = postSnoozeRateNotificationRule() as unknown as Mutation< + { body: { duration_seconds: number }; projectId: string; ruleId: string; userId: string }, ViewRateNotificationRule >; const rule: ViewRateNotificationRule = { @@ -159,7 +166,7 @@ describe('rate notification API', () => { }; // Act - mutation.onSuccess(rule, { body: { duration_seconds: 3600 }, ruleId: rule.id }); + mutation.onSuccess(rule, { ...route, body: { duration_seconds: 3600 }, ruleId: rule.id }); // Assert expect(mocks.setQueriesData).toHaveBeenCalledOnce(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte index 60adf1f614..955c508896 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte @@ -41,16 +41,9 @@ let { hasPremiumFeatures = false, onCancel, onSaved, projectId, rule, upgrade, userId }: Props = $props(); - const route = { - get projectId() { - return projectId; - }, - get userId() { - return userId; - } - }; - const createMutation = postRateNotificationRule({ route }); - const updateMutation = putRateNotificationRule({ route }); + const activeRule = $derived(rule?.project_id === projectId ? rule : undefined); + const createMutation = postRateNotificationRule(); + const updateMutation = putRateNotificationRule(); const stacksQuery = getProjectStacksQuery({ params: { limit: 100, sort: 'last_occurrence:desc' }, route: { @@ -62,12 +55,12 @@ const selectedStackQuery = getStackQuery({ route: { get id() { - return rule?.stack_id ?? undefined; + return activeRule?.stack_id ?? undefined; } } }); - const isEditing = $derived(!!rule); + const isEditing = $derived(!!activeRule); const stacks = $derived.by(() => { const projectStacks = stacksQuery.data?.data ?? []; const selectedStack = selectedStackQuery.data; @@ -75,7 +68,7 @@ }); const form = createForm(() => ({ - defaultValues: getRateNotificationRuleFormData(rule), + defaultValues: getRateNotificationRuleFormData(activeRule), validators: { onSubmit: RateNotificationRuleSchema, onSubmitAsync: async ({ value }) => { @@ -83,10 +76,17 @@ return { form: 'A premium plan is required to enable rate notifications.' }; } + if (!projectId || !userId) { + return { form: 'The selected project or user is unavailable. Please try again.' }; + } + try { const request = toRateNotificationRuleRequest(value, hasPremiumFeatures); - const saved = rule ? await updateMutation.mutateAsync({ body: request, ruleId: rule.id }) : await createMutation.mutateAsync(request); - toast.success(rule ? 'Rule updated.' : 'Rule created.'); + const route = { projectId, userId }; + const saved = activeRule + ? await updateMutation.mutateAsync({ ...route, body: request, ruleId: activeRule.id }) + : await createMutation.mutateAsync({ ...route, body: request }); + toast.success(activeRule ? 'Rule updated.' : 'Rule created.'); onSaved?.(saved); return null; } catch (error: unknown) { @@ -102,7 +102,7 @@ $effect(() => { const selectedProjectId = projectId; - const selectedRule = rule; + const selectedRule = activeRule; untrack(() => { void selectedProjectId; setFormValues(selectedRule); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte index 2b444d441c..f54468b683 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte @@ -16,7 +16,7 @@ postUnsnoozeRateNotificationRule, putRateNotificationRule } from '$features/rate-notifications/api.svelte'; - import { MAX_RULES_PER_PROJECT, SIGNAL_LABELS, WINDOW_OPTIONS } from '$features/rate-notifications/types'; + import { MAX_RULES_PER_PROJECT, RateNotificationSubject, SIGNAL_LABELS, WINDOW_OPTIONS } from '$features/rate-notifications/types'; import BellOffIcon from '@lucide/svelte/icons/bell-off'; import InfoIcon from '@lucide/svelte/icons/info'; import PlusIcon from '@lucide/svelte/icons/plus'; @@ -42,24 +42,31 @@ return userId; } }; - const deleteMutation = deleteRateNotificationRule({ route }); + const deleteMutation = deleteRateNotificationRule(); const listQuery = getRateNotificationRulesQuery({ route }); - const snoozeMutation = postSnoozeRateNotificationRule({ route }); - const unsnoozeMutation = postUnsnoozeRateNotificationRule({ route }); - const updateMutation = putRateNotificationRule({ route }); + const snoozeMutation = postSnoozeRateNotificationRule(); + const unsnoozeMutation = postUnsnoozeRateNotificationRule(); + const updateMutation = putRateNotificationRule(); const rules = $derived(listQuery.data?.data ?? []); let confirmDeleteRuleId = $state(); let errorMessage = $state(); + $effect(() => { + void projectId; + void userId; + confirmDeleteRuleId = undefined; + errorMessage = undefined; + }); + async function confirmDelete() { - if (!confirmDeleteRuleId) { + if (!confirmDeleteRuleId || !projectId || !userId) { return; } errorMessage = undefined; try { - await deleteMutation.mutateAsync(confirmDeleteRuleId); + await deleteMutation.mutateAsync({ projectId, ruleId: confirmDeleteRuleId, userId }); confirmDeleteRuleId = undefined; } catch { errorMessage = 'Failed to delete rule. Please try again.'; @@ -68,13 +75,17 @@ } async function handleSnooze(rule: ViewRateNotificationRule) { + if (!projectId || !userId) { + return; + } + errorMessage = undefined; try { if (rule.is_snoozed) { - await unsnoozeMutation.mutateAsync(rule.id); + await unsnoozeMutation.mutateAsync({ projectId, ruleId: rule.id, userId }); toast.success('Rule resumed.'); } else { - await snoozeMutation.mutateAsync({ body: { duration_seconds: 3600 }, ruleId: rule.id }); + await snoozeMutation.mutateAsync({ body: { duration_seconds: 3600 }, projectId, ruleId: rule.id, userId }); toast.success('Rule snoozed for 1 hour.'); } } catch { @@ -84,9 +95,13 @@ } async function toggleEnabled(rule: ViewRateNotificationRule, enabled: boolean) { + if (!projectId || !userId) { + return; + } + errorMessage = undefined; try { - await updateMutation.mutateAsync({ body: { is_enabled: enabled }, ruleId: rule.id }); + await updateMutation.mutateAsync({ body: { is_enabled: enabled }, projectId, ruleId: rule.id, userId }); } catch { errorMessage = 'Failed to update rule. Please try again.'; toast.error(errorMessage); @@ -140,7 +155,7 @@ {#if rule.is_snoozed} {/if} - {#if rule.subject === 'Stack' && rule.stack_id} + {#if rule.subject === RateNotificationSubject.Stack && rule.stack_id} Stack-scoped {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts index c8ac94de03..439f4134ef 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts @@ -4,6 +4,22 @@ import { describe, expect, it } from 'vitest'; import { getRateNotificationRuleFormData, RateNotificationRuleSchema, toRateNotificationRuleRequest } from './schemas'; describe('RateNotificationRuleSchema', () => { + it('rejects a whitespace-only name', () => { + const result = RateNotificationRuleSchema.safeParse({ + cooldown: '00:30:00', + is_enabled: true, + name: ' ', + signal: RateNotificationSignal.Errors, + stack_id: '', + subject: RateNotificationSubject.Project, + threshold: 10, + window: '00:05:00' + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toContainEqual(expect.objectContaining({ path: ['name'] })); + }); + it('requires a stack for stack-scoped rules', () => { const result = RateNotificationRuleSchema.safeParse({ cooldown: '00:30:00', @@ -35,6 +51,53 @@ describe('RateNotificationRuleSchema', () => { expect(result.success).toBe(false); expect(result.error?.issues).toContainEqual(expect.objectContaining({ path: ['cooldown'] })); }); + + it('rejects an unsupported window', () => { + const result = RateNotificationRuleSchema.safeParse({ + cooldown: '00:30:00', + is_enabled: true, + name: 'Unsupported window', + signal: RateNotificationSignal.Errors, + stack_id: '', + subject: RateNotificationSubject.Project, + threshold: 10, + window: '00:07:00' + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toContainEqual(expect.objectContaining({ path: ['window'] })); + }); + + it('rejects a cooldown longer than 24 hours', () => { + const result = RateNotificationRuleSchema.safeParse({ + cooldown: '1.01:00:00', + is_enabled: true, + name: 'Excessive cooldown', + signal: RateNotificationSignal.Errors, + stack_id: '', + subject: RateNotificationSubject.Project, + threshold: 10, + window: '00:30:00' + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toContainEqual(expect.objectContaining({ path: ['cooldown'] })); + }); + + it('accepts the .NET one-day TimeSpan wire format', () => { + const result = RateNotificationRuleSchema.safeParse({ + cooldown: '1.00:00:00', + is_enabled: true, + name: 'Daily cooldown', + signal: RateNotificationSignal.Errors, + stack_id: '', + subject: RateNotificationSubject.Project, + threshold: 10, + window: '01:00:00' + }); + + expect(result.success).toBe(true); + }); }); describe('rate notification form mapping', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts index 8ba12649de..20f9d9f0a3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts @@ -1,24 +1,40 @@ import type { NewRateNotificationRule, ViewRateNotificationRule } from '$generated/api'; +import { WINDOW_OPTIONS } from '$features/rate-notifications/types'; import { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; import { NewRateNotificationRuleSchema } from '$generated/schemas'; import { type infer as Infer, string } from 'zod'; +const MAXIMUM_COOLDOWN_SECONDS = 24 * 60 * 60; +const SUPPORTED_WINDOWS = new Set(WINDOW_OPTIONS.map((option) => option.value)); + function durationSeconds(value: string): number { - const [hours = '0', minutes = '0', seconds = '0'] = value.split(':'); - return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds); + const daySeparator = value.indexOf('.'); + const days = daySeparator >= 0 ? Number(value.slice(0, daySeparator)) : 0; + const time = daySeparator >= 0 ? value.slice(daySeparator + 1) : value; + const [hours = '0', minutes = '0', seconds = '0'] = time.split(':'); + return days * 86400 + Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds); } export const RateNotificationRuleSchema = NewRateNotificationRuleSchema.extend({ + name: string().trim().min(1, 'Enter a name.').max(100), stack_id: string().optional() }).superRefine((value, context) => { if (value.subject === RateNotificationSubject.Stack && !value.stack_id) { context.addIssue({ code: 'custom', message: 'Select a stack.', path: ['stack_id'] }); } + if (!SUPPORTED_WINDOWS.has(value.window)) { + context.addIssue({ code: 'custom', message: 'Select a supported window.', path: ['window'] }); + } + if (durationSeconds(value.cooldown) < durationSeconds(value.window)) { context.addIssue({ code: 'custom', message: 'Cooldown must be at least as long as the window.', path: ['cooldown'] }); } + + if (durationSeconds(value.cooldown) > MAXIMUM_COOLDOWN_SECONDS) { + context.addIssue({ code: 'custom', message: 'Cooldown must not exceed 24 hours.', path: ['cooldown'] }); + } }); export type RateNotificationRuleFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts index 2ab3370c2b..08ef8a92ea 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts @@ -32,5 +32,5 @@ export const COOLDOWN_OPTIONS = [ { label: '2 hours', value: '02:00:00' }, { label: '4 hours', value: '04:00:00' }, { label: '8 hours', value: '08:00:00' }, - { label: '24 hours', value: '24:00:00' } + { label: '24 hours', value: '1.00:00:00' } ] as const; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 4ab15d7446..19d193d31e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -755,6 +755,7 @@ export interface ViewProject { /** @format int64 */ event_count: number; has_premium_features: boolean; + has_rate_notifications: boolean; has_slack_integration: boolean; usage_hours: UsageHourInfo[]; usage: UsageInfo[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 2bcabed1d7..398b0fb57e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -846,6 +846,7 @@ export const ViewProjectSchema = object({ stack_count: int(), event_count: int(), has_premium_features: boolean(), + has_rate_notifications: boolean(), has_slack_integration: boolean(), usage_hours: array(lazy(() => UsageHourInfoSchema)), usage: array(lazy(() => UsageInfoSchema)), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte index 429a1f066b..ad7ea7257d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/notifications/+page.svelte @@ -9,7 +9,6 @@ import { Skeleton } from '$comp/ui/skeleton'; import { Switch } from '$comp/ui/switch'; import { showUpgradeDialog } from '$features/billing/upgrade-required.svelte'; - import { getOrganizationQuery } from '$features/organizations/api.svelte'; import { getProjectsQuery, getProjectUserNotificationSettings, postProjectUserNotificationSettings } from '$features/projects/api.svelte'; import UserNotificationSettingsForm from '$features/projects/components/user-notification-settings-form.svelte'; import RateNotificationRuleForm from '$features/rate-notifications/components/rate-notification-rule-form.svelte'; @@ -58,14 +57,7 @@ }); const selectedProject = $derived(allProjects.find((p) => p.id === queryParams.project) ?? allProjects[0]); - const selectedOrganizationQuery = getOrganizationQuery({ - route: { - get id() { - return selectedProject?.organization_id; - } - } - }); - const rateNotificationsEnabled = $derived(selectedOrganizationQuery.data?.features.includes('rate-notifications') ?? false); + const rateNotificationsEnabled = $derived(selectedProject?.has_rate_notifications ?? false); const selectedProjectNotificationSettings = getProjectUserNotificationSettings({ route: { get id() { @@ -161,6 +153,12 @@ rateRuleDialogOpen = false; editingRateRule = undefined; } + + $effect(() => { + if (rateRuleDialogOpen && editingRateRule && editingRateRule.project_id !== selectedProject?.id) { + closeRateRuleDialog(); + } + });
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 9c57ade96f..e3425a1185 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -645,6 +645,7 @@ const client = useFetchClient(); const clientStatus = useFetchClientStatus(client); let clientResponse = $state[]>>(); + let loadRequestId = 0; const table = createTable( getSharedTableOptions>({ @@ -689,6 +690,7 @@ } async function loadData() { + const requestId = ++loadRequestId; if (!organization.current || isSavedViewRoutePending) { return; } @@ -698,7 +700,12 @@ include: !eventsQueryParameters.after && !eventsQueryParameters.before ? 'total' : undefined }; delete params.page; - clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { params }); + const response = await client.getJSON[]>(`organizations/${organization.current}/events`, { params }); + if (requestId !== loadRequestId) { + return; + } + + clientResponse = response; if (clientResponse.problem) { showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData()); diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs index de1e2db24e..8f63ab8355 100644 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ b/src/Exceptionless.Web/Controllers/ProjectController.cs @@ -33,7 +33,7 @@ public class ProjectController : RepositoryApiController _workItemQueue; private readonly BillingManager _billingManager; private readonly SlackService _slackService; @@ -49,7 +49,7 @@ public ProjectController( IStackRepository stackRepository, IEventRepository eventRepository, ITokenRepository tokenRepository, - IRateNotificationRuleRepository rateNotificationRuleRepository, + OrganizationService organizationService, IQueue workItemQueue, BillingManager billingManager, SlackService slackService, @@ -69,7 +69,7 @@ ILoggerFactory loggerFactory _stackRepository = stackRepository; _eventRepository = eventRepository; _tokenRepository = tokenRepository; - _rateNotificationRuleRepository = rateNotificationRuleRepository; + _organizationService = organizationService; _workItemQueue = workItemQueue; _billingManager = billingManager; _slackService = slackService; @@ -225,7 +225,7 @@ protected override async Task> DeleteModelsAsync(ICollection _logger.UserDeletingProject(user.Id, project.Name); await _tokenRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); - await _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); + await _organizationService.RemoveProjectRateNotificationRulesAsync(project.OrganizationId, project.Id); } return await base.DeleteModelsAsync(projects); @@ -760,6 +760,7 @@ await _workItemQueue.EnqueueAsync(new SetProjectIsConfiguredWorkItem viewProject.OrganizationName = organization.Name; viewProject.HasPremiumFeatures = organization.HasPremiumFeatures; + viewProject.HasRateNotifications = organization.HasRateNotifications(); var realTimeUsage = await _usageService.GetUsageAsync(organization.Id, viewProject.Id); viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), _timeProvider); diff --git a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs index f8dfc196e9..dd35d28d52 100644 --- a/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs +++ b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs @@ -38,7 +38,6 @@ public class RateNotificationRuleController : ExceptionlessApiController private readonly IOrganizationRepository _organizationRepository; private readonly IUserRepository _userRepository; private readonly IStackRepository _stackRepository; - private readonly RateNotificationRuleCache _ruleCache; private readonly ILockProvider _lockProvider; public RateNotificationRuleController( @@ -47,7 +46,6 @@ public RateNotificationRuleController( IOrganizationRepository organizationRepository, IUserRepository userRepository, IStackRepository stackRepository, - RateNotificationRuleCache ruleCache, ILockProvider lockProvider, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(timeProvider) @@ -57,7 +55,6 @@ public RateNotificationRuleController( _organizationRepository = organizationRepository; _userRepository = userRepository; _stackRepository = stackRepository; - _ruleCache = ruleCache; _lockProvider = lockProvider; } @@ -100,6 +97,15 @@ public async Task> PostAsync( if (project is null) return NotFound(); + if (String.IsNullOrWhiteSpace(model.Name)) + return ValidationProblem(detail: "Name is required."); + + if (!Enum.IsDefined(model.Signal)) + return ValidationProblem(detail: "Signal is invalid."); + + if (!Enum.IsDefined(model.Subject)) + return ValidationProblem(detail: "Subject is invalid."); + // Validate window if (!ValidWindows.Contains(model.Window)) return ValidationProblem(detail: $"Window must be one of: {String.Join(", ", ValidWindows.Select(w => w.ToString()))}"); @@ -108,6 +114,9 @@ public async Task> PostAsync( if (model.Cooldown < model.Window) return ValidationProblem(detail: "Cooldown must be greater than or equal to Window."); + if (model.Cooldown > RateNotificationRule.MaximumCooldown) + return ValidationProblem(detail: "Cooldown must not exceed 24 hours."); + // Validate subject / stackId if (model.Subject == RateNotificationSubject.Stack) { @@ -115,7 +124,9 @@ public async Task> PostAsync( return ValidationProblem(detail: "StackId is required when Subject is Stack."); var stack = await _stackRepository.GetByIdAsync(model.StackId, o => o.Cache()); - if (stack is null || !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal)) + if (stack is null || + !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal) || + !String.Equals(stack.OrganizationId, project.OrganizationId, StringComparison.Ordinal)) return ValidationProblem(detail: "The specified StackId does not belong to this project."); } else if (!String.IsNullOrEmpty(model.StackId)) @@ -143,7 +154,7 @@ public async Task> PostAsync( OrganizationId = project.OrganizationId, ProjectId = projectId, UserId = userId, - Name = model.Name, + Name = model.Name.Trim(), IsEnabled = isEnabled, Signal = model.Signal, Subject = model.Subject, @@ -157,7 +168,6 @@ public async Task> PostAsync( }; rule = await _ruleRepository.AddAsync(rule, o => o.Cache().ImmediateConsistency()); - await _ruleCache.InvalidateAsync(projectId); return Created(new Uri(Url.Link("GetRateNotificationRuleById", new { userId, projectId, ruleId = rule.Id })!, UriKind.RelativeOrAbsolute), MapToView(rule)); } @@ -200,9 +210,18 @@ public async Task> PutAsync( if (rule is null) return NotFound(); + if (model.Name is not null && String.IsNullOrWhiteSpace(model.Name)) + return ValidationProblem(detail: "Name must not be empty."); + + if (model.Signal.HasValue && !Enum.IsDefined(model.Signal.Value)) + return ValidationProblem(detail: "Signal is invalid."); + + if (model.Subject.HasValue && !Enum.IsDefined(model.Subject.Value)) + return ValidationProblem(detail: "Subject is invalid."); + // Apply updates if (model.Name is not null) - rule.Name = model.Name; + rule.Name = model.Name.Trim(); if (model.Signal.HasValue) rule.Signal = model.Signal.Value; @@ -223,7 +242,9 @@ public async Task> PutAsync( return ValidationProblem(detail: "StackId is required when Subject is Stack."); var stack = await _stackRepository.GetByIdAsync(newStackId, o => o.Cache()); - if (stack is null || !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal)) + if (stack is null || + !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal) || + !String.Equals(stack.OrganizationId, project.OrganizationId, StringComparison.Ordinal)) return ValidationProblem(detail: "The specified StackId does not belong to this project."); rule.StackId = newStackId; @@ -246,6 +267,9 @@ public async Task> PutAsync( if (rule.Cooldown < rule.Window) return ValidationProblem(detail: "Cooldown must be greater than or equal to Window."); + if (rule.Cooldown > RateNotificationRule.MaximumCooldown) + return ValidationProblem(detail: "Cooldown must not exceed 24 hours."); + if (model.IsEnabled.HasValue) { var organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); @@ -255,8 +279,7 @@ public async Task> PutAsync( rule.Version++; rule.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; - await _ruleRepository.SaveAsync(rule, o => o.Cache()); - await _ruleCache.InvalidateAsync(projectId); + await _ruleRepository.SaveAsync(rule, o => o.Cache().ImmediateConsistency()); return Ok(MapToView(rule)); } @@ -276,8 +299,7 @@ public async Task DeleteAsync(string userId, string projectId, st if (rule is null) return NotFound(); - await _ruleRepository.RemoveAsync(rule); - await _ruleCache.InvalidateAsync(projectId); + await _ruleRepository.RemoveAsync(rule, o => o.ImmediateConsistency()); return NoContent(); } @@ -315,8 +337,7 @@ public async Task> SnoozeAsync( rule.Version++; rule.UpdatedUtc = now; - await _ruleRepository.SaveAsync(rule, o => o.Cache()); - await _ruleCache.InvalidateAsync(projectId); + await _ruleRepository.SaveAsync(rule, o => o.Cache().ImmediateConsistency()); return Ok(MapToView(rule)); } @@ -341,8 +362,7 @@ public async Task> UnsnoozeAsync(string u rule.SnoozedUntilUtc = now; rule.Version++; rule.UpdatedUtc = now; - await _ruleRepository.SaveAsync(rule, o => o.Cache()); - await _ruleCache.InvalidateAsync(projectId); + await _ruleRepository.SaveAsync(rule, o => o.Cache().ImmediateConsistency()); return Ok(MapToView(rule)); } diff --git a/src/Exceptionless.Web/Mapping/ProjectMapper.cs b/src/Exceptionless.Web/Mapping/ProjectMapper.cs index 6d69ef583d..ee6fc3959f 100644 --- a/src/Exceptionless.Web/Mapping/ProjectMapper.cs +++ b/src/Exceptionless.Web/Mapping/ProjectMapper.cs @@ -15,6 +15,7 @@ public partial class ProjectMapper [MapperIgnoreTarget(nameof(ViewProject.HasSlackIntegration))] [MapperIgnoreTarget(nameof(ViewProject.HasPremiumFeatures))] + [MapperIgnoreTarget(nameof(ViewProject.HasRateNotifications))] [MapperIgnoreTarget(nameof(ViewProject.OrganizationName))] [MapperIgnoreTarget(nameof(ViewProject.StackCount))] [MapperIgnoreTarget(nameof(ViewProject.EventCount))] diff --git a/src/Exceptionless.Web/Models/Project/ViewProject.cs b/src/Exceptionless.Web/Models/Project/ViewProject.cs index 52f5576e9a..bee7180f72 100644 --- a/src/Exceptionless.Web/Models/Project/ViewProject.cs +++ b/src/Exceptionless.Web/Models/Project/ViewProject.cs @@ -22,6 +22,7 @@ public class ViewProject : IIdentity, IData, IHaveCreatedDate public long StackCount { get; set; } public long EventCount { get; set; } public bool HasPremiumFeatures { get; set; } + public bool HasRateNotifications { get; set; } public bool HasSlackIntegration { get; set; } public ICollection UsageHours { get; set; } = new SortedSet(Comparer.Create((a, b) => a.Date.CompareTo(b.Date))); public ICollection Usage { get; set; } = new SortedSet(Comparer.Create((a, b) => a.Date.CompareTo(b.Date))); diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index 6ba9596517..a4008d6877 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -14,8 +14,11 @@ public class AppWebHostFactory : WebApplicationFactory, IAsyncLifetime { private const string SharedElasticsearchUrl = "http://localhost:9200"; private static int s_counter = -1; + private static readonly string s_processScope = $"testprocess-{Environment.ProcessId}"; private static readonly Lazy> s_sharedAppHost = new(StartSharedAppHostAsync, LazyThreadSafetyMode.ExecutionAndPublication); private static readonly ConcurrentQueue s_pool = new(); + private static readonly ConcurrentDictionary s_dataResetLocks = new(); + private static readonly ConcurrentDictionary s_configuredIndexes = new(); private bool _sliceReleased; public AppWebHostFactory() @@ -24,12 +27,23 @@ public AppWebHostFactory() instanceId = Interlocked.Increment(ref s_counter); InstanceId = instanceId; - AppScope = instanceId == 0 ? "test" : $"test-{instanceId}"; + AppScope = instanceId == 0 ? s_processScope : $"{s_processScope}-{instanceId}"; } public string AppScope { get; } public int InstanceId { get; } - public bool IndexesHaveBeenConfigured { get; set; } + public SemaphoreSlim DataResetLock => s_dataResetLocks.GetOrAdd(AppScope, _ => new SemaphoreSlim(1, 1)); + public bool IndexesHaveBeenConfigured + { + get => s_configuredIndexes.ContainsKey(AppScope); + set + { + if (value) + s_configuredIndexes.TryAdd(AppScope, 0); + else + s_configuredIndexes.TryRemove(AppScope, out _); + } + } public async ValueTask InitializeAsync() { @@ -94,14 +108,14 @@ protected override IHostBuilder CreateHostBuilder() return Web.Program.CreateHostBuilder(config, Environments.Development); } - public override ValueTask DisposeAsync() + public override async ValueTask DisposeAsync() { + await base.DisposeAsync(); + if (!_sliceReleased) { s_pool.Enqueue(InstanceId); _sliceReleased = true; } - - return base.DisposeAsync(); } } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 910bc012bc..7cc2ae248b 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -11354,6 +11354,7 @@ "stack_count", "event_count", "has_premium_features", + "has_rate_notifications", "has_slack_integration", "usage_hours", "usage" @@ -11415,6 +11416,9 @@ "has_premium_features": { "type": "boolean" }, + "has_rate_notifications": { + "type": "boolean" + }, "has_slack_integration": { "type": "boolean" }, diff --git a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs index d8843620cd..cfb32bd35d 100644 --- a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs @@ -22,14 +22,18 @@ public sealed class ProjectControllerTests : IntegrationTestsBase private readonly IEventRepository _eventRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IRateNotificationRuleRepository _rateNotificationRuleRepository; private readonly IStackRepository _stackRepository; + private readonly IUserRepository _userRepository; public ProjectControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _eventRepository = GetService(); _organizationRepository = GetService(); _projectRepository = GetService(); + _rateNotificationRuleRepository = GetService(); _stackRepository = GetService(); + _userRepository = GetService(); } protected override async Task ResetDataAsync() @@ -108,6 +112,25 @@ public async Task DeleteAsync_ExistingProject_RemovesProject() .StatusCodeShouldBeCreated() ); Assert.NotNull(project); + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + var now = TimeProvider.GetUtcNow().UtcDateTime; + var rule = await _rateNotificationRuleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = project.OrganizationId, + ProjectId = project.Id, + UserId = user.Id, + Name = "Delete with project", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + Version = 1, + CreatedUtc = now, + UpdatedUtc = now + }, o => o.ImmediateConsistency()); // Act var workItems = await SendRequestAsAsync(r => r @@ -126,6 +149,7 @@ public async Task DeleteAsync_ExistingProject_RemovesProject() // Assert var deleted = await _projectRepository.GetByIdAsync(project.Id); Assert.Null(deleted); + Assert.Null(await _rateNotificationRuleRepository.GetByIdAsync(rule.Id)); } [Fact] @@ -285,6 +309,27 @@ public async Task GetAsync_ExistingProject_MapsToViewProjectWithSlackIntegration Assert.NotNull(viewProject); Assert.Equal(SampleDataService.TEST_PROJECT_ID, viewProject.Id); Assert.False(viewProject.HasSlackIntegration); + Assert.False(viewProject.HasRateNotifications); + } + + [Fact] + public async Task GetAsync_RateNotificationsEnabled_ExposesProjectCapability() + { + // Arrange + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.Features.Add(OrganizationExtensions.RateNotificationsFeature); + await _organizationRepository.SaveAsync(organization, o => o.Cache()); + + // Act + var viewProject = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID) + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(viewProject); + Assert.True(viewProject.HasRateNotifications); } [Fact] diff --git a/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs index 45f45c13e0..846103903e 100644 --- a/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; @@ -207,6 +208,66 @@ await SendRequestAsync(r => r ); } + [Fact] + public async Task PostAsync_CooldownExceedsMaximum_Returns422() + { + // Arrange + var user = await GetTestOrganizationUserAsync(); + + // Act / Assert + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Excessive cooldown", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromHours(25) + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PostAsync_UndefinedSignal_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new + { + name = "Undefined signal", + signal = 999, + subject = RateNotificationSubject.Project, + threshold = 10, + window = TimeSpan.FromMinutes(5), + cooldown = TimeSpan.FromMinutes(30), + is_enabled = true + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task PutAsync_EmptyName_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + var rule = await CreateProjectRuleAsync(user.Id, "Valid name"); + + await SendRequestAsync(r => r + .Put() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, rule.Id)) + .Content(new UpdateRateNotificationRule { Name = " " }) + .StatusCodeShouldBeUnprocessableEntity()); + } + [Fact] public async Task PostAsync_StackSubjectWithoutStackId_Returns422() { @@ -233,6 +294,57 @@ await SendRequestAsync(r => r ); } + [Fact] + public async Task PostAsync_UndefinedSubject_Returns422() + { + // Arrange + var user = await GetTestOrganizationUserAsync(); + + // Act / Assert + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Invalid subject", + Signal = RateNotificationSignal.Errors, + Subject = (RateNotificationSubject)Int32.MaxValue, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PostAsync_StackFromAnotherProject_Returns422() + { + var user = await GetTestOrganizationUserAsync(); + var (stacks, _) = await CreateDataAsync(b => b.Event() + .Organization(SampleDataService.TEST_ORG_ID) + .Project(SampleDataService.TEST_ROCKET_SHIP_PROJECT_ID) + .Type(Event.KnownTypes.Error)); + var stack = Assert.Single(stacks); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Foreign stack", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Stack, + StackId = stack.Id, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + [Fact] public async Task GetByIdAsync_ExistingRule_ReturnsRule() { @@ -395,8 +507,8 @@ public async Task PostAsync_ExceedsMaxRulesPerUser_Returns422() { var user = await GetTestOrganizationUserAsync(); - // Create 20 rules (the maximum) - for (int i = 0; i < 20; i++) + // Create 19 rules, then race the final two requests against the limit. + for (int i = 0; i < 19; i++) { await SendRequestAsync(r => r .Post() @@ -415,22 +527,25 @@ await SendRequestAsync(r => r ); } - // 21st rule should fail - await SendRequestAsync(r => r + Task CreateFinalRuleAsync(string name) => SendRequestAsync(r => r .Post() .AsTestOrganizationUser() .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) .Content(new NewRateNotificationRule { - Name = "Rule 21", + Name = name, Signal = RateNotificationSignal.Errors, Subject = RateNotificationSubject.Project, Threshold = 5, Window = TimeSpan.FromMinutes(5), Cooldown = TimeSpan.FromMinutes(30) - }) - .StatusCodeShouldBeUnprocessableEntity() - ); + })); + + var responses = await Task.WhenAll(CreateFinalRuleAsync("Rule 20"), CreateFinalRuleAsync("Rule 21")); + + Assert.Contains(responses, response => response.StatusCode == HttpStatusCode.Created); + Assert.Contains(responses, response => response.StatusCode == HttpStatusCode.UnprocessableEntity); + Assert.Equal(20, await _ruleRepository.CountByProjectIdAndUserIdAsync(SampleDataService.TEST_PROJECT_ID, user.Id)); } [Fact] diff --git a/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs b/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs index b502952b03..dee0344ce7 100644 --- a/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs +++ b/tests/Exceptionless.Tests/Extensions/RequestExtensions.cs @@ -64,8 +64,9 @@ public static AppSendBuilder StatusCodeShouldBeUpgradeRequired(this AppSendBuild { ArgumentNullException.ThrowIfNull(requestMessage); - requestMessage.Options.TryGetValue(AppSendBuilder.ExpectedStatusKey, out var propertyValue); - return propertyValue; + return requestMessage.Options.TryGetValue(AppSendBuilder.ExpectedStatusKey, out var propertyValue) + ? propertyValue + : null; } public static void SetExpectedStatus(this HttpRequestMessage requestMessage, HttpStatusCode statusCode) diff --git a/tests/Exceptionless.Tests/IntegrationTestsBase.cs b/tests/Exceptionless.Tests/IntegrationTestsBase.cs index 118292c4f4..247a2e0cd7 100644 --- a/tests/Exceptionless.Tests/IntegrationTestsBase.cs +++ b/tests/Exceptionless.Tests/IntegrationTestsBase.cs @@ -149,6 +149,7 @@ protected virtual void RegisterServices(IServiceCollection services) protected virtual async Task ResetDataAsync() { + await _factory.DataResetLock.WaitAsync(TestContext.Current.CancellationToken); var oldLoggingLevel = Log.DefaultLogLevel; Log.DefaultLogLevel = LogLevel.Warning; @@ -188,11 +189,13 @@ await _configuration.Client.DeleteByQueryAsync(new DeleteByQueryRequest(indexes) await GetService>().DeleteQueueAsync(); await GetService>().DeleteQueueAsync(); await GetService>().DeleteQueueAsync(); + await GetService>().DeleteQueueAsync(); await GetService>().DeleteQueueAsync(); await GetService>().DeleteQueueAsync(); } finally { + _factory.DataResetLock.Release(); Log.DefaultLogLevel = oldLoggingLevel; _logger.LogDebug("Reset data for {AppScope}", _factory.AppScope); } diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs index 21c0e5f050..76aff6b0b6 100644 --- a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs @@ -117,6 +117,30 @@ public async Task RunAsync_WhenBelowThreshold_DoesNotEnqueue() Assert.Equal(queueBefore, queueAfter); } + [Fact] + public async Task RunAsync_NonPremiumOrganization_DoesNotEnqueue() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-2)); + var rule = await _ruleRepository.AddAsync(BuildRule(threshold: 1), o => o.ImmediateConsistency()); + await _counterService.IncrementAsync(BuildCounterKey(rule), ct); + var organization = await _orgRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.HasPremiumFeatures = false; + await _orgRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); + TimeProvider.Advance(TimeSpan.FromMinutes(2)); + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + // Act + await _job.RunAsync(ct); + + // Assert + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + Assert.Equal(queueBefore, queueAfter); + } + [Fact] public async Task RunAsync_WhenEventsAreInCurrentMinute_DoesNotEvaluatePartialBucket() { @@ -136,6 +160,23 @@ public async Task RunAsync_WhenEventsAreInCurrentMinute_DoesNotEvaluatePartialBu Assert.Equal(queueBefore, queueAfter); } + [Fact] + public async Task RunAsync_AfterSuccessfulRecovery_AdvancesCheckpointToLastCompleteMinute() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var now = new DateTime(2024, 6, 1, 12, 0, 30, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now); + await _counterService.SetLastEvaluatedMinuteAsync(now.AddMinutes(-4), ct); + + // Act + var result = await _job.RunAsync(ct); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(new DateTime(2024, 6, 1, 11, 59, 0, DateTimeKind.Utc), await _counterService.GetLastEvaluatedMinuteAsync(ct)); + } + [Fact] public async Task RunAsync_WhenOnCooldown_DoesNotEnqueueAgain() { @@ -244,4 +285,35 @@ public async Task RunAsync_SnoozeBackAlert_SnoozedRuleIgnoresTrafficDuringSnooze // So exactly 1 notification should be enqueued. Assert.Equal(1, newNotifications); } + + [Fact] + public async Task RunAsync_MatchingRuleOnSecondPage_EnqueuesNotification() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var now = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(now.AddMinutes(-2)); + + var rules = Enumerable.Range(0, 501).Select(index => + { + var rule = BuildRule( + signal: index == 500 ? RateNotificationSignal.Errors : RateNotificationSignal.AllEvents, + threshold: index == 500 ? 1 : Int32.MaxValue); + rule.Id = index.ToString("x24"); + rule.Name = $"Paged rule {index}"; + return rule; + }).ToList(); + await _ruleRepository.AddAsync(rules, o => o.ImmediateConsistency()); + + await _counterService.IncrementAsync($"project:{SampleDataService.TEST_PROJECT_ID}:signal:{RateNotificationSignal.Errors}", ct); + TimeProvider.Advance(TimeSpan.FromMinutes(2)); + long queueBefore = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + + // Act + await _job.RunAsync(ct); + + // Assert + long queueAfter = (await _notificationQueue.GetQueueStatsAsync()).Enqueued; + Assert.Equal(1, queueAfter - queueBefore); + } } diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs index 576e41325a..2633ee5284 100644 --- a/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs @@ -8,6 +8,7 @@ using Exceptionless.Tests.Mail; using Foundatio.Queues; using Foundatio.Repositories; +using Foundatio.Repositories.Utility; using Xunit; namespace Exceptionless.Tests.Jobs; @@ -105,6 +106,22 @@ public async Task RunAsync_PayloadDoesNotMatchRule_SkipsNotification() Assert.Empty(_mailer.RateNotifications); } + [Fact] + public async Task RunAsync_MalformedRuleId_SkipsNotificationWithoutRepositoryFailure() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + notification.RuleId = "invalid"; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + [Fact] public async Task RunAsync_SubjectKeyDoesNotMatchRule_SkipsNotification() { @@ -121,6 +138,171 @@ public async Task RunAsync_SubjectKeyDoesNotMatchRule_SkipsNotification() Assert.Empty(_mailer.RateNotifications); } + [Fact] + public async Task RunAsync_DisabledRule_SkipsNotification() + { + // Arrange + var (rule, notification) = await CreateRuleAndNotificationAsync(); + rule.IsEnabled = false; + await _ruleRepository.SaveAsync(rule, o => o.ImmediateConsistency()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_UnverifiedUser_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.IsEmailAddressVerified = false; + user.VerifyEmailAddressToken = "verify-token"; + user.VerifyEmailAddressTokenExpiration = TimeProvider.GetUtcNow().UtcDateTime.AddDays(1); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_EmailNotificationsDisabled_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.EmailNotificationsEnabled = false; + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_UserOutsideOrganization_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.OrganizationIds.Remove(SampleDataService.TEST_ORG_ID); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_StackRule_LoadsStackContext() + { + // Arrange + var (stacks, _) = await CreateDataAsync(builder => builder.Event().TestProject().Type(Event.KnownTypes.Error)); + var stack = Assert.Single(stacks); + var (rule, notification) = await CreateRuleAndNotificationAsync(); + rule.Subject = RateNotificationSubject.Stack; + rule.StackId = stack.Id; + await _ruleRepository.SaveAsync(rule, o => o.ImmediateConsistency()); + notification.StackId = stack.Id; + notification.SubjectKey = $"stack:{stack.Id}"; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + var call = Assert.Single(_mailer.RateNotifications); + Assert.Equal(stack.Id, call.StackId); + } + + [Fact] + public async Task RunAsync_ObservedCountBelowThreshold_SkipsTamperedNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + notification.ObservedCount = notification.Threshold - 1; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_InvalidPersistedSubject_SkipsNotification() + { + // Arrange + var (rule, notification) = await CreateRuleAndNotificationAsync(); + rule.StackId = ObjectId.GenerateNewId().ToString(); + await _ruleRepository.SaveAsync(rule, o => o.ImmediateConsistency()); + notification.StackId = rule.StackId; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_StaleRuleVersion_SkipsNotification() + { + // Arrange + var (_, notification) = await CreateRuleAndNotificationAsync(); + notification.RuleVersion++; + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + + [Fact] + public async Task RunAsync_DeletedRule_SkipsNotification() + { + // Arrange + var (rule, notification) = await CreateRuleAndNotificationAsync(); + await _ruleRepository.RemoveAsync(rule, o => o.ImmediateConsistency()); + await _queue.EnqueueAsync(notification); + + // Act + var result = await _job.RunAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(_mailer.RateNotifications); + } + [Fact] public async Task EnqueueAsync_DuplicateEvaluation_EnqueuesOnce() { diff --git a/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs index f6fc3c4322..6c6488615e 100644 --- a/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs @@ -1,5 +1,9 @@ +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Services; using Xunit; namespace Exceptionless.Tests.Pipeline; @@ -23,7 +27,8 @@ public void GetCounterKeys_DuplicateRules_ReturnsOneCounterKey() }; // Act - var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, rules); + var plan = RateNotificationCounterPlan.Compile(ev.ProjectId, rules); + var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, plan); // Assert Assert.Equal(["project:project-1:signal:Errors"], keys); @@ -47,7 +52,8 @@ public void GetCounterKeys_ProjectAndMatchingStackRules_ReturnsDistinctScopeKeys }; // Act - var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, rules); + var plan = RateNotificationCounterPlan.Compile(ev.ProjectId, rules); + var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, plan); // Assert Assert.Equal(2, keys.Count); @@ -55,7 +61,128 @@ public void GetCounterKeys_ProjectAndMatchingStackRules_ReturnsDistinctScopeKeys Assert.Contains("project:project-1:stack:stack-1:signal:Errors", keys); } - private static RateNotificationRule CreateRule(string id, RateNotificationSubject subject, string? stackId = null) + [Fact] + public void Compile_ManyRulesForSameScope_ProducesOneHotPathCounter() + { + // Arrange + var rules = Enumerable.Range(0, 1000) + .Select(index => CreateRule($"rule-{index}", RateNotificationSubject.Project)); + + // Act + var plan = RateNotificationCounterPlan.Compile("project-1", rules); + + // Assert + Assert.Equal(1000, plan.RuleCount); + Assert.Single(plan.ProjectCounters); + Assert.Empty(plan.StackCounters); + } + + [Fact] + public void GetCounterKeys_AllSignalsAndScopes_HasFixedTenIncrementMaximum() + { + // Arrange + var rules = Enum.GetValues() + .SelectMany(signal => Enumerable.Range(0, 100).SelectMany(index => new[] + { + CreateRule($"project-{signal}-{index}", RateNotificationSubject.Project, signal: signal), + CreateRule($"stack-{signal}-{index}", RateNotificationSubject.Stack, "stack-1", signal) + })); + var plan = RateNotificationCounterPlan.Compile("project-1", rules); + var ev = new PersistentEvent + { + ProjectId = "project-1", + StackId = "stack-1", + Type = Event.KnownTypes.Error, + Tags = [Event.KnownTags.Critical] + }; + + // Act + var keys = UpdateRateCountersAction.GetCounterKeys(ev, true, true, plan); + + // Assert + Assert.Equal(10, keys.Count); + Assert.Equal(10, keys.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void Compile_InvalidDefinitions_AreExcludedFromHotPathPlan() + { + // Arrange + var disabled = CreateRule("disabled", RateNotificationSubject.Project); + disabled.IsEnabled = false; + var deleted = CreateRule("deleted", RateNotificationSubject.Project); + deleted.IsDeleted = true; + var wrongProject = CreateRule("wrong-project", RateNotificationSubject.Project); + wrongProject.ProjectId = "project-2"; + var undefinedSignal = CreateRule("undefined-signal", RateNotificationSubject.Project); + undefinedSignal.Signal = (RateNotificationSignal)Int32.MaxValue; + var stackWithoutId = CreateRule("stack-without-id", RateNotificationSubject.Stack); + var projectWithStack = CreateRule("project-with-stack", RateNotificationSubject.Project, "stack-1"); + + // Act + var plan = RateNotificationCounterPlan.Compile("project-1", [disabled, deleted, wrongProject, undefinedSignal, stackWithoutId, projectWithStack]); + + // Assert + Assert.Equal(0, plan.RuleCount); + Assert.False(plan.HasCounters); + } + + [Fact] + public void ShouldIncrement_EnabledPremiumContext_ReturnsTrue() + { + var context = CreateContext(); + + Assert.True(UpdateRateCountersAction.ShouldIncrement(context)); + } + + [Fact] + public void ShouldIncrement_MissingPremiumOrFeature_ReturnsFalse() + { + var context = CreateContext(); + context.Organization.HasPremiumFeatures = false; + Assert.False(UpdateRateCountersAction.ShouldIncrement(context)); + + context.Organization.HasPremiumFeatures = true; + context.Organization.Features.Clear(); + Assert.False(UpdateRateCountersAction.ShouldIncrement(context)); + } + + [Fact] + public void ShouldIncrement_SuppressedStackOrContext_ReturnsFalse() + { + var context = CreateContext(); + context.Stack!.Status = StackStatus.Ignored; + Assert.False(UpdateRateCountersAction.ShouldIncrement(context)); + + context = CreateContext(); + context.IsCancelled = true; + Assert.False(UpdateRateCountersAction.ShouldIncrement(context)); + + context = CreateContext(); + context.IsDiscarded = true; + Assert.False(UpdateRateCountersAction.ShouldIncrement(context)); + } + + [Fact] + public void ShouldIncrement_BotMarkedRequest_ReturnsFalse() + { + var context = CreateContext(); + context.Event.Data = new DataDictionary + { + [Event.KnownDataKeys.RequestInfo] = new RequestInfo + { + Data = new DataDictionary { [RequestInfo.KnownDataKeys.IsBot] = true } + } + }; + + Assert.False(UpdateRateCountersAction.ShouldIncrement(context)); + } + + private static RateNotificationRule CreateRule( + string id, + RateNotificationSubject subject, + string? stackId = null, + RateNotificationSignal signal = RateNotificationSignal.Errors) { return new RateNotificationRule { @@ -65,7 +192,7 @@ private static RateNotificationRule CreateRule(string id, RateNotificationSubjec UserId = "user-1", Name = id, IsEnabled = true, - Signal = RateNotificationSignal.Errors, + Signal = signal, Subject = subject, StackId = stackId, Threshold = 1, @@ -73,4 +200,27 @@ private static RateNotificationRule CreateRule(string id, RateNotificationSubjec Cooldown = TimeSpan.FromMinutes(5) }; } + + private static EventContext CreateContext() + { + var organization = new Organization + { + Id = "organization-1", + HasPremiumFeatures = true, + Features = new HashSet { OrganizationExtensions.RateNotificationsFeature } + }; + var project = new Project { Id = "project-1", OrganizationId = organization.Id }; + var context = new EventContext(new PersistentEvent { Type = Event.KnownTypes.Error }, organization, project) + { + Stack = new Stack + { + Id = "stack-1", + OrganizationId = organization.Id, + ProjectId = project.Id, + Status = StackStatus.Open + } + }; + context.Event.StackId = context.Stack.Id; + return context; + } } diff --git a/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs index 404b84bce5..1f268ffd07 100644 --- a/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs @@ -144,6 +144,28 @@ public async Task IncrementAsync_MultipleIncrements_AccumulatesCount() Assert.Equal(7, count); } + [Fact] + public async Task IncrementAsync_MultipleCounterKeys_IncrementsEachAndTracksOneDistinctActiveEntry() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + var now = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc); + timeProvider.SetUtcNow(now); + string stackCounterKey = "project:P1:stack:S1:signal:Errors"; + + // Act + await service.IncrementAsync([CounterKey, stackCounterKey, CounterKey], ct); + + // Assert + Assert.Equal(1, await service.SumBucketsAsync(CounterKey, now.AddMinutes(-1), now.AddMinutes(1), ct)); + Assert.Equal(1, await service.SumBucketsAsync(stackCounterKey, now.AddMinutes(-1), now.AddMinutes(1), ct)); + var activeKeys = await service.GetActiveCounterKeysAsync(now, ct); + Assert.Equal(2, activeKeys.Count); + Assert.Contains(CounterKey, activeKeys); + Assert.Contains(stackCounterKey, activeKeys); + } + [Fact] public async Task SumBucketsAsync_AcrossMultipleMinutes_SumsAllBuckets() { diff --git a/tests/Exceptionless.Tests/Services/RateNotificationRuleCacheTests.cs b/tests/Exceptionless.Tests/Services/RateNotificationRuleCacheTests.cs new file mode 100644 index 0000000000..34d649b561 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/RateNotificationRuleCacheTests.cs @@ -0,0 +1,174 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Tests.Utility; +using Foundatio.Repositories; +using Foundatio.Repositories.Utility; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class RateNotificationRuleCacheTests : IntegrationTestsBase +{ + private readonly RateNotificationRuleCache _cache; + private readonly IRateNotificationRuleRepository _repository; + private readonly OrganizationService _organizationService; + + public RateNotificationRuleCacheTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _cache = GetService(); + _repository = GetService(); + _organizationService = GetService(); + } + + [Fact] + public async Task GetCounterPlanAsync_MoreThanOnePage_LoadsEveryEnabledRule() + { + // Arrange + var now = TimeProvider.GetUtcNow().UtcDateTime; + var rules = Enumerable.Range(0, 501).Select(index => new RateNotificationRule + { + OrganizationId = TestConstants.OrganizationId, + ProjectId = TestConstants.ProjectId, + UserId = TestConstants.UserId, + Name = $"Rule {index}", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Stack, + StackId = ObjectId.GenerateNewId().ToString(), + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + Version = 1, + CreatedUtc = now, + UpdatedUtc = now + }).ToList(); + await _repository.AddAsync(rules, o => o.ImmediateConsistency()); + + // Act + var plan = await _cache.GetCounterPlanAsync(TestConstants.ProjectId, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(501, plan.RuleCount); + Assert.Equal(501, plan.StackCounters.Count); + } + + [Fact] + public async Task RemoveProjectRateNotificationRulesAsync_InvalidatesCompiledPlan() + { + // Arrange + var now = TimeProvider.GetUtcNow().UtcDateTime; + await _repository.AddAsync(new RateNotificationRule + { + OrganizationId = TestConstants.OrganizationId, + ProjectId = TestConstants.ProjectId, + UserId = TestConstants.UserId, + Name = "Rule to remove", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + Version = 1, + CreatedUtc = now, + UpdatedUtc = now + }, o => o.ImmediateConsistency()); + var ct = TestContext.Current.CancellationToken; + Assert.True((await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct)).HasCounters); + + // Act + await _organizationService.RemoveProjectRateNotificationRulesAsync(TestConstants.OrganizationId, TestConstants.ProjectId); + var plan = await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct); + + // Assert + Assert.False(plan.HasCounters); + } + + [Fact] + public async Task SaveAsync_RuleChanged_InvalidatesCompiledPlan() + { + // Arrange + var now = TimeProvider.GetUtcNow().UtcDateTime; + var rule = await _repository.AddAsync(CreateRule(TestConstants.ProjectId, TestConstants.UserId, now), o => o.ImmediateConsistency()); + var ct = TestContext.Current.CancellationToken; + var initialPlan = await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct); + Assert.True(initialPlan.ProjectCounters.ContainsKey(RateNotificationSignal.Errors)); + + // Act + rule.Signal = RateNotificationSignal.CriticalErrors; + await _repository.SaveAsync(rule, o => o.ImmediateConsistency()); + var updatedPlan = await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct); + + // Assert + Assert.False(updatedPlan.ProjectCounters.ContainsKey(RateNotificationSignal.Errors)); + Assert.True(updatedPlan.ProjectCounters.ContainsKey(RateNotificationSignal.CriticalErrors)); + } + + [Fact] + public async Task RemoveUserRateNotificationRulesAsync_RemovesOnlyOwnedRulesAndInvalidatesPlans() + { + // Arrange + string otherProjectId = ObjectId.GenerateNewId().ToString(); + var now = TimeProvider.GetUtcNow().UtcDateTime; + await _repository.AddAsync([ + CreateRule(TestConstants.ProjectId, TestConstants.UserId, now), + CreateRule(otherProjectId, TestConstants.UserId2, now) + ], o => o.ImmediateConsistency()); + var ct = TestContext.Current.CancellationToken; + Assert.True((await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct)).HasCounters); + Assert.True((await _cache.GetCounterPlanAsync(otherProjectId, ct)).HasCounters); + + // Act + await _organizationService.RemoveUserRateNotificationRulesAsync(TestConstants.OrganizationId, TestConstants.UserId); + + // Assert + Assert.Empty((await _repository.GetByOrganizationIdAndUserIdAsync(TestConstants.OrganizationId, TestConstants.UserId)).Documents); + Assert.Single((await _repository.GetByOrganizationIdAndUserIdAsync(TestConstants.OrganizationId, TestConstants.UserId2)).Documents); + Assert.False((await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct)).HasCounters); + Assert.True((await _cache.GetCounterPlanAsync(otherProjectId, ct)).HasCounters); + } + + [Fact] + public async Task RemoveRateNotificationRulesAsync_RemovesOrganizationRulesAndInvalidatesEveryPlan() + { + // Arrange + string otherProjectId = ObjectId.GenerateNewId().ToString(); + var now = TimeProvider.GetUtcNow().UtcDateTime; + await _repository.AddAsync([ + CreateRule(TestConstants.ProjectId, TestConstants.UserId, now), + CreateRule(otherProjectId, TestConstants.UserId2, now) + ], o => o.ImmediateConsistency()); + var ct = TestContext.Current.CancellationToken; + Assert.True((await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct)).HasCounters); + Assert.True((await _cache.GetCounterPlanAsync(otherProjectId, ct)).HasCounters); + + // Act + await _organizationService.RemoveRateNotificationRulesAsync(TestConstants.OrganizationId); + + // Assert + Assert.Empty((await _repository.GetByOrganizationIdAsync(TestConstants.OrganizationId)).Documents); + Assert.False((await _cache.GetCounterPlanAsync(TestConstants.ProjectId, ct)).HasCounters); + Assert.False((await _cache.GetCounterPlanAsync(otherProjectId, ct)).HasCounters); + } + + private static RateNotificationRule CreateRule(string projectId, string userId, DateTime now) + { + return new RateNotificationRule + { + OrganizationId = TestConstants.OrganizationId, + ProjectId = projectId, + UserId = userId, + Name = "Lifecycle rule", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 10, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30), + Version = 1, + CreatedUtc = now, + UpdatedUtc = now + }; + } +} diff --git a/tests/http/rate-notifications.http b/tests/http/rate-notifications.http index f5a2ad840f..a42f9c26f7 100644 --- a/tests/http/rate-notifications.http +++ b/tests/http/rate-notifications.http @@ -44,7 +44,7 @@ Content-Type: application/json { "name": "Project errors", - "signal": "Error", + "signal": "Errors", "subject": "Project", "threshold": 10, "window": "00:05:00",