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..5e4b8a3845 --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/design.md @@ -0,0 +1,439 @@ +# 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. It also follows the existing premium-only occurrence-notification model instead of introducing a new free notification channel. + +## 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? LastFiredUtc { 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. +- `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 + +### IRateNotificationRuleRepository + +```csharp +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); +} +``` + +### 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. + +### Dark-launch gating + +- 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` +- `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 + +### RateNotificationRuleCache service + +Purpose: + +- Load enabled rules for a project. +- Cache compiled counter definitions briefly. +- Ensure event pipeline can cheaply determine which counters to increment. +- 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:v2:counter-plan: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:v2:counter-plan: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: configured cooldown duration + +### 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) +- 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 +- Performs one increment per unique matching counter and one batched active-key update per event +- 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 +- 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 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 +- 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. + +### 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 + +```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 +- Loads stack for stack-scoped rules so email copy can include stack title and deep link +- Validates: + - Rule still exists + - Rule is enabled + - 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 + - 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) + +## 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 + +```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 +- Entire feature hidden unless `ViewProject.has_rate_notifications` exposes the combined premium-plus-rollout capability + +### 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 + +- `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 + +- `RuleId` +- `ProjectId` +- `UserId` +- `ObservedCount` +- `Threshold` +- `Reason` (skipped/fired) + +## Bootstrap / DI + +- Register `IRateNotificationRuleRepository` / `RateNotificationRuleRepository` +- Register the persistent `RateNotificationRuleIndex` and compiled `RateNotificationRuleCache` +- 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 +- 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 + +- 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 +- 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 + +- 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..6dd4c4ebaa --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/proposal.md @@ -0,0 +1,126 @@ +# 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 +- 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. + +## 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. +- 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. +- 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 + +- 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 | +| 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 | +| 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 + +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. 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 new file mode 100644 index 0000000000..dbdfabc5e0 --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/specs/rate-notifications/spec.md @@ -0,0 +1,432 @@ +# 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: 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 +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: Rate notifications honor premium and rollout gating + +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 + +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. + +#### 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. + +#### 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: 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. + +#### 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: 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. + +#### 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. + +#### 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. + +#### 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. + +#### Scenario: Organizations without the combined capability do not see rate notifications + +Given the organization lacks premium status or the `rate-notifications` feature +When the user opens project notification settings +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 + +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. + +### 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. + +#### 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 new file mode 100644 index 0000000000..1b0a85db51 --- /dev/null +++ b/openspec/changes/add-personal-rate-notifications/tasks.md @@ -0,0 +1,165 @@ +# Tasks: Add Personal Rate Notifications + +## Backend - Models and Repositories + +- [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) + +- [x] **Add IRateNotificationRuleRepository and implementation** + - Create interface extending `IRepositoryOwnedByOrganizationAndProject` + - Add methods: `GetByProjectIdAndUserIdAsync`, `GetEnabledByProjectIdAsync`, `CountByProjectIdAndUserIdAsync` + - Create Elasticsearch-backed implementation following existing repository patterns + +- [x] **Register repository in DI** + - Register `IRateNotificationRuleRepository` / `RateNotificationRuleRepository` in `Bootstrapper.cs` + +## Backend - Countering and Evaluation + +- [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}` + +- [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 = configured cooldown + +- [x] **Add UpdateRateCountersAction (event pipeline)** + - Priority after stack assignment (e.g., 75) + - 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) + - Perform N unique counter increments plus one batched active-key update + - Never query Elasticsearch per event; never send notifications directly + +- [x] **Add RateNotificationEvaluatorJob** + - Periodic job (60s interval) with distributed lock + - 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 + - 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 + +- [x] **Add RateNotification queue model** + - Fields: RuleId, RuleVersion, OrganizationId, ProjectId, UserId, SubjectKey, StackId, WindowStartUtc, WindowEndUtc, ObservedCount, Threshold + +- [x] **Register counter/evaluator services in DI** + - Register `RateNotificationRuleCache`, `RateCounterService`, `RateNotificationEvaluatorJob` + - Register `IQueue` in Core and Insulation bootstrappers + +## Backend - 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 + +- [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 + +- [x] **Update Insulation queue registration** + - Register `IQueue` for Redis/Azure/SQS providers in `Exceptionless.Insulation/Bootstrapper.cs` + +## Backend - API + +- [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: 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 + +- [x] **Add request/response DTOs** + - Create/update request models with validation attributes + - Snooze request model (duration or until timestamp) + +- [x] **Update tests/http files** + - Add HTTP sample requests for all rate notification endpoints + +## Frontend + +- [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) + +- [x] **Add rate notification rule list component** + - List rules for current user/project + - Show enable/disable toggle, snooze status + - Hide the feature unless the project exposes the combined premium-plus-rollout capability + - Delete confirmation + +- [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." + +- [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 + - Gate the section with additive `ViewProject.has_rate_notifications` + +## Tests + +- [x] **Add unit tests** + - Rule validation (threshold, window, cooldown, subject/stack, name) + - 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 + +- [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 + - 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 + +- [x] **Add lifecycle cleanup** + - Remove/invalidate rules when user membership changes + - Remove/invalidate rules when project or organization is deleted + +## Validation + +- [x] **Run OpenSpec validation** + - `openspec validate add-personal-rate-notifications --strict --no-interactive` + +- [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 9cfb19de39..45bec0e0c5 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -108,8 +108,10 @@ 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>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, RateNotificationDuplicateDetectionQueueBehavior>()); services.AddSingleton(); services.AddSingleton(); @@ -123,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 { @@ -145,6 +159,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -194,6 +209,9 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO }); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddStartupAction(); services.AddSingleton(); services.AddSingleton(); @@ -289,12 +307,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)); @@ -321,4 +341,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/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index 64605e0c70..fab0a4ff17 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -7,6 +7,7 @@ + @@ -18,6 +19,7 @@ + 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 new file mode 100644 index 0000000000..49f75a7ba4 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs @@ -0,0 +1,262 @@ +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; +using Foundatio.Repositories; +using Foundatio.Repositories.Utility; +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 IProjectRepository _projectRepository; + 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, + IProjectRepository projectRepository, + IQueue notificationQueue, + ILockProvider lockProvider, + TimeProvider timeProvider, + IResiliencePolicyProvider resiliencePolicyProvider, + ILoggerFactory loggerFactory) : base(timeProvider, resiliencePolicyProvider, loggerFactory) + { + _counterService = counterService; + _ruleRepository = ruleRepository; + _organizationRepository = organizationRepository; + _projectRepository = projectRepository; + _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) + { + using var evaluationTimer = AppDiagnostics.RateNotificationEvaluationTime.StartTimer(); + var now = _timeProvider.GetUtcNow().UtcDateTime; + 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; + + _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(); + 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 + .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); + + foreach (var projectCounterKeys in counterKeysByProject) + { + if (context.CancellationToken.IsCancellationRequested) + return JobResult.Cancelled; + + await context.RenewLockAsync(); + await EvaluateProjectAsync(projectCounterKeys.Key, projectCounterKeys, currentMinute, context); + } + + await _counterService.SetLastEvaluatedMinuteAsync(toMinute, context.CancellationToken); + _logger.LogInformation("Finished evaluating rate notification rules"); + return JobResult.Success; + } + + private async Task EvaluateProjectAsync(string projectId, IEnumerable counterKeys, DateTime evaluationEndUtc, JobContext context) + { + var ct = context.CancellationToken; + var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache()); + if (project is null) + return; + + var organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + if (organization is null || !organization.HasRateNotifications()) + return; + + 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)) + continue; + + foreach (var rule in matchingRules) + { + if (ct.IsCancellationRequested) + 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) + return; + + // Skip if actively snoozed + if (rule.SnoozedUntilUtc.HasValue && rule.SnoozedUntilUtc.Value > evaluationEndUtc) + { + _logger.LogDebug("Skipping snoozed rule {RuleId} snoozed until {SnoozedUntil}", rule.Id, rule.SnoozedUntilUtc); + return; + } + + 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 snoozeBoundaryUtc = rule.SnoozedUntilUtc?.Ceiling(TimeSpan.FromMinutes(1)); + var effectiveWindowStartUtc = snoozeBoundaryUtc.HasValue && snoozeBoundaryUtc.Value > windowStartUtc + ? snoozeBoundaryUtc.Value + : windowStartUtc; + + var observedCount = await _counterService.SumBucketsAsync(counterKey, effectiveWindowStartUtc, evaluationEndUtc, 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); + + // 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; + } + + 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 + { + await _counterService.RemoveCooldownAsync(rule.Id, subjectKey, ct); + throw; + } + + // Update LastFiredUtc + rule.LastFiredUtc = evaluationEndUtc; + 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); + } + + /// 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, StringComparison.Ordinal)) + return null; + + int start = prefix.Length; + int end = counterKey.IndexOf(':', start); + if (end < 0) + return null; + + string projectId = counterKey[start..end]; + return ObjectId.IsValid(projectId) ? projectId : null; + } + + 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..295c1ced80 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs @@ -0,0 +1,165 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Mail; +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; + +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 IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; + private readonly IUserRepository _userRepository; + private readonly IStackRepository _stackRepository; + + public RateNotificationsJob( + IQueue queue, + IMailer mailer, + IRateNotificationRuleRepository ruleRepository, + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IUserRepository userRepository, + IStackRepository stackRepository, + TimeProvider timeProvider, + IResiliencePolicyProvider resiliencePolicyProvider, + ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) + { + _mailer = mailer; + _ruleRepository = ruleRepository; + _organizationRepository = organizationRepository; + _projectRepository = projectRepository; + _userRepository = userRepository; + _stackRepository = stackRepository; + } + + protected override async Task ProcessQueueEntryAsync(QueueEntryContext context) + { + 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) + return Skip($"Rate notification rule {wi.RuleId} not found; skipping."); + + 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}"; + 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 < 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."); + } + + // 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 Skip($"Rate notification rule {wi.RuleId} changed after enqueue; skipping."); + } + + 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 || !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 Skip($"User {rule.UserId} not found; skipping."); + + // 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 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 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 Skip($"User {rule.UserId} disabled email notifications; skipping."); + } + + // 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 || + !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/Mail/IMailer.cs b/src/Exceptionless.Core/Mail/IMailer.cs index 6e8905cab5..4050fdecb9 100644 --- a/src/Exceptionless.Core/Mail/IMailer.cs +++ b/src/Exceptionless.Core/Mail/IMailer.cs @@ -11,6 +11,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 16394cca4d..098d36d683 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -317,6 +317,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" }, + { "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", + 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/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs new file mode 100644 index 0000000000..f9cab7e722 --- /dev/null +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSignal.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +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..4ad179a98f --- /dev/null +++ b/src/Exceptionless.Core/Models/Enums/RateNotificationSubject.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +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..0658bb51a9 --- /dev/null +++ b/src/Exceptionless.Core/Models/Queues/RateNotification.cs @@ -0,0 +1,20 @@ +using Foundatio.Queues; + +namespace Exceptionless.Core.Queues.Models; + +public class RateNotification : IHaveUniqueIdentifier +{ + 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; } + + public string UniqueIdentifier => $"RateNotification:{RuleId}:{RuleVersion}:{WindowEndUtc.Ticks}"; +} diff --git a/src/Exceptionless.Core/Models/RateNotificationRule.cs b/src/Exceptionless.Core/Models/RateNotificationRule.cs new file mode 100644 index 0000000000..4ad5e43932 --- /dev/null +++ b/src/Exceptionless.Core/Models/RateNotificationRule.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Attributes; +using Foundatio.Repositories.Models; + +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!; + + [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; + + [EnumDataType(typeof(RateNotificationSignal))] + public RateNotificationSignal Signal { get; set; } + + [EnumDataType(typeof(RateNotificationSubject))] + 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/Pipeline/075_UpdateRateCountersAction.cs b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs new file mode 100644 index 0000000000..5ace30c48e --- /dev/null +++ b/src/Exceptionless.Core/Pipeline/075_UpdateRateCountersAction.cs @@ -0,0 +1,78 @@ +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; + +namespace Exceptionless.Core.Pipeline; + +[Priority(75)] +public class UpdateRateCountersAction : EventPipelineActionBase +{ + private readonly RateNotificationRuleCache _ruleCache; + private readonly RateCounterService _counterService; + + public UpdateRateCountersAction( + RateNotificationRuleCache ruleCache, + RateCounterService counterService, + AppOptions options, + ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _ruleCache = ruleCache; + _counterService = counterService; + ContinueOnError = true; + } + + public override async Task ProcessAsync(EventContext ctx) + { + if (!ShouldIncrement(ctx)) + return; + + // 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; + + var counterKeys = GetCounterKeys(ctx.Event, ctx.IsNew, ctx.IsRegression, counterPlan); + AppDiagnostics.RateCounterKeysIncremented.Add(counterKeys.Count); + await _counterService.IncrementAsync(counterKeys); + } + + internal static bool ShouldIncrement(EventContext ctx) + { + if (ctx.IsCancelled || ctx.IsDiscarded || !ctx.Organization.HasRateNotifications()) + return false; + + 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, RateNotificationCounterPlan counterPlan) + { + bool isError = ev.IsError(); + bool isCritical = ev.IsCritical(); + var matchedSignals = new HashSet(); + + matchedSignals.Add(RateNotificationSignal.AllEvents); + + if (isError) + matchedSignals.Add(RateNotificationSignal.Errors); + + if (isError && isCritical) + matchedSignals.Add(RateNotificationSignal.CriticalErrors); + + if (isNew && isError) + matchedSignals.Add(RateNotificationSignal.NewErrors); + + if (isRegression) + matchedSignals.Add(RateNotificationSignal.Regressions); + + return counterPlan.GetCounterKeys(ev.StackId, matchedSignals); + } +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs index 7fd742b21b..3033beb15d 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs @@ -46,6 +46,7 @@ ILoggerFactory loggerFactory AddIndex(OAuthApplications = new OAuthApplicationIndex(this)); AddIndex(OAuthTokens = new OAuthTokenIndex(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)); @@ -76,6 +77,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder) public OAuthApplicationIndex OAuthApplications { get; } public OAuthTokenIndex OAuthTokens { 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..52dda63b19 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/RateNotificationRuleIndex.cs @@ -0,0 +1,45 @@ +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; + +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 void ConfigureIndexMapping(TypeMappingDescriptor map) + { + map + .Dynamic(DynamicMapping.False) + .Properties(p => p + .SetupDefaults() + .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 void ConfigureIndex(CreateIndexRequestDescriptor idx) + { + base.ConfigureIndex(idx); + 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..3e575c60c1 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Interfaces/IRateNotificationRuleRepository.cs @@ -0,0 +1,14 @@ +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> 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 new file mode 100644 index 0000000000..dad515bd41 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/RateNotificationRuleRepository.cs @@ -0,0 +1,81 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Validation; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; + +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), 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); + + return FindAsync(q => q + .Project(projectId) + .FieldEquals(r => r.IsEnabled, true) + .FieldEquals(r => r.IsDeleted, false) + .SortAscending(r => r.Id), 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 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); + ArgumentException.ThrowIfNullOrEmpty(userId); + + return RemoveAllAsync(q => q + .Organization(organizationId) + .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 5413a5cfe7..0b7f797ea6 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -14,6 +14,8 @@ public class OrganizationService : IStartupAction private const int BATCH_SIZE = 50; 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; @@ -22,10 +24,12 @@ 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, 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; @@ -193,6 +197,32 @@ public Task RemoveUserSavedViewsAsync(string organizationId, string userId return _savedViewRepository.RemovePrivateByUserIdAsync(organizationId, userId); } + public async Task RemoveRateNotificationRulesAsync(string organizationId) + { + _logger.LogDebug("Removing rate notification rules for organization {OrganizationId}", organizationId); + var projectIds = await GetRateNotificationProjectIdsAsync(organizationId); + long removed = await _rateNotificationRuleRepository.RemoveAllByOrganizationIdAsync(organizationId); + await Task.WhenAll(projectIds.Select(_rateNotificationRuleCache.InvalidateAsync)); + return removed; + } + + public async Task RemoveProjectRateNotificationRulesAsync(string organizationId, string projectId) + { + _logger.LogDebug("Removing rate notification rules for project {ProjectId} in organization {OrganizationId}", projectId, organizationId); + long removed = await _rateNotificationRuleRepository.RemoveAllByProjectIdAsync(organizationId, projectId); + await _rateNotificationRuleCache.InvalidateAsync(projectId); + return removed; + } + + public async Task RemoveUserRateNotificationRulesAsync(string organizationId, string userId) + { + _logger.LogDebug("Removing rate notification rules for user {UserId} in organization {OrganizationId}", userId, organizationId); + 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) { if (organization.IsDeleted) @@ -201,6 +231,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, []); @@ -226,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 new file mode 100644 index 0000000000..40ae586845 --- /dev/null +++ b/src/Exceptionless.Core/Services/RateCounterService.cs @@ -0,0 +1,129 @@ +using Foundatio.Caching; + +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 = configured cooldown +/// rate:v1:evaluator:last-minute — last completed evaluation minute +/// +public class RateCounterService +{ + private readonly ICacheClient _cache; + private readonly TimeProvider _timeProvider; + + private static readonly TimeSpan BucketTtl = TimeSpan.FromHours(3); + private const string LastEvaluatedMinuteKey = "rate:v1:evaluator:last-minute"; + + public RateCounterService(ICacheClient cache, TimeProvider timeProvider) + { + _cache = cache; + _timeProvider = timeProvider; + } + + /// Increments the 1-minute bucket counter for the given counter key at the current UTC minute. + 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(); + + var increments = distinctCounterKeys.Select(async counterKey => + { + await _cache.IncrementAsync(GetCountKey(epochMinute, counterKey), 1, BucketTtl); + }); + + string activeKey = GetActiveKey(epochMinute); + 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). + 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; + + var keys = Enumerable.Range(0, checked((int)(toMinute - fromMinute))) + .Select(offset => GetCountKey(fromMinute + offset, counterKey)) + .ToList(); + var values = await _cache.GetAllAsync(keys); + + return values.Values.Where(value => value.HasValue).Sum(value => value.Value); + } + + /// 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); + } + + /// Atomically claims the cooldown for a rule/subject combination. + public Task TrySetCooldownAsync(string ruleId, string subjectKey, TimeSpan duration, CancellationToken ct = default) + { + 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 ---- + + 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/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 new file mode 100644 index 0000000000..ff84cd7525 --- /dev/null +++ b/src/Exceptionless.Core/Services/RateNotificationRuleCache.cs @@ -0,0 +1,104 @@ +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; + +/// +/// 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 : 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); + + public RateNotificationRuleCache(IRateNotificationRuleRepository repository, IHybridCacheClient cache) + { + _repository = repository; + _cache = cache; + } + + 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); + if (cached.HasValue && cached.Value is not null) + return cached.Value; + + 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; + + 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 async Task InvalidateAsync(string projectId) + { + 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:v2:counter-plan:project:{projectId}"; +} 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.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.Insulation/Bootstrapper.cs b/src/Exceptionless.Insulation/Bootstrapper.cs index 5ce4f4f7b3..94d3c0e6cf 100644 --- a/src/Exceptionless.Insulation/Bootstrapper.cs +++ b/src/Exceptionless.Insulation/Bootstrapper.cs @@ -88,6 +88,7 @@ private static IHealthChecksBuilder RegisterHealthChecks(IServiceCollection serv .AddAutoNamedCheck>("EventUserDescriptions", "AllJobs") .AddAutoNamedCheck>("EventNotifications", "AllJobs") .AddAutoNamedCheck>("WebHooks", "AllJobs") + .AddAutoNamedCheck>("RateNotifications", "AllJobs") .AddAutoNamedCheck>("AllJobs") .AddAutoNamedCheck>("WorkItem", "AllJobs") @@ -158,6 +159,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))); } @@ -167,6 +169,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))); } @@ -176,6 +179,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))); } 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/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/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 new file mode 100644 index 0000000000..209ee28dcb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts @@ -0,0 +1,170 @@ +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, type QueryClient, useQueryClient } from '@tanstack/svelte-query'; + +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 RuleBodyMutationVariables extends RuleMutationVariables { + body: TBody; +} + +interface RuleMutationVariables extends MutationRoute { + ruleId: string; +} + +export const queryKeys = { + list: (userId: string | undefined, projectId: string | undefined) => ['RateNotificationRule', userId, projectId] as const +}; + +export function deleteRateNotificationRule() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + mutationFn: async (variables) => { + const client = useFetchClient(); + await client.delete(ruleRoute(variables, variables.ruleId), { expectedStatusCodes: [204] }); + }, + onSuccess: (_, variables) => { + updateRulesCache(queryClient, variables, (rules) => rules.filter((rule) => rule.id !== variables.ruleId)); + scheduleConsistencyRefresh(queryClient, variables); + } + })); +} + +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(); + return client.getJSON(ruleRoute(request.route), { + params: { limit: 50, ...request.params }, + signal + }); + }, + queryKey: [...queryKeys.list(request.route.userId, request.route.projectId), { params: request.params }] + })); +} + +export function postRateNotificationRule() { + const queryClient = useQueryClient(); + + return createMutation>(() => ({ + mutationFn: async ({ body, ...route }) => { + const client = useFetchClient(); + const response = await client.postJSON(ruleRoute(route), body); + return response.data!; + }, + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); + } + })); +} + +export function postSnoozeRateNotificationRule() { + const queryClient = useQueryClient(); + + return createMutation>(() => ({ + mutationFn: async ({ body, ruleId, ...route }) => { + const client = useFetchClient(); + const response = await client.postJSON(`${ruleRoute(route, ruleId)}/snooze`, body, { + expectedStatusCodes: [200] + }); + return response.data!; + }, + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); + } + })); +} + +export function postUnsnoozeRateNotificationRule() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + mutationFn: async (variables) => { + const client = useFetchClient(); + const response = await client.postJSON( + `${ruleRoute(variables, variables.ruleId)}/unsnooze`, + {}, + { + expectedStatusCodes: [200] + } + ); + return response.data!; + }, + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); + } + })); +} + +export function putRateNotificationRule() { + const queryClient = useQueryClient(); + + return createMutation>(() => ({ + mutationFn: async ({ body, ruleId, ...route }) => { + const client = useFetchClient(); + const response = await client.putJSON(ruleRoute(route, ruleId), body); + return response.data!; + }, + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + scheduleConsistencyRefresh(queryClient, variables); + } + })); +} + +function ruleRoute(route: RateNotificationRoute, ruleId?: string): string { + const base = `users/${route.userId}/projects/${route.projectId}/rate-notifications`; + return ruleId ? `${base}/${ruleId}` : base; +} + +function scheduleConsistencyRefresh(queryClient: QueryClient, route: RateNotificationRoute): void { + const queryKey = queryKeys.list(route.userId, route.projectId); + setTimeout(() => void queryClient.invalidateQueries({ queryKey }), CONSISTENCY_REFRESH_DELAY_MS); +} + +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 { ...response, data: update(response.data) }; + } + ); +} + +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 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..8569867d26 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts @@ -0,0 +1,183 @@ +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() 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; + }>; + + // Act + 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); + 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() as unknown as Mutation< + { body: { duration_seconds: number }; projectId: string; ruleId: string; userId: 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, { ...route, 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 new file mode 100644 index 0000000000..955c508896 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-form.svelte @@ -0,0 +1,344 @@ + + +
{ + event.preventDefault(); + form.handleSubmit(); + }} +> + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + {#if !hasPremiumFeatures} + + + {/if} + + + + + + + {#snippet children(field)} + + Name + field.handleChange(event.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + + {#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} + + + + {#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} + + + 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} + + + + {#snippet children(field)} + + Threshold (events) + field.handleChange(event.currentTarget.valueAsNumber)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + + {#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} + + + + {#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 new file mode 100644 index 0000000000..f54468b683 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/components/rate-notification-rule-list.svelte @@ -0,0 +1,216 @@ + + +
+ + + {#if !hasPremiumFeatures} + + + {/if} + + {#if listQuery.isLoading} +
+ {#each [0, 1] as index (index)} + + {/each} +
+ {:else if listQuery.isError} + + {:else if rules.length === 0} +
+
+ {:else} +
+ {#each rules as rule (rule.id)} +
+
+
+ +
+ {SIGNAL_LABELS[rule.signal]} + ≥{rule.threshold} in {windowLabel(rule.window)} + {#if rule.is_snoozed} + + {/if} + {#if rule.subject === RateNotificationSubject.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. + + + 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 new file mode 100644 index 0000000000..b565bbac10 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/index.ts @@ -0,0 +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 './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..439f4134ef --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.test.ts @@ -0,0 +1,155 @@ +import { RateNotificationSignal, RateNotificationSubject, type ViewRateNotificationRule } from '$generated/api'; +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', + 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'] })); + }); + + 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', () => { + 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..20f9d9f0a3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/schemas.ts @@ -0,0 +1,79 @@ +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 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; + +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 new file mode 100644 index 0000000000..08ef8a92ea --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/types.ts @@ -0,0 +1,36 @@ +import { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; + +export type { NewRateNotificationRule, SnoozeRateNotificationRuleRequest, UpdateRateNotificationRule, ViewRateNotificationRule } from '$generated/api'; +export { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; + +export const MAX_RULES_PER_PROJECT = 20; + +export const SIGNAL_LABELS: Record = { + [RateNotificationSignal.AllEvents]: 'All Events', + [RateNotificationSignal.CriticalErrors]: 'Critical Errors', + [RateNotificationSignal.Errors]: 'Errors', + [RateNotificationSignal.NewErrors]: 'New Errors', + [RateNotificationSignal.Regressions]: 'Regressions' +}; + +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' } +] 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: '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 1e8554a074..19d193d31e 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; @@ -680,11 +755,41 @@ 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[]; } +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..398b0fb57e 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(), @@ -745,12 +846,47 @@ 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)), }); 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 5c660266e7..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 @@ -1,14 +1,18 @@
@@ -201,5 +237,45 @@ {emailNotificationsEnabled} hasPremiumFeatures={selectedProject.has_premium_features} /> + + {#if rateNotificationsEnabled} +
+

Rate Notifications

+ Get notified when event rates for this project exceed your custom thresholds. +
+ + + {/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} 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/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..8f63ab8355 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, + OrganizationService organizationService, IQueue workItemQueue, BillingManager billingManager, SlackService slackService, @@ -67,6 +69,7 @@ ILoggerFactory loggerFactory _stackRepository = stackRepository; _eventRepository = eventRepository; _tokenRepository = tokenRepository; + _organizationService = organizationService; _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 _organizationService.RemoveProjectRateNotificationRulesAsync(project.OrganizationId, project.Id); } return await base.DeleteModelsAsync(projects); @@ -756,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 new file mode 100644 index 0000000000..6ec78afaff --- /dev/null +++ b/src/Exceptionless.Web/Controllers/RateNotificationRuleController.cs @@ -0,0 +1,437 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +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 Foundatio.Lock; +using Foundatio.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +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 IUserRepository _userRepository; + private readonly IStackRepository _stackRepository; + private readonly ILockProvider _lockProvider; + + public RateNotificationRuleController( + IRateNotificationRuleRepository ruleRepository, + IProjectRepository projectRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IStackRepository stackRepository, + ILockProvider lockProvider, + TimeProvider timeProvider) : base(timeProvider) + { + _ruleRepository = ruleRepository; + _projectRepository = projectRepository; + _organizationRepository = organizationRepository; + _userRepository = userRepository; + _stackRepository = stackRepository; + _lockProvider = lockProvider; + } + + /// 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, userId); + 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, userId); + 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()))}"); + + // Validate cooldown >= window + 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) + { + 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) || + !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)) + { + return ValidationProblem(detail: "StackId must be empty when Subject is Project."); + } + + 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."); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var rule = new RateNotificationRule + { + OrganizationId = project.OrganizationId, + ProjectId = projectId, + UserId = userId, + Name = model.Name.Trim(), + 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().ImmediateConsistency()); + + 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 project = await GetProjectAndCheckAccessAsync(projectId, userId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); + 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, userId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); + 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.Trim(); + + 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 ?? (rule.Subject == RateNotificationSubject.Stack ? rule.StackId : null); + 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) || + !String.Equals(stack.OrganizationId, project.OrganizationId, 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 (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()); + rule.IsEnabled = model.IsEnabled.Value && organization?.HasRateNotifications() == true; + } + + rule.Version++; + rule.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + + await _ruleRepository.SaveAsync(rule, o => o.Cache().ImmediateConsistency()); + + 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 project = await GetProjectAndCheckAccessAsync(projectId, userId); + if (project is null) + return NotFound(); + + var rule = await GetRuleAndCheckAccessAsync(ruleId, userId, project); + if (rule is null) + return NotFound(); + + await _ruleRepository.RemoveAsync(rule, o => o.ImmediateConsistency()); + + 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 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 == 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; + await _ruleRepository.SaveAsync(rule, o => o.Cache().ImmediateConsistency()); + + 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 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 + var now = _timeProvider.GetUtcNow().UtcDateTime; + rule.SnoozedUntilUtc = now; + rule.Version++; + rule.UpdatedUtc = now; + await _ruleRepository.SaveAsync(rule, o => o.Cache().ImmediateConsistency()); + + 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, 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, Project project) + { + 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, project.Id, StringComparison.Ordinal)) + return null; + + if (!String.Equals(rule.OrganizationId, project.OrganizationId, StringComparison.Ordinal)) + 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/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/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/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; } +} 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/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..7cc2ae248b 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": { @@ -10803,6 +11354,7 @@ "stack_count", "event_count", "has_premium_features", + "has_rate_notifications", "has_slack_integration", "usage_hours", "usage" @@ -10864,6 +11416,9 @@ "has_premium_features": { "type": "boolean" }, + "has_rate_notifications": { + "type": "boolean" + }, "has_slack_integration": { "type": "boolean" }, @@ -10881,6 +11436,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 +11893,9 @@ } }, "tags": [ + { + "name": "RateNotificationRule" + }, { "name": "SavedView" }, diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 6e99232bcc..807e671e7d 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,24 @@ 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", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 1, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(5), + Version = 1, + CreatedUtc = TimeProvider.GetUtcNow().UtcDateTime, + UpdatedUtc = TimeProvider.GetUtcNow().UtcDateTime + }, o => o.ImmediateConsistency()); + var ruleCache = GetService(); + Assert.Equal(1, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); // Act await SendRequestAsync(r => r @@ -931,6 +952,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.Equal(0, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); } [Fact] diff --git a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs index d8843620cd..4fa5d29b9a 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,14 +23,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 +113,27 @@ 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()); + var ruleCache = GetService(); + Assert.Equal(1, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); // Act var workItems = await SendRequestAsAsync(r => r @@ -126,6 +152,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.Equal(0, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); } [Fact] @@ -285,6 +313,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 new file mode 100644 index 0000000000..a9d77f8a0c --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/RateNotificationRuleControllerTests.cs @@ -0,0 +1,761 @@ +using System.Net; +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; + +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(); + } + + 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 ---- + 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}"; + + 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] + 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 organization 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 organization 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_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() + { + 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_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() + { + 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 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() + { + 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 19 rules, then race the final two requests against the limit. + for (int i = 0; i < 19; 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() + ); + } + + Task CreateFinalRuleAsync(string name) => SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, 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) + })); + + 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] + 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/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/CleanupDataJobTests.cs b/tests/Exceptionless.Tests/Jobs/CleanupDataJobTests.cs index c7300641a2..065ad11446 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; @@ -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,24 @@ 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", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 1, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(5), + Version = 1, + CreatedUtc = TimeProvider.GetUtcNow().UtcDateTime, + UpdatedUtc = TimeProvider.GetUtcNow().UtcDateTime + }, o => o.ImmediateConsistency()); + var ruleCache = GetService(); + Assert.Equal(1, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); 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 +175,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.Equal(0, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); Assert.False(await _fileStorage.ExistsAsync(iconPath)); } @@ -248,6 +270,24 @@ 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", + IsEnabled = true, + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 1, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(5), + Version = 1, + CreatedUtc = TimeProvider.GetUtcNow().UtcDateTime, + UpdatedUtc = TimeProvider.GetUtcNow().UtcDateTime + }, o => o.ImmediateConsistency()); + var ruleCache = GetService(); + Assert.Equal(1, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); await _job.RunAsync(TestCancellationToken); @@ -255,6 +295,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.Equal(0, (await ruleCache.GetCounterPlanAsync(project.Id, TestCancellationToken)).RuleCount); } [Fact] diff --git a/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs new file mode 100644 index 0000000000..cb4f11fbef --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs @@ -0,0 +1,343 @@ +using Exceptionless.Core.Extensions; +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 Foundatio.Repositories; +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 _organizationRepository; + private readonly IProjectRepository _projectRepository; + private readonly IQueue _notificationQueue; + + public RateNotificationEvaluatorJobTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _job = GetService(); + _counterService = GetService(); + _ruleRepository = GetService(); + _organizationRepository = GetService(); + _projectRepository = GetService(); + _notificationQueue = GetService>(); + } + + 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, o => o.ImmediateConsistency()); + } + + 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), o => o.ImmediateConsistency()); + 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), o => o.ImmediateConsistency()); + 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_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 _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.HasPremiumFeatures = false; + await _organizationRepository.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_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() + { + // 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_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() + { + 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), o => o.ImmediateConsistency()); + 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") + Assert.True(await _counterService.TrySetCooldownAsync(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, o => o.ImmediateConsistency()); + 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, o => o.ImmediateConsistency()); + + // Rule B: not snoozed — should fire normally + var ruleB = await _ruleRepository.AddAsync(BuildRule(threshold: 10), o => o.ImmediateConsistency()); + + // 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); + } + + [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 new file mode 100644 index 0000000000..753608cafa --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs @@ -0,0 +1,404 @@ +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 Exceptionless.Tests.Utility; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Repositories.Utility; +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_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_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_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() + { + // 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 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() + { + // 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/CountingMailer.cs b/tests/Exceptionless.Tests/Mail/CountingMailer.cs index 2967a84886..fe4f047b4b 100644 --- a/tests/Exceptionless.Tests/Mail/CountingMailer.cs +++ b/tests/Exceptionless.Tests/Mail/CountingMailer.cs @@ -70,6 +70,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 7ce54fcde6..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); @@ -49,4 +51,12 @@ 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) + { + 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/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); + } +} diff --git a/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs new file mode 100644 index 0000000000..6c6488615e --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs @@ -0,0 +1,226 @@ +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; + +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 plan = RateNotificationCounterPlan.Compile(ev.ProjectId, rules); + var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, plan); + + // 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 plan = RateNotificationCounterPlan.Compile(ev.ProjectId, rules); + var keys = UpdateRateCountersAction.GetCounterKeys(ev, false, false, plan); + + // 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); + } + + [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 + { + Id = id, + OrganizationId = "organization-1", + ProjectId = "project-1", + UserId = "user-1", + Name = id, + IsEnabled = true, + Signal = signal, + Subject = subject, + StackId = stackId, + Threshold = 1, + Window = TimeSpan.FromMinutes(5), + 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/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 new file mode 100644 index 0000000000..1f268ffd07 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs @@ -0,0 +1,347 @@ +using Exceptionless.Core.Services; +using Exceptionless.Tests.Utility; +using Foundatio.Caching; +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 + { + TimeProvider = timeProvider + }); + var service = new RateCounterService(cache, timeProvider); + 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.AddMinutes(1), 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.AddMinutes(1), ct); + 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() + { + 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.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() + { + 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_AfterClaimingCooldown_ReturnsTrue() + { + var ct = TestContext.Current.CancellationToken; + var (service, _, _) = Create(); + 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 TrySetCooldownAsync_DifferentRules_IndependentCooldowns() + { + var ct = TestContext.Current.CancellationToken; + var (service, _, _) = Create(); + const string ruleId2 = "rule-002"; + + 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); + + 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/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 new file mode 100644 index 0000000000..a42f9c26f7 --- /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": "Errors", + "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}}