diff --git a/k8s/exceptionless/templates/jobs.yaml b/k8s/exceptionless/templates/jobs.yaml index 3a71d1a8ae..2cae96f3be 100644 --- a/k8s/exceptionless/templates/jobs.yaml +++ b/k8s/exceptionless/templates/jobs.yaml @@ -79,6 +79,145 @@ spec: fieldPath: metadata.uid {{- include "exceptionless.otel-env" . | indent 12 }} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ template "exceptionless.fullname" . }}-jobs-rate-notification-evaluator + labels: + app: {{ template "exceptionless.name" . }} + component: {{ template "exceptionless.fullname" . }}-jobs-rate-notification-evaluator + tier: {{ template "exceptionless.fullname" . }}-job + chart: {{ template "exceptionless.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + schedule: "* * * * *" + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + containers: + - name: {{ template "exceptionless.name" . }}-jobs-rate-notification-evaluator + image: "{{ .Values.jobs.image.repository }}:{{ .Values.version }}" + imagePullPolicy: {{ .Values.jobs.image.pullPolicy }} + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + memory: 100Mi + cpu: 50m + limits: + memory: 300Mi + cpu: 500m + args: [RateNotificationEvaluator] + envFrom: + - configMapRef: + name: {{ template "exceptionless.fullname" . }}-config + env: + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.hostIP + - name: K8S_POD_IP + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + - name: K8S_POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid +{{- include "exceptionless.otel-env" . | indent 16 }} + restartPolicy: OnFailure + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "exceptionless.fullname" . }}-jobs-rate-notifications + labels: + app: {{ template "exceptionless.name" . }} + component: {{ template "exceptionless.fullname" . }}-jobs-rate-notifications + tier: {{ template "exceptionless.fullname" . }}-job + chart: {{ template "exceptionless.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + {{- if not (kindIs "invalid" ( .Values.jobs.rateNotifications | default dict ).replicaCount) }} + replicas: {{ .Values.jobs.rateNotifications.replicaCount }} + {{- end }} + selector: + matchLabels: + component: {{ template "exceptionless.fullname" . }}-jobs-rate-notifications + template: + metadata: + labels: + app: {{ template "exceptionless.name" . }} + component: {{ template "exceptionless.fullname" . }}-jobs-rate-notifications + tier: {{ template "exceptionless.fullname" . }}-job + chart: {{ template "exceptionless.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }} + spec: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + component: {{ template "exceptionless.fullname" . }}-jobs-rate-notifications + containers: + - name: {{ template "exceptionless.name" . }}-jobs-rate-notifications + image: "{{ .Values.jobs.image.repository }}:{{ .Values.version }}" + imagePullPolicy: {{ .Values.jobs.image.pullPolicy }} + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 3 + timeoutSeconds: 5 + resources: + requests: + memory: 100Mi + cpu: 100m + limits: + memory: 300Mi + cpu: 500m + args: [RateNotifications] + envFrom: + - configMapRef: + name: {{ template "exceptionless.fullname" . }}-config + env: + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.hostIP + - name: K8S_POD_IP + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + - name: K8S_POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid +{{- include "exceptionless.otel-env" . | indent 12 }} + --- apiVersion: apps/v1 kind: Deployment diff --git a/k8s/exceptionless/values.yaml b/k8s/exceptionless/values.yaml index 9b9febe39b..ddb5f576c7 100644 --- a/k8s/exceptionless/values.yaml +++ b/k8s/exceptionless/values.yaml @@ -18,6 +18,7 @@ jobs: image: repository: exceptionless/job pullPolicy: IfNotPresent + rateNotifications: {} config: {} # key: value 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 efcfae9efb..172bf3f5c6 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -109,8 +109,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(); @@ -124,6 +126,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 { @@ -146,6 +160,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -195,6 +210,10 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO }); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddStartupAction(); services.AddSingleton(); services.AddSingleton(); @@ -290,12 +309,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)); @@ -322,4 +343,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 9ff9a9608d..0b28a19766 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/EventNotificationsJob.cs b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs index 8890398ce2..fa6d496538 100644 --- a/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs @@ -6,7 +6,6 @@ using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; using Exceptionless.Core.Utility; -using Exceptionless.DateTimeExtensions; using Foundatio.Caching; using Foundatio.Jobs; using Foundatio.Queues; @@ -30,6 +29,7 @@ public class EventNotificationsJob : QueueJobBase private readonly ICacheClient _cache; private readonly UserAgentParser _parser; private readonly ITextSerializer _serializer; + private readonly ProjectNotificationThrottleService _projectNotificationThrottle; public EventNotificationsJob(IQueue queue, SlackService slackService, @@ -40,6 +40,7 @@ public EventNotificationsJob(IQueue queue, IUserRepository userRepository, IEventRepository eventRepository, ICacheClient cacheClient, + ProjectNotificationThrottleService projectNotificationThrottle, UserAgentParser parser, ITextSerializer serializer, TimeProvider timeProvider, @@ -54,6 +55,7 @@ public EventNotificationsJob(IQueue queue, _userRepository = userRepository; _eventRepository = eventRepository; _cache = cacheClient; + _projectNotificationThrottle = projectNotificationThrottle; _parser = parser; _serializer = serializer; } @@ -90,10 +92,8 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex return JobResult.Cancelled; // don't send more than 10 notifications for a given project every 30 minutes - var projectTimeWindow = TimeSpan.FromMinutes(30); - string cacheKey = String.Concat("notify:project-throttle:", ev.ProjectId, "-", _timeProvider.GetUtcNow().UtcDateTime.Floor(projectTimeWindow).Ticks); - double notificationCount = await _cache.IncrementAsync(cacheKey, 1, projectTimeWindow); - if (notificationCount > 10 && !wi.IsRegression) + long notificationCount = await _projectNotificationThrottle.IncrementAsync(ev.ProjectId, context.CancellationToken); + if (notificationCount > ProjectNotificationThrottleService.NotificationLimit && !wi.IsRegression) { if (shouldLog) _logger.LogInformation("Skipping message because of project throttling: count={NotificationCount}", notificationCount); return JobResult.Success; diff --git a/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs new file mode 100644 index 0000000000..851f3ab5e4 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/RateNotificationEvaluatorJob.cs @@ -0,0 +1,270 @@ +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.Models; +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); + + if (await _counterService.IsOnCooldownAsync(rule.Id, subjectKey, ct)) + { + _logger.LogDebug("Rule {RuleId} is on cooldown for subject {SubjectKey}", rule.Id, subjectKey); + return; + } + + // Use a short-lived claim while enqueueing. The full cooldown starts only after the + // queue accepts the notification, so a process crash cannot silence the rule for hours. + if (!await _counterService.TryAcquireEvaluationClaimAsync(rule.Id, subjectKey, ct)) + 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 + }); + await _counterService.SetCooldownAsync(rule.Id, subjectKey, rule.Cooldown, ct); + AppDiagnostics.RateNotificationsEnqueued.Add(1); + } + finally + { + await _counterService.RemoveEvaluationClaimAsync(rule.Id, subjectKey, ct); + } + + // LastFiredUtc is evaluator-owned bookkeeping. Patch only that field so a concurrent + // user edit, disable, or snooze cannot be overwritten by this previously loaded snapshot. + await _ruleRepository.PatchAsync( + rule.Id, + new PartialPatch(new { last_fired_utc = evaluationEndUtc }), + 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..bc3256bae8 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/RateNotificationsJob.cs @@ -0,0 +1,178 @@ +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; + private readonly ProjectNotificationThrottleService _projectNotificationThrottle; + + public RateNotificationsJob( + IQueue queue, + IMailer mailer, + IRateNotificationRuleRepository ruleRepository, + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IUserRepository userRepository, + IStackRepository stackRepository, + ProjectNotificationThrottleService projectNotificationThrottle, + TimeProvider timeProvider, + IResiliencePolicyProvider resiliencePolicyProvider, + ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) + { + _mailer = mailer; + _ruleRepository = ruleRepository; + _organizationRepository = organizationRepository; + _projectRepository = projectRepository; + _userRepository = userRepository; + _stackRepository = stackRepository; + _projectNotificationThrottle = projectNotificationThrottle; + } + + 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 (rule.SnoozedUntilUtc > _timeProvider.GetUtcNow().UtcDateTime) + return Skip($"Rule {wi.RuleId} is snoozed; 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."); + } + } + + long notificationCount = await _projectNotificationThrottle.IncrementAsync(project.Id, context.CancellationToken); + if (notificationCount > ProjectNotificationThrottleService.NotificationLimit) + { + _logger.LogInformation("Skipping rate notification because of project throttling: project={ProjectId} count={NotificationCount}", project.Id, notificationCount); + return Skip($"Project {project.Id} exceeded its notification budget; 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..f0d0666ca4 --- /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; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = null!; + + public bool IsEnabled { get; set; } + + [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; } + + public TimeSpan Window { get; set; } + + public TimeSpan Cooldown { get; set; } + + 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/ProjectNotificationThrottleService.cs b/src/Exceptionless.Core/Services/ProjectNotificationThrottleService.cs new file mode 100644 index 0000000000..1639cc60bb --- /dev/null +++ b/src/Exceptionless.Core/Services/ProjectNotificationThrottleService.cs @@ -0,0 +1,29 @@ +using Exceptionless.DateTimeExtensions; +using Foundatio.Caching; + +namespace Exceptionless.Core.Services; + +/// Shares the project-wide email budget across event and rate notifications. +public sealed class ProjectNotificationThrottleService +{ + public const int NotificationLimit = 10; + public static readonly TimeSpan TimeWindow = TimeSpan.FromMinutes(30); + + private readonly ICacheClient _cache; + private readonly TimeProvider _timeProvider; + + public ProjectNotificationThrottleService(ICacheClient cache, TimeProvider timeProvider) + { + _cache = cache; + _timeProvider = timeProvider; + } + + public Task IncrementAsync(string projectId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + cancellationToken.ThrowIfCancellationRequested(); + + string cacheKey = $"notify:project-throttle:{projectId}-{_timeProvider.GetUtcNow().UtcDateTime.Floor(TimeWindow).Ticks}"; + return _cache.IncrementAsync(cacheKey, 1, TimeWindow); + } +} diff --git a/src/Exceptionless.Core/Services/RateCounterService.cs b/src/Exceptionless.Core/Services/RateCounterService.cs new file mode 100644 index 0000000000..48d9cc489a --- /dev/null +++ b/src/Exceptionless.Core/Services/RateCounterService.cs @@ -0,0 +1,153 @@ +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 static readonly TimeSpan EvaluationClaimTtl = TimeSpan.FromMinutes(2); + 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); + } + + /// Claims an evaluation briefly so a crash before enqueue cannot consume the full cooldown. + public Task TryAcquireEvaluationClaimAsync(string ruleId, string subjectKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return _cache.AddAsync(GetEvaluationClaimKey(ruleId, subjectKey), true, EvaluationClaimTtl); + } + + /// Starts the configured cooldown after the notification has been durably enqueued. + public Task SetCooldownAsync(string ruleId, string subjectKey, TimeSpan duration, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return _cache.SetAsync(GetCooldownKey(ruleId, subjectKey), true, duration); + } + + public Task RemoveEvaluationClaimAsync(string ruleId, string subjectKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return _cache.RemoveAsync(GetEvaluationClaimKey(ruleId, subjectKey)); + } + + 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}"; + + private static string GetEvaluationClaimKey(string ruleId, string subjectKey) + => $"rate:v1:evaluation-claim:{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 2c04155463..2d8ad033eb 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/Api/ApiEndpoints.cs b/src/Exceptionless.Web/Api/ApiEndpoints.cs index a804c7dd7d..53ee864ff7 100644 --- a/src/Exceptionless.Web/Api/ApiEndpoints.cs +++ b/src/Exceptionless.Web/Api/ApiEndpoints.cs @@ -23,6 +23,7 @@ public static WebApplication MapApiEndpoints(this WebApplication app) app.MapOAuthApplicationEndpoints(); app.MapOAuthEndpoints(); app.MapEventEndpoints(); + app.MapRateNotificationEndpoints(); app.MapMediatorEndpoints(); return app; diff --git a/src/Exceptionless.Web/Api/Endpoints/RateNotificationEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/RateNotificationEndpoints.cs new file mode 100644 index 0000000000..16e6114fe0 --- /dev/null +++ b/src/Exceptionless.Web/Api/Endpoints/RateNotificationEndpoints.cs @@ -0,0 +1,89 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Web.Api.Filters; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility.OpenApi; +using Foundatio.Mediator; +using Microsoft.AspNetCore.Mvc; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; +using RateNotificationMessages = Exceptionless.Web.Api.Messages; + +namespace Exceptionless.Web.Api.Endpoints; + +public static class RateNotificationEndpoints +{ + public static IEndpointRouteBuilder MapRateNotificationEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications") + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .AddEndpointFilter() + .WithTags("RateNotification"); + + group.MapGet("", async (string userId, string projectId, HttpContext context, IMediator mediator, IMediatorResultMapper resultMapper, int page = 1, int limit = 25) + => (await mediator.InvokeAsync>>(new RateNotificationMessages.GetRateNotifications(userId, projectId, page, limit, context))).ToHttpResult(resultMapper)) + .Produces>() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get all") + .WithMetadata(new EndpointDocumentation + { + ParameterDescriptions = new() + { + ["userId"] = "The identifier of the user.", + ["projectId"] = "The identifier of the project.", + ["page"] = "The page number.", + ["limit"] = "The maximum number of rules to return." + } + }); + + group.MapPost("", async (string userId, string projectId, HttpContext context, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] NewRateNotificationRule rule) + => (await mediator.InvokeAsync>(new RateNotificationMessages.CreateRateNotification(userId, projectId, rule, context))).ToHttpResult(resultMapper)) + .Accepts("application/json", "application/*+json") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Create"); + + group.MapGet("{ruleId:objectid}", async (string userId, string projectId, string ruleId, HttpContext context, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new RateNotificationMessages.GetRateNotificationById(userId, projectId, ruleId, context))).ToHttpResult(resultMapper)) + .WithName("GetRateNotificationRuleById") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get by id"); + + group.MapPut("{ruleId:objectid}", async (string userId, string projectId, string ruleId, HttpContext context, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateRateNotificationRule rule) + => (await mediator.InvokeAsync>(new RateNotificationMessages.UpdateRateNotification(userId, projectId, ruleId, rule, context))).ToHttpResult(resultMapper)) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update"); + + group.MapDelete("{ruleId:objectid}", async (string userId, string projectId, string ruleId, HttpContext context, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync(new RateNotificationMessages.DeleteRateNotification(userId, projectId, ruleId, context))).ToHttpResult(resultMapper)) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithSummary("Remove"); + + group.MapPost("{ruleId:objectid}/snooze", async (string userId, string projectId, string ruleId, HttpContext context, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] SnoozeRateNotificationRuleRequest request) + => (await mediator.InvokeAsync>(new RateNotificationMessages.SnoozeRateNotification(userId, projectId, ruleId, request, context))).ToHttpResult(resultMapper)) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Snooze"); + + group.MapPost("{ruleId:objectid}/unsnooze", async (string userId, string projectId, string ruleId, HttpContext context, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new RateNotificationMessages.UnsnoozeRateNotification(userId, projectId, ruleId, context))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .WithSummary("Unsnooze"); + + return endpoints; + } +} diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 9c910c721c..092d43263f 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -620,6 +620,7 @@ public async Task Handle(RemoveOrganizationUser message) 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/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index d07f40001a..7d22e3f8d0 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -37,6 +37,7 @@ public class ProjectHandler( SlackService slackService, IOAuthProviderClient oauthProviderClient, SampleDataService sampleDataService, + OrganizationService organizationService, ApiMapper mapper, ITextSerializer serializer, AppOptions options, @@ -511,6 +512,7 @@ private async Task AfterResultMapAsync(ICollection m 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); @@ -592,6 +594,7 @@ private async Task> DeleteModelsAsync(ICollection p using var _ = _logger.BeginScope(new ExceptionlessState().Organization(project.OrganizationId).Project(project.Id).Tag("Delete").Identity(user.EmailAddress).Property("User", user).SetHttpContext(httpContext)); _logger.UserDeletingProject(user.Id, project.Name); await tokenRepository.RemoveAllByProjectIdAsync(project.OrganizationId, project.Id); + await organizationService.RemoveProjectRateNotificationRulesAsync(project.OrganizationId, project.Id); } foreach (var project in projects.OfType()) diff --git a/src/Exceptionless.Web/Api/Handlers/RateNotificationHandler.cs b/src/Exceptionless.Web/Api/Handlers/RateNotificationHandler.cs new file mode 100644 index 0000000000..6e6a9f74ff --- /dev/null +++ b/src/Exceptionless.Web/Api/Handlers/RateNotificationHandler.cs @@ -0,0 +1,290 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Web.Api.Infrastructure; +using Exceptionless.Web.Api.Messages; +using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Foundatio.Lock; +using Foundatio.Mediator; +using Foundatio.Repositories; + +namespace Exceptionless.Web.Api.Handlers; + +public class RateNotificationHandler( + IRateNotificationRuleRepository ruleRepository, + IProjectRepository projectRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IStackRepository stackRepository, + ILockProvider lockProvider, + TimeProvider timeProvider) +{ + 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) + ]; + + public async Task>> Handle(GetRateNotifications message) + { + var project = await GetProjectAndCheckAccessAsync(message.ProjectId, message.UserId, message.Context); + if (project is null) + return Result.NotFound("Rate notification rules not found."); + + int page = Pagination.GetPage(message.Page); + int limit = Pagination.GetLimit(message.Limit); + var results = await ruleRepository.GetByProjectIdAndUserIdAsync(message.ProjectId, message.UserId, o => o.PageNumber(page).PageLimit(limit)); + return new PagedResult(results.Documents.Select(MapToView).ToList(), results.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, results.Total); + } + + public async Task> Handle(CreateRateNotification message) + { + var project = await GetProjectAndCheckAccessAsync(message.ProjectId, message.UserId, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + var model = message.Rule; + var validation = await ValidateRuleAsync(model.Name, model.Signal, model.Subject, model.StackId, model.Window, model.Cooldown, message.ProjectId, project.OrganizationId); + if (validation is not null) + return Result.FromResult(validation); + + 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:{message.ProjectId}:{message.UserId}", TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(5)); + if (createLock is null) + return Result.Conflict("Another rate notification rule is being created. Please retry."); + + long count = await ruleRepository.CountByProjectIdAndUserIdAsync(message.ProjectId, message.UserId); + if (count >= MaxRulesPerUserPerProject) + return Invalid("general", $"Maximum of {MaxRulesPerUserPerProject} rate notification rules per user per project."); + + DateTime now = timeProvider.GetUtcNow().UtcDateTime; + var rule = await ruleRepository.AddAsync(new RateNotificationRule + { + OrganizationId = project.OrganizationId, + ProjectId = message.ProjectId, + UserId = message.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 + }, o => o.Cache().ImmediateConsistency()); + + string location = $"/api/v2/users/{message.UserId}/projects/{message.ProjectId}/rate-notifications/{rule.Id}"; + return Result.Created(MapToView(rule), location); + } + + public async Task> Handle(GetRateNotificationById message) + { + var rule = await GetRuleAndCheckAccessAsync(message.UserId, message.ProjectId, message.RuleId, message.Context); + return rule is null ? Result.NotFound("Rate notification rule not found.") : MapToView(rule); + } + + public async Task> Handle(UpdateRateNotification message) + { + var project = await GetProjectAndCheckAccessAsync(message.ProjectId, message.UserId, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + await using var mutationLock = await TryAcquireRuleMutationLockAsync(message.RuleId); + if (mutationLock is null) + return Result.Conflict("Another update to this rate notification rule is in progress. Please retry."); + + var rule = await GetRuleAndCheckAccessAsync(message.UserId, project, message.RuleId); + if (rule is null) + return Result.NotFound("Rate notification rule not found."); + + var model = message.Rule; + string name = model.Name ?? rule.Name; + var signal = model.Signal ?? rule.Signal; + var subject = model.Subject ?? rule.Subject; + string? stackId = subject == RateNotificationSubject.Stack ? model.StackId ?? rule.StackId : null; + TimeSpan window = model.Window ?? rule.Window; + TimeSpan cooldown = model.Cooldown ?? rule.Cooldown; + var validation = await ValidateRuleAsync(name, signal, subject, stackId, window, cooldown, message.ProjectId, project.OrganizationId); + if (validation is not null) + return Result.FromResult(validation); + + rule.Name = name.Trim(); + rule.Signal = signal; + rule.Subject = subject; + rule.StackId = subject == RateNotificationSubject.Stack ? stackId : null; + rule.Threshold = model.Threshold ?? rule.Threshold; + rule.Window = window; + rule.Cooldown = cooldown; + 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 MapToView(rule); + } + + public async Task Handle(DeleteRateNotification message) + { + var project = await GetProjectAndCheckAccessAsync(message.ProjectId, message.UserId, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + await using var mutationLock = await TryAcquireRuleMutationLockAsync(message.RuleId); + if (mutationLock is null) + return Result.Conflict("Another update to this rate notification rule is in progress. Please retry."); + + var rule = await GetRuleAndCheckAccessAsync(message.UserId, project, message.RuleId); + if (rule is null) + return Result.NotFound("Rate notification rule not found."); + + await ruleRepository.RemoveAsync(rule, o => o.ImmediateConsistency()); + return Result.NoContent(); + } + + public async Task> Handle(SnoozeRateNotification message) + { + var project = await GetProjectAndCheckAccessAsync(message.ProjectId, message.UserId, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + await using var mutationLock = await TryAcquireRuleMutationLockAsync(message.RuleId); + if (mutationLock is null) + return Result.Conflict("Another update to this rate notification rule is in progress. Please retry."); + + var rule = await GetRuleAndCheckAccessAsync(message.UserId, project, message.RuleId); + if (rule is null) + return Result.NotFound("Rate notification rule not found."); + + DateTime now = timeProvider.GetUtcNow().UtcDateTime; + if (message.Request.UntilUtc.HasValue == message.Request.DurationSeconds.HasValue) + return Invalid("general", "Either DurationSeconds or UntilUtc must be provided."); + + DateTime snoozedUntilUtc = message.Request.UntilUtc ?? now.AddSeconds(message.Request.DurationSeconds!.Value); + if (snoozedUntilUtc <= now) + return Invalid("general", "Snooze expiration must be in the future."); + + rule.SnoozedUntilUtc = snoozedUntilUtc; + await SaveMutationAsync(rule, now); + return MapToView(rule); + } + + public async Task> Handle(UnsnoozeRateNotification message) + { + var project = await GetProjectAndCheckAccessAsync(message.ProjectId, message.UserId, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + await using var mutationLock = await TryAcquireRuleMutationLockAsync(message.RuleId); + if (mutationLock is null) + return Result.Conflict("Another update to this rate notification rule is in progress. Please retry."); + + var rule = await GetRuleAndCheckAccessAsync(message.UserId, project, message.RuleId); + if (rule is null) + return Result.NotFound("Rate notification rule not found."); + + DateTime now = timeProvider.GetUtcNow().UtcDateTime; + rule.SnoozedUntilUtc = now; + await SaveMutationAsync(rule, now); + return MapToView(rule); + } + + private async Task ValidateRuleAsync(string name, RateNotificationSignal signal, RateNotificationSubject subject, string? stackId, TimeSpan window, TimeSpan cooldown, string projectId, string organizationId) + { + if (String.IsNullOrWhiteSpace(name)) + return Result.Invalid(ValidationError.Create("name", "Name is required.")); + if (!Enum.IsDefined(signal)) + return Result.Invalid(ValidationError.Create("signal", "Signal is invalid.")); + if (!Enum.IsDefined(subject)) + return Result.Invalid(ValidationError.Create("subject", "Subject is invalid.")); + if (!ValidWindows.Contains(window)) + return Result.Invalid(ValidationError.Create("window", $"Window must be one of: {String.Join(", ", ValidWindows.Select(value => value.ToString()))}")); + if (cooldown < window) + return Result.Invalid(ValidationError.Create("cooldown", "Cooldown must be greater than or equal to Window.")); + if (cooldown > RateNotificationRule.MaximumCooldown) + return Result.Invalid(ValidationError.Create("cooldown", "Cooldown must not exceed 24 hours.")); + if (subject == RateNotificationSubject.Project) + return String.IsNullOrEmpty(stackId) ? null : Result.Invalid(ValidationError.Create("stack_id", "StackId must be empty when Subject is Project.")); + if (String.IsNullOrEmpty(stackId)) + return Result.Invalid(ValidationError.Create("stack_id", "StackId is required when Subject is Stack.")); + + var stack = await stackRepository.GetByIdAsync(stackId, o => o.Cache()); + return stack is null || !String.Equals(stack.ProjectId, projectId, StringComparison.Ordinal) || !String.Equals(stack.OrganizationId, organizationId, StringComparison.Ordinal) + ? Result.Invalid(ValidationError.Create("stack_id", "The specified StackId does not belong to this project.")) + : null; + } + + private async Task GetProjectAndCheckAccessAsync(string projectId, string userId, HttpContext context) + { + if (!CanManage(userId, context)) + return null; + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache()); + if (project is null || !context.Request.CanAccessOrganization(project.OrganizationId)) + return null; + var user = await userRepository.GetByIdAsync(userId, o => o.Cache()); + return user?.OrganizationIds.Contains(project.OrganizationId) == true ? project : null; + } + + private async Task GetRuleAndCheckAccessAsync(string userId, string projectId, string ruleId, HttpContext context) + { + var project = await GetProjectAndCheckAccessAsync(projectId, userId, context); + return project is null ? null : await GetRuleAndCheckAccessAsync(userId, project, ruleId); + } + + private async Task GetRuleAndCheckAccessAsync(string userId, Project project, string ruleId) + { + var rule = await ruleRepository.GetByIdAsync(ruleId); + return rule is not null && String.Equals(rule.UserId, userId, StringComparison.Ordinal) && String.Equals(rule.ProjectId, project.Id, StringComparison.Ordinal) && String.Equals(rule.OrganizationId, project.OrganizationId, StringComparison.Ordinal) ? rule : null; + } + + private static bool CanManage(string userId, HttpContext context) => String.Equals(context.Request.GetUser().Id, userId, StringComparison.Ordinal) || context.Request.IsGlobalAdmin(); + private Task TryAcquireRuleMutationLockAsync(string ruleId) => lockProvider.TryAcquireAsync($"rate-notification:mutation:{ruleId}", TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(5)); + + private Task SaveMutationAsync(RateNotificationRule rule, DateTime now) + { + rule.Version++; + rule.UpdatedUtc = now; + return ruleRepository.SaveAsync(rule, o => o.Cache().ImmediateConsistency()); + } + + private ViewRateNotificationRule MapToView(RateNotificationRule rule) => new() + { + 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 > timeProvider.GetUtcNow().UtcDateTime, + LastFiredUtc = rule.LastFiredUtc, + CreatedUtc = rule.CreatedUtc, + UpdatedUtc = rule.UpdatedUtc + }; + + private static Result Invalid(string identifier, string message) => Result.FromResult(Result.Invalid(ValidationError.Create(identifier, message))); +} diff --git a/src/Exceptionless.Web/Api/Messages/RateNotificationMessages.cs b/src/Exceptionless.Web/Api/Messages/RateNotificationMessages.cs new file mode 100644 index 0000000000..57991e478c --- /dev/null +++ b/src/Exceptionless.Web/Api/Messages/RateNotificationMessages.cs @@ -0,0 +1,11 @@ +using Exceptionless.Web.Models; + +namespace Exceptionless.Web.Api.Messages; + +public record GetRateNotifications(string UserId, string ProjectId, int Page, int Limit, HttpContext Context); +public record CreateRateNotification(string UserId, string ProjectId, NewRateNotificationRule Rule, HttpContext Context); +public record GetRateNotificationById(string UserId, string ProjectId, string RuleId, HttpContext Context); +public record UpdateRateNotification(string UserId, string ProjectId, string RuleId, UpdateRateNotificationRule Rule, HttpContext Context); +public record DeleteRateNotification(string UserId, string ProjectId, string RuleId, HttpContext Context); +public record SnoozeRateNotification(string UserId, string ProjectId, string RuleId, SnoozeRateNotificationRuleRequest Request, HttpContext Context); +public record UnsnoozeRateNotification(string UserId, string ProjectId, string RuleId, HttpContext Context); diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index e9f20ea5bc..0ae669abbd 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 fa8bb3b386..dace0d253a 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -109,6 +109,7 @@ }, "type": "module", "overrides": { + "cookie": "0.7.0", "storybook": "$storybook" } } 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..c601399fe1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.svelte.ts @@ -0,0 +1,234 @@ +import { accessToken } from '$features/auth/index.svelte'; +import { + type NewRateNotificationRule, + RateNotificationSubject, + type SnoozeRateNotificationRuleRequest, + type UpdateRateNotificationRule, + type ViewRateNotificationRule +} from '$features/rate-notifications/types'; +import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createMutation, createQuery, type QueryClient, type QueryKey, useQueryClient } from '@tanstack/svelte-query'; + +const CONSISTENCY_REFRESH_DELAY_MS = 1500; + +interface BodyMutationVariables extends MutationRoute { + body: TBody; +} + +interface MutationRoute { + projectId: string; + userId: string; +} + +interface RateNotificationMutationContext { + previousRules: [QueryKey, FetchClientResponse | undefined][]; +} + +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, RateNotificationMutationContext>( + () => ({ + mutationFn: async ({ body, ruleId, ...route }) => { + const client = useFetchClient(); + const response = await client.putJSON(ruleRoute(route, ruleId), body); + return response.data!; + }, + onError: (_error, _variables, context) => { + for (const [queryKey, response] of context?.previousRules ?? []) { + queryClient.setQueryData(queryKey, response); + } + }, + onMutate: async (variables) => { + const queryKey = queryKeys.list(variables.userId, variables.projectId); + await queryClient.cancelQueries({ queryKey }); + const previousRules = queryClient.getQueriesData>({ queryKey }); + updateRulesCache(queryClient, variables, (rules) => + rules.map((rule) => (rule.id === variables.ruleId ? applyOptimisticUpdate(rule, variables.body) : rule)) + ); + return { previousRules }; + }, + onSettled: (_data, _error, variables) => { + scheduleConsistencyRefresh(queryClient, variables); + }, + onSuccess: (rule, variables) => { + updateRulesCache(queryClient, variables, (rules) => upsertRule(rules, rule)); + } + }) + ); +} + +function applyOptimisticUpdate(rule: ViewRateNotificationRule, body: UpdateRateNotificationRule): ViewRateNotificationRule { + const updated = { ...rule }; + + if (body.name != null) { + updated.name = body.name; + } + + if (body.signal != null) { + updated.signal = body.signal; + } + + if (body.subject != null) { + updated.subject = body.subject; + } + + if (body.stack_id != null) { + updated.stack_id = body.stack_id; + } + + if (body.threshold != null) { + updated.threshold = body.threshold; + } + + if (body.window != null) { + updated.window = body.window; + } + + if (body.cooldown != null) { + updated.cooldown = body.cooldown; + } + + if (body.is_enabled != null) { + updated.is_enabled = body.is_enabled; + } + + if (updated.subject === RateNotificationSubject.Project) { + updated.stack_id = null; + } + + return updated; +} + +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..8679c567fb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/rate-notifications/api.test.ts @@ -0,0 +1,217 @@ +import type { ViewRateNotificationRule } from '$generated/api'; +import type { FetchClientResponse } from '@exceptionless/fetchclient'; + +import { RateNotificationSignal, RateNotificationSubject } from '$generated/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + cancelQueries: vi.fn(), + client: { + delete: vi.fn(), + getJSON: vi.fn(), + postJSON: vi.fn(), + putJSON: vi.fn() + }, + getQueriesData: vi.fn(), + invalidateQueries: vi.fn(), + setQueriesData: vi.fn(), + setQueryData: 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: () => ({ + cancelQueries: mocks.cancelQueries, + getQueriesData: mocks.getQueriesData, + invalidateQueries: mocks.invalidateQueries, + setQueriesData: mocks.setQueriesData, + setQueryData: mocks.setQueryData + }) +})); + +import { + deleteRateNotificationRule, + getRateNotificationRulesQuery, + postRateNotificationRule, + postSnoozeRateNotificationRule, + postUnsnoozeRateNotificationRule, + putRateNotificationRule +} from './api.svelte'; + +interface Mutation { + mutationFn: (variables: TVariables) => Promise; + onError?: (error: unknown, variables: TVariables, context: unknown) => void; + onMutate?: (variables: TVariables) => Promise; + onSettled?: (data: TData | undefined, error: unknown, variables: TVariables) => void; + 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' } }); + mocks.cancelQueries.mockResolvedValue(undefined); + mocks.getQueriesData.mockReturnValue([]); + }); + + 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] }); + }); + + it('rolls back an optimistic rule update when the request fails', async () => { + // Arrange + const route = { projectId: 'project-id', userId: 'user-id' }; + const variables = { ...route, body: { is_enabled: false }, ruleId: 'rule-id' }; + const queryKey = ['RateNotificationRule', route.userId, route.projectId, { params: undefined }] as const; + const previousResponse = { data: [{ id: variables.ruleId, is_enabled: true }] } as FetchClientResponse; + mocks.getQueriesData.mockReturnValue([[queryKey, previousResponse]]); + const mutation = putRateNotificationRule() as unknown as Mutation; + + // Act + const context = await mutation.onMutate?.(variables); + mutation.onError?.(new Error('request failed'), variables, context); + + // Assert + expect(mocks.cancelQueries).toHaveBeenCalledWith({ queryKey: ['RateNotificationRule', route.userId, route.projectId] }); + expect(mocks.setQueriesData).toHaveBeenCalledOnce(); + expect(mocks.setQueryData).toHaveBeenCalledWith(queryKey, previousResponse); + }); +}); 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 3ec0186c02..2c52bf6f7d 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; @@ -396,6 +428,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. @@ -493,6 +540,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; @@ -708,11 +774,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 c2cd06eb56..4e09765e4b 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") @@ -516,6 +556,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") @@ -585,6 +637,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(), @@ -788,12 +880,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)/account/verify/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/verify/+page.svelte index 2c642051e6..e44ce9babb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/verify/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/verify/+page.svelte @@ -11,9 +11,7 @@ async function verifyAccount() { if (token) { try { - await client.post('/users/verify-email-address', undefined, { - params: { token } - }); + await client.getJSON(`users/verify-email-address/${encodeURIComponent(token)}`); toast.success('Your account has been successfully verified.'); } catch { toast.error('An error occurred while verifying your account.'); 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..38932b4810 --- /dev/null +++ b/src/Exceptionless.Web/Models/RateNotification/RateNotificationRuleModels.cs @@ -0,0 +1,89 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models; + +namespace Exceptionless.Web.Models; + +public record NewRateNotificationRule +{ + [Required] + [MaxLength(100)] + public string Name { get; init; } = null!; + + [JsonRequired] + public RateNotificationSignal Signal { get; init; } + + [JsonRequired] + public RateNotificationSubject Subject { get; init; } + + [ObjectId] + public string? StackId { get; init; } + + [Range(1, int.MaxValue)] + [JsonRequired] + public int Threshold { get; init; } + + [JsonRequired] + public TimeSpan Window { get; init; } + + [JsonRequired] + public TimeSpan Cooldown { get; init; } + + [JsonRequired] + 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/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index 65f2578aba..bf9e2bc903 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -2780,6 +2780,104 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "GET", + "route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/", + "displayName": "HTTP: GET api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/", + "tags": [ + "RateNotification" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/", + "displayName": "HTTP: POST api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/", + "tags": [ + "RateNotification" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "DELETE", + "route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "displayName": "HTTP: DELETE api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "tags": [ + "RateNotification" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "displayName": "HTTP: GET api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "tags": [ + "RateNotification" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "displayName": "HTTP: PUT api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}", + "tags": [ + "RateNotification" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}/snooze", + "displayName": "HTTP: POST api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}/snooze", + "tags": [ + "RateNotification" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}/unsnooze", + "displayName": "HTTP: POST api/v2/users/{userId:objectid}/projects/{projectId:objectid}/rate-notifications/{ruleId:objectid}/unsnooze", + "tags": [ + "RateNotification" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "POST", "route": "/api/v2/webhooks", diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 68ec2364ce..e1d291eee4 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -10741,6 +10741,539 @@ } } } + }, + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications": { + "get": { + "tags": [ + "RateNotification" + ], + "summary": "Get all", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "The identifier of the user.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "projectId", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page number.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "The maximum number of rules to return.", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "tags": [ + "RateNotification" + ], + "summary": "Create", + "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/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/NewRateNotificationRule" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}": { + "get": { + "tags": [ + "RateNotification" + ], + "summary": "Get by id", + "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" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "RateNotification" + ], + "summary": "Update", + "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/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateRateNotificationRule" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "RateNotification" + ], + "summary": "Remove", + "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": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}/snooze": { + "post": { + "tags": [ + "RateNotification" + ], + "summary": "Snooze", + "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/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/SnoozeRateNotificationRuleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewRateNotificationRule" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/users/{userId}/projects/{projectId}/rate-notifications/{ruleId}/unsnooze": { + "post": { + "tags": [ + "RateNotification" + ], + "summary": "Unsnooze", + "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" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } } }, "components": { @@ -11144,6 +11677,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\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "cooldown": { + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "is_enabled": { + "type": "boolean" + } + } + }, "NewSavedView": { "required": [ "name", @@ -12075,6 +12658,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", @@ -12124,6 +12733,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", @@ -12375,6 +13007,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\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "cooldown": { + "pattern": "^-?(\\d\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "is_enabled": { + "type": [ + "null", + "boolean" + ] + } + } + }, "UpdateSavedView": { "type": "object", "properties": { @@ -13083,6 +13785,7 @@ "stack_count", "event_count", "has_premium_features", + "has_rate_notifications", "has_slack_integration", "usage_hours", "usage" @@ -13144,6 +13847,9 @@ "has_premium_features": { "type": "boolean" }, + "has_rate_notifications": { + "type": "boolean" + }, "has_slack_integration": { "type": "boolean" }, @@ -13161,6 +13867,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\u002B\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "cooldown": { + "pattern": "^-?(\\d\u002B\\.)?\\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", @@ -13554,6 +14353,9 @@ }, { "name": "Stack" + }, + { + "name": "RateNotification" } ] } \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs index 21989facec..11492b5272 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ContactEndpointTests.cs @@ -191,6 +191,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/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 4fddfae77a..025a5fd95b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -5,6 +5,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; @@ -23,6 +24,7 @@ public sealed class OrganizationEndpointTests : 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; @@ -32,6 +34,7 @@ public OrganizationEndpointTests(ITestOutputHelper output, AppWebHostFactory fac { _organizationRepository = GetService(); _projectRepository = GetService(); + _rateNotificationRuleRepository = GetService(); _userRepository = GetService(); _billingManager = GetService(); _plans = GetService(); @@ -1008,6 +1011,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 @@ -1027,6 +1048,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/Api/Endpoints/ProjectEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.cs index 00516ae49d..8d4cdeaa2b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.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 ProjectEndpointTests : 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 ProjectEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _eventRepository = GetService(); _organizationRepository = GetService(); _projectRepository = GetService(); + _rateNotificationRuleRepository = GetService(); _stackRepository = GetService(); + _userRepository = GetService(); } protected override async Task ResetDataAsync() @@ -131,6 +136,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 @@ -149,6 +175,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] @@ -344,6 +372,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/Api/Endpoints/RateNotificationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/RateNotificationEndpointTests.cs new file mode 100644 index 0000000000..499ee2eefd --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/RateNotificationEndpointTests.cs @@ -0,0 +1,826 @@ +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.Api.Endpoints; + +public sealed class RateNotificationEndpointTests : IntegrationTestsBase +{ + private readonly IRateNotificationRuleRepository _ruleRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IUserRepository _userRepository; + + public RateNotificationEndpointTests(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_MissingRequiredField_ReturnsBadRequest() + { + var user = await GetTestOrganizationUserAsync(); + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new + { + name = "Missing enabled state", + signal = RateNotificationSignal.Errors, + subject = RateNotificationSubject.Project, + threshold = 10, + window = TimeSpan.FromMinutes(5), + cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeBadRequest()); + } + + [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 PutAsync_ConcurrentUpdates_PreservesBothChanges() + { + var user = await GetTestOrganizationUserAsync(); + var created = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID)) + .Content(new NewRateNotificationRule + { + Name = "Concurrent update test", + Signal = RateNotificationSignal.Errors, + Subject = RateNotificationSubject.Project, + Threshold = 5, + Window = TimeSpan.FromMinutes(5), + Cooldown = TimeSpan.FromMinutes(30) + }) + .StatusCodeShouldBeCreated()); + + Assert.NotNull(created); + + Task UpdateAsync(object model) => SendRequestAsync(r => r + .Put() + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)) + .Content(model)); + + var responses = await Task.WhenAll( + UpdateAsync(new { Name = "Concurrent rename" }), + UpdateAsync(new { Threshold = 6 })); + + Assert.All(responses, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode)); + + var updated = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPath(RuleUrl(user.Id, SampleDataService.TEST_PROJECT_ID, created.Id)) + .StatusCodeShouldBeOk()); + + Assert.NotNull(updated); + Assert.Equal("Concurrent rename", updated.Name); + Assert.Equal(6, updated.Threshold); + Assert.Equal(created.Version + 2, updated.Version); + } + + [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/Api/Handlers/ContactHandlerTests.cs b/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs index 0b15a79b36..95ce54c55f 100644 --- a/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs +++ b/tests/Exceptionless.Tests/Api/Handlers/ContactHandlerTests.cs @@ -127,6 +127,11 @@ public Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable return Task.CompletedTask; } + public Task SendRateNotificationAsync(User user, Project project, RateNotificationRule rule, long observedCount, DateTime windowStartUtc, DateTime windowEndUtc, Stack? stack) + { + return Task.CompletedTask; + } + public Task SendUserEmailVerifyAsync(User user) { return Task.CompletedTask; diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index fa1d2f73ee..6c7f1359c6 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -24,8 +24,11 @@ public class AppWebHostFactory : WebApplicationFactory> 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() @@ -34,12 +37,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() { @@ -170,14 +184,14 @@ private static IFileStorage CreateScopedFileStorage(IServiceProvider serviceProv : storage; } - 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/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..d62f631be8 100644 --- a/tests/Exceptionless.Tests/IntegrationTestsBase.cs +++ b/tests/Exceptionless.Tests/IntegrationTestsBase.cs @@ -39,6 +39,7 @@ public abstract class IntegrationTestsBase : TestWithLoggingBase, Xunit.IAsyncLi protected readonly TestServer _server; private readonly ProxyTimeProvider _timeProvider; protected readonly IList _disposables = new List(); + private bool _dataResetLockHeld; public IntegrationTestsBase(ITestOutputHelper output, AppWebHostFactory factory) : base(output) { @@ -86,7 +87,18 @@ public override async ValueTask InitializeAsync() Log.SetLogLevel("Microsoft.AspNetCore.Hosting.Internal.WebHost", LogLevel.Information); Log.SetLogLevel("Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService", LogLevel.Information); - await ResetDataAsync(); + await _factory.DataResetLock.WaitAsync(TestContext.Current.CancellationToken); + _dataResetLockHeld = true; + + try + { + await ResetDataAsync(); + } + catch + { + ReleaseDataResetLock(); + throw; + } } protected ProxyTimeProvider TimeProvider => _timeProvider; @@ -188,6 +200,7 @@ 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(); } @@ -279,20 +292,36 @@ protected Task SendGlobalAdminRequestAsync(Action(); } - public override ValueTask DisposeAsync() + public override async ValueTask DisposeAsync() { - foreach (var disposable in _disposables) + try { - try - { - disposable.Dispose(); - } - catch (Exception ex) + foreach (var disposable in _disposables) { - _logger?.LogError(ex, "Error disposing resource"); + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error disposing resource"); + } } + + await base.DisposeAsync(); } + finally + { + ReleaseDataResetLock(); + } + } + + private void ReleaseDataResetLock() + { + if (!_dataResetLockHeld) + return; - return base.DisposeAsync(); + _factory.DataResetLock.Release(); + _dataResetLockHeld = false; } } 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..bab8814202 --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationEvaluatorJobTests.cs @@ -0,0 +1,346 @@ +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."); + var savedRule = await _ruleRepository.GetByIdAsync(rule.Id); + Assert.NotNull(savedRule); + Assert.Equal(now, savedRule.LastFiredUtc); + } + + [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..0dade775d9 --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/RateNotificationsJobTests.cs @@ -0,0 +1,443 @@ +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.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Mail; +using Exceptionless.Tests.Utility; +using Foundatio.Jobs; +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_ActivelySnoozedRule_SkipsNotification() + { + // Arrange + var (rule, notification) = await CreateRuleAndNotificationAsync(); + rule.SnoozedUntilUtc = TimeProvider.GetUtcNow().UtcDateTime.AddHours(1); + 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); + } + + [Fact] + public async Task RunAsync_ManyMatchingRules_EnforcesSharedProjectNotificationBudget() + { + // Arrange + for (int index = 0; index <= ProjectNotificationThrottleService.NotificationLimit; index++) + { + var (_, notification) = await CreateRuleAndNotificationAsync(); + await _queue.EnqueueAsync(notification); + } + + // Act + var results = new List(); + for (int index = 0; index <= ProjectNotificationThrottleService.NotificationLimit; index++) + results.Add(await _job.RunAsync(TestContext.Current.CancellationToken)); + + // Assert + Assert.All(results, result => Assert.True(result.IsSuccess)); + Assert.Equal(ProjectNotificationThrottleService.NotificationLimit, _mailer.RateNotifications.Count); + } + + 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..fdf53fec0c --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionIntegrationTests.cs @@ -0,0 +1,139 @@ +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), + Version = 1, + 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..8e3522385c --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/UpdateRateCountersActionTests.cs @@ -0,0 +1,227 @@ +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), + Version = 1 + }; + } + + 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/Serializer/Models/RateNotificationRuleSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/RateNotificationRuleSerializerTests.cs new file mode 100644 index 0000000000..242d9e0e73 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/RateNotificationRuleSerializerTests.cs @@ -0,0 +1,36 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Services; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +public class RateNotificationRuleSerializerTests : TestWithServices +{ + private readonly ITextSerializer _serializer; + + public RateNotificationRuleSerializerTests(ITestOutputHelper output) : base(output) + { + _serializer = GetService(); + } + + [Fact] + public void Deserialize_MissingRuntimeFields_RemainsDisabledAndInvalid() + { + const string json = """ + { + "id": "507f1f77bcf86cd799439011", + "organization_id": "507f1f77bcf86cd799439012", + "project_id": "507f1f77bcf86cd799439013", + "user_id": "507f1f77bcf86cd799439014", + "name": "Legacy incomplete rule" + } + """; + + var rule = _serializer.Deserialize(json); + + Assert.NotNull(rule); + Assert.False(rule.IsEnabled); + Assert.False(RateNotificationCounterPlan.IsValidRuntimeDefinition(rule, rule.ProjectId)); + } +} diff --git a/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs new file mode 100644 index 0000000000..7b2180a9db --- /dev/null +++ b/tests/Exceptionless.Tests/Services/RateCounterServiceTests.cs @@ -0,0 +1,368 @@ +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); + } + + [Fact] + public async Task TryAcquireEvaluationClaimAsync_WithoutEnqueue_DoesNotStartCooldownAndExpiresQuickly() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + var (service, timeProvider, _) = Create(); + + // Act + bool first = await service.TryAcquireEvaluationClaimAsync(RuleId, SubjectKey, ct); + bool concurrent = await service.TryAcquireEvaluationClaimAsync(RuleId, SubjectKey, ct); + bool onCooldown = await service.IsOnCooldownAsync(RuleId, SubjectKey, ct); + timeProvider.Advance(TimeSpan.FromMinutes(2)); + bool retry = await service.TryAcquireEvaluationClaimAsync(RuleId, SubjectKey, ct); + + // Assert + Assert.True(first); + Assert.False(concurrent); + Assert.False(onCooldown); + Assert.True(retry); + } +} 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}}