From 38228a850abc8963d7188a5ae0a22040fb1f5b1d Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 15:45:19 -0600 Subject: [PATCH 01/10] Manage environments and environment notifications per project Projects can list their environments, discard events from environments that are not listed, and choose which environments send notifications. Project settings: a list of named environments (stored casing, Notify toggle, max 100, case-insensitive duplicates rejected), plus DiscardUnlistedEnvironments, DiscardEventsWithoutEnvironment and NotifyOtherEnvironments (default true). Defaults keep today's behavior. Discarding happens in a new pipeline action before stacking and uses the existing IsDiscarded path, so it is counted as discarded usage like stack discards. Rejected names are recorded in the cache per project (first-seen casing, per-day counts, last seen, capped, 7 day expiry) and exposed through the API. Notifications: every channel requires a notifying environment. New notifications use a new Stack.IsNewNotificationPending flag that is claimed atomically in Elasticsearch, so a stack first seen in a non-notifying environment is reported as new the first time it reaches a notifying one. Stacks created before the flag never notify as new. Notification emails and Slack messages mention the environment. Daily summaries are unchanged. New project endpoints manage the list and settings, and the Svelte project settings gain an Environments page. Listed casing is used in the environment filter, event details, event and session tables. Co-Authored-By: Claude Sonnet 5.5 --- docs/docs/environments.md | 26 +- src/Exceptionless.Core/Bootstrapper.cs | 1 + .../ProjectEnvironmentExtensions.cs | 54 ++ .../Jobs/EventNotificationsJob.cs | 9 +- src/Exceptionless.Core/Mail/Mailer.cs | 2 +- .../Models/DiscardedEnvironment.cs | 24 + src/Exceptionless.Core/Models/Project.cs | 47 ++ .../Models/ProjectEnvironment.cs | 40 ++ src/Exceptionless.Core/Models/Stack.cs | 7 + .../002_CheckEventEnvironmentAction.cs | 64 +++ .../Pipeline/010_AssignToStackAction.cs | 4 +- .../Pipeline/070_QueueNotificationAction.cs | 56 +- .../Default/10_SimpleErrorFormattingPlugin.cs | 4 +- .../Default/20_ErrorFormattingPlugin.cs | 4 +- .../Default/30_NotFoundFormattingPlugin.cs | 4 +- .../Default/40_UsageFormattingPlugin.cs | 2 +- .../Default/60_LogFormattingPlugin.cs | 4 +- .../Default/99_DefaultFormattingPlugin.cs | 4 +- .../Formatting/FormattingPluginBase.cs | 11 +- .../Formatting/FormattingPluginManager.cs | 4 +- .../Plugins/Formatting/IFormattingPlugin.cs | 2 +- .../Interfaces/IStackRepository.cs | 5 + .../Repositories/StackRepository.cs | 25 + .../Services/DiscardedEnvironmentService.cs | 148 ++++++ .../Api/Endpoints/ProjectEndpoints.cs | 97 +++- .../Api/Handlers/ProjectHandler.cs | 98 ++++ .../Api/Messages/ProjectMessages.cs | 5 + .../e2e/tests/project-environments.e2e.ts | 103 ++++ .../filters/environment-faceted-filter.svelte | 31 +- .../table/event-environment-cell.svelte | 8 +- .../events/components/table/options.svelte.ts | 7 +- .../events/components/views/overview.svelte | 3 +- .../organization-notifications.stories.svelte | 4 + .../lib/features/projects/api.svelte.test.ts | 4 + .../src/lib/features/projects/api.svelte.ts | 143 ++++- .../components/environment-name.svelte | 27 + .../environment-name.svelte.test.ts | 58 +++ .../environments/add-environment-form.svelte | 123 +++++ .../discarded-environments-table.svelte | 93 ++++ .../environments/environment-settings.svelte | 71 +++ .../environments/environments-table.svelte | 99 ++++ .../environments/environments.svelte.test.ts | 155 ++++++ .../features/projects/environments.test.ts | 107 ++++ .../src/lib/features/projects/environments.ts | 120 +++++ .../src/lib/features/projects/models.ts | 15 +- .../src/lib/features/projects/schemas.ts | 14 +- .../components/session-table-columns.ts | 3 +- .../ClientApp/src/lib/generated/api.ts | 79 +++ .../ClientApp/src/lib/generated/schemas.ts | 67 +++ .../[projectId]/environments/+page.svelte | 213 ++++++++ .../project/[projectId]/routes.svelte.ts | 9 + .../Project/ProjectEnvironmentModels.cs | 63 +++ .../Models/Project/ViewProject.cs | 8 + .../Api/Data/endpoint-manifest.json | 70 +++ .../Exceptionless.Tests/Api/Data/openapi.json | 491 ++++++++++++++++++ .../ProjectEndpointTests.Environments.cs | 322 ++++++++++++ .../Api/Endpoints/ProjectEndpointTests.cs | 2 +- .../ProjectEnvironmentExtensionsTests.cs | 113 ++++ .../EventNotificationsJobEnvironmentTests.cs | 99 ++++ .../Jobs/EventPostJobTests.cs | 33 ++ .../Mail/CountingMailer.cs | 14 + .../EnvironmentManagementPipelineTests.cs | 433 +++++++++++++++ .../NotificationEnvironmentFormattingTests.cs | 75 +++ .../DiscardedEnvironmentServiceTests.cs | 121 +++++ .../Validation/ProjectValidatorTests.cs | 81 +++ 65 files changed, 4187 insertions(+), 45 deletions(-) create mode 100644 src/Exceptionless.Core/Extensions/ProjectEnvironmentExtensions.cs create mode 100644 src/Exceptionless.Core/Models/DiscardedEnvironment.cs create mode 100644 src/Exceptionless.Core/Models/ProjectEnvironment.cs create mode 100644 src/Exceptionless.Core/Pipeline/002_CheckEventEnvironmentAction.cs create mode 100644 src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environment-settings.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments-table.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte create mode 100644 src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs create mode 100644 tests/Exceptionless.Tests/Extensions/ProjectEnvironmentExtensionsTests.cs create mode 100644 tests/Exceptionless.Tests/Jobs/EventNotificationsJobEnvironmentTests.cs create mode 100644 tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs create mode 100644 tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs create mode 100644 tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs diff --git a/docs/docs/environments.md b/docs/docs/environments.md index fc1e066a9a..a337029178 100644 --- a/docs/docs/environments.md +++ b/docs/docs/environments.md @@ -60,7 +60,31 @@ Environment searches and aggregations are available on every plan: Stack dashboard counts and charts use events matching the filter. Stack status remains shared, so changing status while viewing production also changes the same stack seen in staging. Automatic session detection separates the same user's activity by environment. Session heartbeats do not include an environment, so a heartbeat keeps the user's open sessions active in every environment. -Notifications and the **New** view are not environment-aware yet. A stack is new once, when its first event arrives from any environment. An error first seen in development triggers the new error notification then, and is not reported as new again when it reaches production; the **New** view also dates it from that first occurrence. Critical error and regression notifications are sent for events from every environment. +The **New** view is not environment-aware. A stack is dated from its first event in any environment. For notifications, see [Manage environments](#manage-environments) below. + +## Manage environments + +Open **Project Settings → Environments** to list the environments a project uses. Listed names are shown in the casing you type (for example `Production`) in the Environment filter, event details, and notification emails, while matching stays case-insensitive. Adding or removing a name never changes or deletes events, and the server never adds names to the list on its own: environments that appear in your data show up in the table with an **Add** action. A project can list up to 100 environments, and a name follows the same rules as an event's environment: 1 to 64 characters after trimming, with no control characters. + +Three project settings control what happens to events from environments that are not listed. Projects with no list and default settings behave exactly as before. + +| Setting | Default | Meaning | +| --- | --- | --- | +| Discard events from environments that are not listed | Off | Events whose environment is not in the list are discarded before they are stored or stacked. They count as discarded events in your usage and do not count toward your plan limit, like events for a discarded stack. | +| Also discard events that have no environment | Off | Only applies when the setting above is on. Events without an environment are discarded too. Session heartbeats and session ends, which never carry an environment, are never discarded for this reason. | +| Send notifications for other environments | On | Applies to environments that are not listed and to events without an environment. Each listed environment has its own **Notifications** toggle. | + +Ingestion acknowledges events before they are processed, so a client cannot see that an event was discarded. The **Discarded Events** table shows the names that were rejected in the last 7 days (up to 50 names per project), how many events each had, and an **Allow** action that adds the name to the list. This is the quickest way to find a misspelled environment such as `prodution`. Events without an environment are shown as their own row. + +### Notifications by environment + +An event can send notifications (email, Slack, and web hooks) only when its environment notifies: a listed environment with notifications on, or an unlisted environment or no environment while **Send notifications for other environments** is on. + +- Critical error, critical event, and regression notifications are sent only for events from a notifying environment. +- A new error or new event notification is sent the first time a stack occurs in a notifying environment. If an error is first seen in `dev` and `dev` does not notify, nothing is sent; the first time the same error occurs in `Production`, you get the new error notification, and it is not reported as new again in other environments. +- Stacks that existed before environments could be managed are treated as already reported, so deploying this change does not send new notifications for old stacks. +- Notification emails and Slack messages mention the environment, for example "New error in Production". +- Daily summaries are unchanged and still include events from every environment. ## Fixing stacks across deployments diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 0d45dc5823..2ddd2a4b1a 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -237,6 +237,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); } diff --git a/src/Exceptionless.Core/Extensions/ProjectEnvironmentExtensions.cs b/src/Exceptionless.Core/Extensions/ProjectEnvironmentExtensions.cs new file mode 100644 index 0000000000..51b7ea66fe --- /dev/null +++ b/src/Exceptionless.Core/Extensions/ProjectEnvironmentExtensions.cs @@ -0,0 +1,54 @@ +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Extensions; + +/// +/// The single place that decides how a project treats an event's environment. Matching is always case-insensitive. +/// A project with no listed environments and default settings discards nothing and notifies for everything. +/// +public static class ProjectEnvironmentExtensions +{ + public static ProjectEnvironment? FindEnvironment(this Project project, string? environment) + { + if (String.IsNullOrEmpty(environment) || project.Environments is not { Count: > 0 }) + return null; + + foreach (var listed in project.Environments) + { + if (ProjectEnvironment.NamesMatch(listed.Name, environment)) + return listed; + } + + return null; + } + + /// + /// Returns the listed casing for a listed environment, otherwise the name as it was given. + /// + public static string? GetEnvironmentDisplayName(this Project project, string? environment) + { + return project.FindEnvironment(environment)?.Name ?? environment; + } + + /// + /// Events without an environment are only discarded when unlisted environments are discarded as well. + /// + public static bool ShouldDiscardEnvironment(this Project project, string? environment) + { + if (!project.DiscardUnlistedEnvironments) + return false; + + if (String.IsNullOrEmpty(environment)) + return project.DiscardEventsWithoutEnvironment; + + return project.FindEnvironment(environment) is null; + } + + /// + /// Listed environments use their own setting. Unlisted environments and events without an environment use the project setting. + /// + public static bool ShouldNotifyForEnvironment(this Project project, string? environment) + { + return project.FindEnvironment(environment)?.Notify ?? project.NotifyOtherEnvironments; + } +} diff --git a/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs index 8890398ce2..5a2d5c14f3 100644 --- a/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventNotificationsJob.cs @@ -78,9 +78,16 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex { if (shouldLog) _logger.LogTrace("Loaded project: name={ProjectName}", project.Name); + // The pipeline only queues notifying environments, but the project settings may have changed while this was queued. + if (!project.ShouldNotifyForEnvironment(ev.Environment)) + { + if (shouldLog) _logger.LogInformation("Skipping notification because the environment does not send notifications: environment={Environment}", ev.Environment); + return JobResult.Success; + } + // after the first 2 occurrences, don't send a notification for the same stack more then once every 30 minutes var lastTimeSentUtc = await _cache.GetAsync(String.Concat("notify:stack-throttle:", ev.StackId), DateTime.MinValue); - if (wi is { TotalOccurrences: > 2, IsRegression: false } && lastTimeSentUtc != DateTime.MinValue && lastTimeSentUtc > _timeProvider.GetUtcNow().UtcDateTime.AddMinutes(-30)) + if (wi is { TotalOccurrences: > 2, IsRegression: false, IsNew: false } && lastTimeSentUtc != DateTime.MinValue && lastTimeSentUtc > _timeProvider.GetUtcNow().UtcDateTime.AddMinutes(-30)) { if (shouldLog) _logger.LogInformation("Skipping message because of stack throttling: last sent={LastSentUtc} occurrences={TotalOccurrences}", lastTimeSentUtc, wi.TotalOccurrences); return JobResult.Success; diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 85933fb25a..b9675667e2 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -74,7 +74,7 @@ public async Task SendContactRequestAsync(string name, string emailAddress public async Task SendEventNoticeAsync(User user, PersistentEvent ev, Project project, bool isNew, bool isRegression, int totalOccurrences) { bool isCritical = ev.IsCritical(); - var result = _pluginManager.GetEventNotificationMailMessageData(ev, isCritical, isNew, isRegression); + var result = _pluginManager.GetEventNotificationMailMessageData(ev, project, isCritical, isNew, isRegression); if (result is null || result.Data.Count == 0) { _logger.LogWarning("Unable to create event notification mail message for event \"{UserId}\". User: \"{EmailAddress}\"", ev.Id, user.EmailAddress); diff --git a/src/Exceptionless.Core/Models/DiscardedEnvironment.cs b/src/Exceptionless.Core/Models/DiscardedEnvironment.cs new file mode 100644 index 0000000000..aa711c2540 --- /dev/null +++ b/src/Exceptionless.Core/Models/DiscardedEnvironment.cs @@ -0,0 +1,24 @@ +namespace Exceptionless.Core.Models; + +/// +/// An environment name that recently had events discarded because it is not listed on the project. +/// +public record DiscardedEnvironment +{ + /// + /// The lowercased key for the environment, or null for events that had no environment. + /// + public string? Key { get; init; } + + /// + /// The first-seen casing of the name, or null for events that had no environment. + /// + public string? Name { get; init; } + + /// + /// The number of events discarded within the retention window. + /// + public long Count { get; init; } + + public DateTime LastSeenUtc { get; init; } +} diff --git a/src/Exceptionless.Core/Models/Project.cs b/src/Exceptionless.Core/Models/Project.cs index 4ac783e893..3506bd10fa 100644 --- a/src/Exceptionless.Core/Models/Project.cs +++ b/src/Exceptionless.Core/Models/Project.cs @@ -13,6 +13,7 @@ public Project() Configuration = new ClientConfiguration(); NotificationSettings = new Dictionary(); PromotedTabs = []; + Environments = []; DeleteBotDataEnabled = false; Usage = new SortedSet(Comparer.Create((a, b) => a.Date.CompareTo(b.Date))); UsageHours = new SortedSet(Comparer.Create((a, b) => a.Date.CompareTo(b.Date))); @@ -63,6 +64,26 @@ public Project() public bool DeleteBotDataEnabled { get; set; } + /// + /// The environments that this project lists. Names keep the casing that was entered. Events are never changed by adding or removing a name. + /// + public List Environments { get; set; } + + /// + /// If true, events whose environment is not listed are discarded at ingestion. + /// + public bool DiscardUnlistedEnvironments { get; set; } + + /// + /// If true, events without an environment are also discarded. Only applies when is true. + /// + public bool DiscardEventsWithoutEnvironment { get; set; } + + /// + /// If true, events from environments that are not listed, and events without an environment, can send notifications. + /// + public bool NotifyOtherEnvironments { get; set; } = true; + /// /// The tick count that represents the next time the daily summary job should run. This time is set to midnight of the /// projects local time. @@ -90,5 +111,31 @@ public IEnumerable Validate(ValidationContext validationContex yield return new ValidationResult("Please specify a valid next summary end of day ticks.", [nameof(NextSummaryEndOfDayTicks)]); } + + foreach (var result in ValidateEnvironments(Environments)) + yield return result; + } + + public static IEnumerable ValidateEnvironments(IReadOnlyCollection? environments) + { + if (environments is null) + yield break; + + if (environments.Count > ProjectEnvironment.MaximumCount) + yield return new ValidationResult($"A project cannot list more than {ProjectEnvironment.MaximumCount} environments.", [nameof(Environments)]); + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var environment in environments) + { + string? name = environment?.Name; + if (name is null || !String.Equals(ProjectEnvironment.NormalizeName(name), name, StringComparison.Ordinal)) + { + yield return new ValidationResult("Environment names must be 1 to 64 characters, without leading or trailing whitespace or control characters.", [nameof(Environments)]); + continue; + } + + if (!names.Add(name)) + yield return new ValidationResult($"The environment '{name}' is listed more than once. Names are case-insensitive.", [nameof(Environments)]); + } } } diff --git a/src/Exceptionless.Core/Models/ProjectEnvironment.cs b/src/Exceptionless.Core/Models/ProjectEnvironment.cs new file mode 100644 index 0000000000..2ff75ea665 --- /dev/null +++ b/src/Exceptionless.Core/Models/ProjectEnvironment.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exceptionless.Core.Models; + +/// +/// A deployment environment that a project has chosen to list, such as production or staging. +/// +public class ProjectEnvironment +{ + /// + /// The maximum number of environments that can be listed on a project. + /// + public const int MaximumCount = 100; + + /// + /// The environment name in the exact casing that was entered. Matching against event environments is case-insensitive. + /// + [Required] + [StringLength(64, MinimumLength = 1)] + public string Name { get; set; } = null!; + + /// + /// If true, events from this environment can send notifications. + /// + public bool Notify { get; set; } = true; + + public DateTime CreatedUtc { get; set; } + + /// + /// Returns the trimmed name, or null when the value cannot be an environment name. Uses the same rules as . + /// + public static string? NormalizeName(string? value) => Event.NormalizeEnvironment(value); + + /// + /// Returns the case-insensitive key for an environment name. This matches the lowercased keys returned by environment aggregations. + /// + public static string GetKey(string name) => name.ToLowerInvariant(); + + public static bool NamesMatch(string? first, string? second) => String.Equals(first, second, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Exceptionless.Core/Models/Stack.cs b/src/Exceptionless.Core/Models/Stack.cs index 4356365293..57420e35c5 100644 --- a/src/Exceptionless.Core/Models/Stack.cs +++ b/src/Exceptionless.Core/Models/Stack.cs @@ -101,6 +101,13 @@ public class Stack : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, ISu /// public bool OccurrencesAreCritical { get; set; } + /// + /// True when the stack was created by an event from an environment that does not send notifications and no notifying + /// environment has reported it yet. The first occurrence in a notifying environment clears this and is reported as new. + /// Stacks created before this field existed are not pending, so they never produce a new notification for it. + /// + public bool IsNewNotificationPending { get; set; } + /// /// A list of references. /// diff --git a/src/Exceptionless.Core/Pipeline/002_CheckEventEnvironmentAction.cs b/src/Exceptionless.Core/Pipeline/002_CheckEventEnvironmentAction.cs new file mode 100644 index 0000000000..273b452758 --- /dev/null +++ b/src/Exceptionless.Core/Pipeline/002_CheckEventEnvironmentAction.cs @@ -0,0 +1,64 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Pipeline; + +/// +/// Discards events from environments the project does not list when the project asks for that. This runs before stacking so +/// rejected events never create stacks. Discarded events are counted as discarded usage by the caller, like stack discards. +/// +[Priority(2)] +public class CheckEventEnvironmentAction : EventPipelineActionBase +{ + private readonly DiscardedEnvironmentService _discardedEnvironments; + + public CheckEventEnvironmentAction(DiscardedEnvironmentService discardedEnvironments, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + { + _discardedEnvironments = discardedEnvironments; + ContinueOnError = true; + } + + public override async Task ProcessBatchAsync(ICollection contexts) + { + var project = contexts.FirstOrDefault()?.Project; + if (project is null || !project.DiscardUnlistedEnvironments) + return; + + var discarded = new List(); + foreach (var ctx in contexts) + { + // Heartbeats and session ends never carry an environment. They only keep sessions alive and are never stored as events. + if (ctx.Event.Environment is null && (ctx.Event.IsSessionHeartbeat() || ctx.Event.IsSessionEnd())) + continue; + + if (!project.ShouldDiscardEnvironment(ctx.Event.Environment)) + continue; + + ctx.IsDiscarded = true; + ctx.IsCancelled = true; + discarded.Add(ctx); + } + + if (discarded.Count == 0) + return; + + _logger.LogInformation("Discarding {Count} events from environments that are not listed on the project", discarded.Count); + + try + { + await _discardedEnvironments.RecordAsync(project.Id, discarded.Select(c => c.Event.Environment)); + } + catch (Exception ex) + { + // Visibility is best effort and must never fail ingestion. + _logger.LogError(ex, "Error recording discarded environments: {Message}", ex.Message); + } + } + + public override Task ProcessAsync(EventContext ctx) + { + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs b/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs index c0d067e00f..bc092f7bf7 100644 --- a/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs +++ b/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs @@ -90,7 +90,9 @@ public override async Task ProcessBatchAsync(ICollection contexts) Type = ctx.Event.Type ?? Event.KnownTypes.Log, TotalOccurrences = 1, FirstOccurrence = ctx.Event.Date.UtcDateTime, - LastOccurrence = ctx.Event.Date.UtcDateTime + LastOccurrence = ctx.Event.Date.UtcDateTime, + // A stack that starts in an environment that does not notify is reported as new when it first reaches one that does. + IsNewNotificationPending = !ctx.Project.ShouldNotifyForEnvironment(ctx.Event.Environment) }; if (ctx.Event.Type == Event.KnownTypes.Session) diff --git a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs index 9c9a6b8fa4..65bfdf0d02 100644 --- a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs +++ b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Models; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.EventProcessor; using Exceptionless.Core.Plugins.WebHook; using Exceptionless.Core.Queues.Models; @@ -14,13 +15,15 @@ public class QueueNotificationAction : EventPipelineActionBase private readonly IQueue _notificationQueue; private readonly IQueue _webHookNotificationQueue; private readonly IWebHookRepository _webHookRepository; + private readonly IStackRepository _stackRepository; private readonly WebHookDataPluginManager _webHookDataPluginManager; - public QueueNotificationAction(IQueue notificationQueue, IQueue webHookNotificationQueue, IWebHookRepository webHookRepository, WebHookDataPluginManager webHookDataPluginManager, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) + public QueueNotificationAction(IQueue notificationQueue, IQueue webHookNotificationQueue, IWebHookRepository webHookRepository, IStackRepository stackRepository, WebHookDataPluginManager webHookDataPluginManager, AppOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { _notificationQueue = notificationQueue; _webHookNotificationQueue = webHookNotificationQueue; _webHookRepository = webHookRepository; + _stackRepository = stackRepository; _webHookDataPluginManager = webHookDataPluginManager; ContinueOnError = true; } @@ -34,11 +37,16 @@ public override async Task ProcessAsync(EventContext ctx) if (ctx.Stack is null || !ctx.Stack.AllowNotifications) return; - if (ShouldQueueNotification(ctx)) + // Every notification for every channel (email, Slack, web hooks) requires a notifying environment. + if (!ctx.Project.ShouldNotifyForEnvironment(ctx.Event.Environment)) + return; + + bool isNew = await IsNewForNotificationAsync(ctx); + if (ShouldQueueNotification(ctx, isNew)) await _notificationQueue.EnqueueAsync(new EventNotification { EventId = ctx.Event.Id, - IsNew = ctx.IsNew, + IsNew = isNew, IsRegression = ctx.IsRegression, TotalOccurrences = ctx.Stack.TotalOccurrences }); @@ -46,10 +54,10 @@ await _notificationQueue.EnqueueAsync(new EventNotification var webHooks = await _webHookRepository.GetByOrganizationIdOrProjectIdAsync(ctx.Event.OrganizationId, ctx.Event.ProjectId); foreach (var hook in webHooks.Documents) { - if (!ShouldCallWebHook(hook, ctx)) + if (!ShouldCallWebHook(hook, ctx, isNew)) continue; - var context = new WebHookDataContext(hook, ctx.Organization, ctx.Project, ctx.Stack, ctx.Event, ctx.IsNew, ctx.IsRegression); + var context = new WebHookDataContext(hook, ctx.Organization, ctx.Project, ctx.Stack, ctx.Event, isNew, ctx.IsRegression); var notification = new WebHookNotification { OrganizationId = ctx.Event.OrganizationId, @@ -71,7 +79,31 @@ await _notificationQueue.EnqueueAsync(new EventNotification } } - private bool ShouldCallWebHook(WebHook hook, EventContext ctx) + /// + /// A stack is new for notifications on its first occurrence in a notifying environment. That is the occurrence that created it, + /// or the first one after it was created in an environment that does not notify. Stacks created before environments were managed + /// are never pending, so they are never reported as new again. + /// + private async Task IsNewForNotificationAsync(EventContext ctx) + { + if (ctx.IsNew) + return true; + + if (ctx.Stack is not { IsNewNotificationPending: true }) + return false; + + // The repository clears the flag atomically, so concurrent events can only produce one new notification. + if (!await _stackRepository.TryClaimNewNotificationAsync(ctx.Stack)) + { + ctx.Stack.IsNewNotificationPending = false; + return false; + } + + ctx.Stack.IsNewNotificationPending = false; + return true; + } + + private bool ShouldCallWebHook(WebHook hook, EventContext ctx, bool isNew) { if (!hook.IsEnabled) return false; @@ -79,7 +111,7 @@ private bool ShouldCallWebHook(WebHook hook, EventContext ctx) if (!String.IsNullOrEmpty(hook.ProjectId) && !String.Equals(ctx.Project.Id, hook.ProjectId)) return false; - if (ctx.IsNew && ctx.Event.IsError() && hook.EventTypes.Contains(WebHook.KnownEventTypes.NewError)) + if (isNew && ctx.Event.IsError() && hook.EventTypes.Contains(WebHook.KnownEventTypes.NewError)) return true; if (ctx.Event.IsCritical() && ctx.Event.IsError() && hook.EventTypes.Contains(WebHook.KnownEventTypes.CriticalError)) @@ -88,7 +120,7 @@ private bool ShouldCallWebHook(WebHook hook, EventContext ctx) if (ctx.IsRegression && hook.EventTypes.Contains(WebHook.KnownEventTypes.StackRegression)) return true; - if (ctx.IsNew && hook.EventTypes.Contains(WebHook.KnownEventTypes.NewEvent)) + if (isNew && hook.EventTypes.Contains(WebHook.KnownEventTypes.NewEvent)) return true; if (ctx.Event.IsCritical() && hook.EventTypes.Contains(WebHook.KnownEventTypes.CriticalEvent)) @@ -97,12 +129,12 @@ private bool ShouldCallWebHook(WebHook hook, EventContext ctx) return false; } - private bool ShouldQueueNotification(EventContext ctx) + private bool ShouldQueueNotification(EventContext ctx, bool isNew) { if (ctx.Project.NotificationSettings.Count == 0) return false; - if (ctx.IsNew && ctx.Event.IsError() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportNewErrors)) + if (isNew && ctx.Event.IsError() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportNewErrors)) return true; if (ctx.Event.IsCritical() && ctx.Event.IsError() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportCriticalErrors)) @@ -111,7 +143,7 @@ private bool ShouldQueueNotification(EventContext ctx) if (ctx.IsRegression && ctx.Project.NotificationSettings.Any(n => n.Value.ReportEventRegressions)) return true; - if (ctx.IsNew && ctx.Project.NotificationSettings.Any(n => n.Value.ReportNewEvents)) + if (isNew && ctx.Project.NotificationSettings.Any(n => n.Value.ReportNewEvents)) return true; if (ctx.Event.IsCritical() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportCriticalEvents)) diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs index 6dbdfef7a3..2246be7804 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs @@ -68,7 +68,7 @@ private bool ShouldHandle(PersistentEvent ev) return new SummaryData { Id = ev.Id, TemplateKey = "event-simple-summary", Data = data }; } - public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { if (!ShouldHandle(ev)) return null; @@ -91,6 +91,7 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("Critical ", notificationType); + notificationType = InEnvironment(notificationType, ev, project); string subject = String.Concat(notificationType, ": ", error.Message).Truncate(120); var data = new Dictionary { { "Message", error.Message?.Truncate(60) } }; if (!String.IsNullOrEmpty(errorTypeName)) @@ -149,6 +150,7 @@ private bool ShouldHandle(PersistentEvent ev) } AddDefaultSlackFields(ev, attachment.Fields); + notificationType = InEnvironment(notificationType, ev, project); string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, error.Message?.Truncate(120))}*"; return new SlackMessage(subject) { diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs index 3084b18778..20b78a080c 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs @@ -85,7 +85,7 @@ private bool ShouldHandle(PersistentEvent ev) return new SummaryData { Id = ev.Id, TemplateKey = "event-error-summary", Data = data }; } - public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { if (!ShouldHandle(ev)) return null; @@ -109,6 +109,7 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("Critical ", notificationType); + notificationType = InEnvironment(notificationType, ev, project); string subject = String.Concat(notificationType, ": ", stackingTarget.Error.Message).Truncate(120); var data = new Dictionary { { "Message", stackingTarget.Error.Message?.Truncate(60) } }; if (!String.IsNullOrEmpty(errorTypeName)) @@ -164,6 +165,7 @@ private bool ShouldHandle(PersistentEvent ev) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Method", Value = stackingTarget.Method.Name.Truncate(60) }); AddDefaultSlackFields(ev, attachment.Fields); + notificationType = InEnvironment(notificationType, ev, project); string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, stackingTarget.Error.Message?.Truncate(120))}*"; return new SlackMessage(subject) { diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs index 1ed977c6f0..56a75b17a4 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs @@ -47,7 +47,7 @@ private bool ShouldHandle(PersistentEvent ev) return new SummaryData { Id = ev.Id, TemplateKey = "event-notfound-summary", Data = data }; } - public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { if (!ShouldHandle(ev)) return null; @@ -61,6 +61,7 @@ private bool ShouldHandle(PersistentEvent ev) if (isCritical) notificationType = String.Concat("Critical ", notificationType.ToLowerInvariant()); + notificationType = InEnvironment(notificationType, ev, project); string subject = String.Concat(notificationType, ": ", ev.Source).Truncate(120); var requestInfo = ev.GetRequestInfo(_serializer, _logger); var data = new Dictionary { @@ -101,6 +102,7 @@ private bool ShouldHandle(PersistentEvent ev) }; AddDefaultSlackFields(ev, attachment.Fields, false); + notificationType = InEnvironment(notificationType, ev, project); string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, ev.Source?.Truncate(120))}*"; return new SlackMessage(subject) { diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs index c74d6d9c86..42505806cf 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs @@ -43,7 +43,7 @@ private bool ShouldHandle(PersistentEvent ev) return new SummaryData { Id = ev.Id, TemplateKey = "event-feature-summary", Data = data }; } - public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { if (!ShouldHandle(ev)) return null; diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs index f00ffc6278..16efa3d957 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs @@ -68,7 +68,7 @@ private bool ShouldHandle(PersistentEvent ev) return new SummaryData { Id = ev.Id, TemplateKey = "event-log-summary", Data = data }; } - public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public override MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { if (!ShouldHandle(ev)) return null; @@ -83,6 +83,7 @@ private bool ShouldHandle(PersistentEvent ev) notificationType = String.Concat("Critical ", notificationType.ToLowerInvariant()); string source = !String.IsNullOrEmpty(ev.Source) ? ev.Source : "(Global)"; + notificationType = InEnvironment(notificationType, ev, project); string subject = String.Concat(notificationType, ": ", source).Truncate(120); var data = new Dictionary { { "Source", source.Truncate(60) } }; if (!String.IsNullOrEmpty(ev.Message)) @@ -154,6 +155,7 @@ private bool ShouldHandle(PersistentEvent ev) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Url", Value = requestInfo.GetFullPath(true, true, true) }); AddDefaultSlackFields(ev, attachment.Fields); + notificationType = InEnvironment(notificationType, ev, project); string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, source.Truncate(120))}*"; return new SlackMessage(subject) { diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs index db42da417b..8a42e579f7 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs @@ -42,7 +42,7 @@ public override SummaryData GetEventSummaryData(PersistentEvent ev) return new SummaryData { Id = ev.Id, TemplateKey = "event-summary", Data = data }; } - public override MailMessageData GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public override MailMessageData GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { string? messageOrSource = !String.IsNullOrEmpty(ev.Message) ? ev.Message : ev.Source; if (String.IsNullOrEmpty(messageOrSource)) @@ -57,6 +57,7 @@ public override MailMessageData GetEventNotificationMailMessageData(PersistentEv if (isCritical) notificationType = String.Concat("Critical ", notificationType.ToLowerInvariant()); + notificationType = InEnvironment(notificationType, ev, project); string subject = String.IsNullOrEmpty(messageOrSource) ? notificationType : String.Concat(notificationType, ": ", messageOrSource).Truncate(120); @@ -98,6 +99,7 @@ public override SlackMessage GetSlackEventNotification(PersistentEvent ev, Proje attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Source", Value = ev.Source.Truncate(60) }); AddDefaultSlackFields(ev, attachment.Fields); + notificationType = InEnvironment(notificationType, ev, project); string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, messageOrSource.Truncate(120))}*"; return new SlackMessage(subject) { diff --git a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs index 072ad5c412..a77065c4cf 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs @@ -31,7 +31,7 @@ public FormattingPluginBase(ITextSerializer serializer, AppOptions options, ILog return null; } - public virtual MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public virtual MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { return null; } @@ -41,6 +41,15 @@ public FormattingPluginBase(ITextSerializer serializer, AppOptions options, ILog return null; } + /// + /// Adds the environment to a notification description, such as "new error in Production". A listed environment uses its listed casing. + /// + protected static string InEnvironment(string notificationType, PersistentEvent ev, Project project) + { + string? environment = project.GetEnvironmentDisplayName(ev.Environment); + return String.IsNullOrEmpty(environment) ? notificationType : String.Concat(notificationType, " in ", environment); + } + protected void AddDefaultSlackFields(PersistentEvent ev, List attachmentFields, bool includeUrl = true) { var requestInfo = ev.GetRequestInfo(_serializer, _logger); diff --git a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginManager.cs b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginManager.cs index 26f2fa6fa1..dd0a88cc4c 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginManager.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginManager.cs @@ -82,7 +82,7 @@ public string GetStackTitle(PersistentEvent ev) /// /// Runs through the formatting plugins to get notification mail content for an event. /// - public MailMessageData GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression) + public MailMessageData GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression) { foreach (var plugin in Plugins.Values.ToList()) { @@ -90,7 +90,7 @@ public MailMessageData GetEventNotificationMailMessageData(PersistentEvent ev, b try { - var result = plugin.GetEventNotificationMailMessageData(ev, isCritical, isNew, isRegression); + var result = plugin.GetEventNotificationMailMessageData(ev, project, isCritical, isNew, isRegression); if (result is not null) return result; } diff --git a/src/Exceptionless.Core/Plugins/Formatting/IFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/IFormattingPlugin.cs index 57475b0c1d..2ce1f50e38 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/IFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/IFormattingPlugin.cs @@ -7,6 +7,6 @@ public interface IFormattingPlugin : IPlugin string? GetStackTitle(PersistentEvent ev); SummaryData? GetStackSummaryData(Stack stack); SummaryData? GetEventSummaryData(PersistentEvent ev); - MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, bool isCritical, bool isNew, bool isRegression); + MailMessageData? GetEventNotificationMailMessageData(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression); SlackMessage? GetSlackEventNotification(PersistentEvent ev, Project project, bool isCritical, bool isNew, bool isRegression); } diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs index 13199d28c6..786933ba0f 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs @@ -10,6 +10,11 @@ public interface IStackRepository : IRepositoryOwnedByOrganizationAndProject> GetIdsByQueryAsync(RepositoryQueryDescriptor query, CommandOptionsDescriptor? options = null); Task> GetExpiredSnoozedStatuses(DateTime utcNow, CommandOptionsDescriptor? options = null); Task MarkAsRegressedAsync(string stackId); + + /// + /// Atomically clears . Returns true only for the single caller that cleared it. + /// + Task TryClaimNewNotificationAsync(Stack stack); Task IncrementEventCounterAsync(string organizationId, string projectId, string stackId, DateTime minOccurrenceDateUtc, DateTime maxOccurrenceDateUtc, int count, bool sendNotifications = true); Task SetEventCounterAsync(string stackId, DateTime firstOccurrenceUtc, DateTime lastOccurrenceUtc, long totalOccurrences, bool sendNotifications = true); Task> GetStacksForCleanupAsync(string organizationId, DateTime cutoff); diff --git a/src/Exceptionless.Core/Repositories/StackRepository.cs b/src/Exceptionless.Core/Repositories/StackRepository.cs index 29b0c3407e..4c70c4958d 100644 --- a/src/Exceptionless.Core/Repositories/StackRepository.cs +++ b/src/Exceptionless.Core/Repositories/StackRepository.cs @@ -151,6 +151,31 @@ Instant parseDate(def dt) { } } + public async Task TryClaimNewNotificationAsync(Stack stack) + { + // The update is applied by Elasticsearch to the current document, so only one caller can see the flag set. + // Everyone else (and stacks that were never pending) becomes a no-op, which PatchAsync reports as not modified. + const string script = @" +if (ctx._source.is_new_notification_pending == true) { + ctx._source.is_new_notification_pending = false; +} else { + ctx.op = 'noop'; +}"; + + try + { + bool claimed = await PatchAsync(stack.Id, new ScriptPatch(script.TrimScript()), o => o.Notifications(false)); + if (claimed) + await InvalidateCacheAsync([new ModifiedDocument(stack, stack)]); + + return claimed; + } + catch (DocumentNotFoundException) + { + return false; + } + } + public async Task GetStackBySignatureHashAsync(string projectId, string signatureHash) { string key = GetStackSignatureCacheKey(projectId, signatureHash); diff --git a/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs b/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs new file mode 100644 index 0000000000..e48a616649 --- /dev/null +++ b/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs @@ -0,0 +1,148 @@ +using Exceptionless.Core.Models; +using Foundatio.Caching; + +namespace Exceptionless.Core.Services; + +/// +/// Remembers which environment names recently had events discarded for a project so the owner can see them and allow them. +/// Ingestion acknowledges events before they are processed, so clients never learn about these discards. +/// +/// +/// Everything lives in the cache and expires. Each name has a first-seen display casing, a last-seen time, and one counter per UTC day +/// so the reported count covers exactly the retention window. Writes are grouped by name, so a batch costs a handful of cache calls. +/// The number of tracked names per project is capped; names beyond the cap are not tracked until older ones expire. The cap is checked +/// without a lock, so concurrent batches can briefly exceed it by a few names. +/// +public class DiscardedEnvironmentService +{ + public const int RetentionDays = 7; + public const int MaximumNamesPerProject = 50; + + private const string UnspecifiedBucket = "unspecified"; + private const string NamePrefix = "name:"; + private static readonly TimeSpan _retention = TimeSpan.FromDays(RetentionDays); + private static readonly TimeSpan _counterRetention = TimeSpan.FromDays(RetentionDays + 1); + + private readonly ICacheClient _cache; + private readonly TimeProvider _timeProvider; + + public DiscardedEnvironmentService(ICacheClient cache, TimeProvider timeProvider) + { + _cache = cache; + _timeProvider = timeProvider; + } + + /// + /// Records discarded events. A null or empty environment is counted in the bucket for events without an environment. + /// + public async Task RecordAsync(string projectId, IEnumerable environments) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + + var groups = new Dictionary(StringComparer.Ordinal); + foreach (string? environment in environments) + { + string bucket = GetBucket(environment); + groups[bucket] = groups.TryGetValue(bucket, out var group) + ? (group.Name, group.Count + 1) + : (String.IsNullOrEmpty(environment) ? null : environment, 1); + } + + if (groups.Count == 0) + return; + + string namesKey = GetNamesKey(projectId); + var tracked = await _cache.GetListAsync(namesKey); + var trackedBuckets = tracked.HasValue ? new HashSet(tracked.Value, StringComparer.Ordinal) : []; + + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + string day = GetDay(utcNow); + foreach ((string bucket, (string? name, int count)) in groups) + { + if (!trackedBuckets.Contains(bucket) && trackedBuckets.Count >= MaximumNamesPerProject) + continue; + + await Task.WhenAll( + _cache.ListAddAsync(namesKey, bucket, _retention), + _cache.AddAsync(GetDisplayKey(projectId, bucket), name ?? String.Empty, _retention), + _cache.SetAsync(GetLastSeenKey(projectId, bucket), utcNow, _retention), + _cache.IncrementAsync(GetCountKey(projectId, bucket, day), count, _counterRetention) + ); + + trackedBuckets.Add(bucket); + } + } + + /// + /// Gets the discarded names, most discarded first. + /// + public async Task> GetAsync(string projectId) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + + var tracked = await _cache.GetListAsync(GetNamesKey(projectId)); + if (!tracked.HasValue || tracked.Value.Count == 0) + return []; + + var buckets = tracked.Value.Distinct(StringComparer.Ordinal).ToList(); + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + var days = Enumerable.Range(0, RetentionDays).Select(offset => GetDay(utcNow.AddDays(-offset))).ToList(); + + var displayNames = await _cache.GetAllAsync(buckets.Select(b => GetDisplayKey(projectId, b))); + var lastSeen = await _cache.GetAllAsync(buckets.Select(b => GetLastSeenKey(projectId, b))); + var counts = await _cache.GetAllAsync(buckets.SelectMany(b => days.Select(d => GetCountKey(projectId, b, d)))); + + var results = new List(buckets.Count); + foreach (string bucket in buckets) + { + long count = days.Sum(day => counts.TryGetValue(GetCountKey(projectId, bucket, day), out var value) && value.HasValue ? value.Value : 0); + if (count <= 0) + continue; + + bool isUnspecified = bucket == UnspecifiedBucket; + string? name = null; + if (!isUnspecified) + { + name = displayNames.TryGetValue(GetDisplayKey(projectId, bucket), out var display) && display.HasValue && !String.IsNullOrEmpty(display.Value) + ? display.Value + : bucket[NamePrefix.Length..]; + } + + results.Add(new DiscardedEnvironment + { + Key = isUnspecified ? null : bucket[NamePrefix.Length..], + Name = name, + Count = count, + LastSeenUtc = lastSeen.TryGetValue(GetLastSeenKey(projectId, bucket), out var seen) && seen.HasValue ? seen.Value : default + }); + } + + return results.OrderByDescending(r => r.Count).ThenBy(r => r.Key, StringComparer.Ordinal).ToList(); + } + + /// + /// Forgets a name, for example after it was added to the project. A null or empty environment forgets events without an environment. + /// + public async Task RemoveAsync(string projectId, string? environment) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + + string bucket = GetBucket(environment); + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + var keys = Enumerable.Range(0, RetentionDays + 1) + .Select(offset => GetCountKey(projectId, bucket, GetDay(utcNow.AddDays(-offset)))) + .Append(GetDisplayKey(projectId, bucket)) + .Append(GetLastSeenKey(projectId, bucket)); + + await _cache.ListRemoveAsync(GetNamesKey(projectId), bucket); + await _cache.RemoveAllAsync(keys); + } + + private static string GetBucket(string? environment) => String.IsNullOrEmpty(environment) ? UnspecifiedBucket : NamePrefix + ProjectEnvironment.GetKey(environment); + private static string GetDay(DateTime utc) => utc.ToString("yyyyMMdd"); + private static string GetPrefix(string projectId) => String.Concat("project:", projectId, ":discarded-environments"); + private static string GetNamesKey(string projectId) => String.Concat(GetPrefix(projectId), ":names"); + private static string GetDisplayKey(string projectId, string bucket) => String.Concat(GetPrefix(projectId), ":display:", bucket); + private static string GetLastSeenKey(string projectId, string bucket) => String.Concat(GetPrefix(projectId), ":last:", bucket); + private static string GetCountKey(string projectId, string bucket, string day) => String.Concat(GetPrefix(projectId), ":count:", day, ":", bucket); +} diff --git a/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs index 199f45d795..346c2b874a 100644 --- a/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs @@ -480,7 +480,102 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild } }); - group.MapGet("organizations/{organizationId:objectid}/projects/check-name", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? name = null, string? projectId = null) + group.MapPost("projects/{id:objectid}/environments", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] NewProjectEnvironment? environment) + => environment is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new ProjectMessages.AddProjectEnvironment(id, environment, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Add environment") + .WithDescription("Lists an environment on the project. Events are never changed, and a name that was being discarded is accepted from now on.") + .WithMetadata(new EndpointDocumentation { + RequestBodyRequired = true, + RequestBodyDescription = "The environment to list.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + ["409"] = "The environment is already listed. Names are case-insensitive.", + ["422"] = "The name is not a valid environment name, or the project already lists the maximum number of environments.", + } + }); + + group.MapPatch("projects/{id:objectid}/environments", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProjectEnvironment? changes, string? name = null) + => changes is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectEnvironmentMessage(id, name, changes, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithSummary("Update environment") + .WithMetadata(new EndpointDocumentation { + RequestBodyRequired = true, + RequestBodyDescription = "The changes.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["name"] = "The environment name. Matching is case-insensitive.", + }, + ResponseDescriptions = new() { + ["404"] = "The project or the listed environment could not be found.", + } + }); + + group.MapDelete("projects/{id:objectid}/environments", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? name = null) + => (await mediator.InvokeAsync>(new ProjectMessages.RemoveProjectEnvironment(id, name, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Remove environment") + .WithDescription("Removes an environment from the project's list. Events are never deleted.") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + ["name"] = "The environment name. Matching is case-insensitive.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapPut("projects/{id:objectid}/environments/settings", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProjectEnvironmentSettings? settings) + => settings is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectEnvironmentSettingsMessage(id, settings, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json", "application/*+json") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Update environment settings") + .WithDescription("Sets whether unlisted environments and events without an environment are discarded, and whether they send notifications. Omitted settings are unchanged.") + .WithMetadata(new EndpointDocumentation { + RequestBodyRequired = true, + RequestBodyDescription = "The settings to change.", + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("projects/{id:objectid}/environments/discarded", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new ProjectMessages.GetDiscardedProjectEnvironments(id, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Get discarded environments") + .WithDescription("Gets the environment names that recently had events discarded because they are not listed, with the number of events discarded.") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["id"] = "The identifier of the project.", + }, + ResponseDescriptions = new() { + ["404"] = "The project could not be found.", + } + }); + + group.MapGet("organizations/{organizationId:objectid}/projects/check-name",async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? name = null, string? projectId = null) => (await mediator.InvokeAsync(new ProjectMessages.CheckProjectName(name, organizationId, projectId, httpContext))).ToHttpResult(resultMapper)) .RequireAuthorization(AuthorizationRoles.UserPolicy) .Produces(StatusCodes.Status201Created) diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 3705100472..7da0713896 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -41,6 +41,7 @@ public class ProjectHandler( ITextSerializer serializer, AppOptions options, UsageService usageService, + DiscardedEnvironmentService discardedEnvironmentService, TimeProvider timeProvider, LinkGenerator linkGenerator, ILoggerFactory loggerFactory) @@ -462,6 +463,103 @@ public async Task Handle(RemoveProjectSlack message) return Result.Success(); } + public async Task> Handle(AddProjectEnvironment message) + { + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + string? name = ProjectEnvironment.NormalizeName(message.Environment.Name); + if (name is null) + return Result.Invalid(ValidationError.Create("name", "Environment names must be 1 to 64 characters and cannot contain control characters.")); + + if (project.FindEnvironment(name) is not null) + return Result.Conflict($"The environment '{name}' is already listed. Names are case-insensitive."); + + if (project.Environments.Count >= ProjectEnvironment.MaximumCount) + return Result.Invalid(ValidationError.Create("environments", $"A project cannot list more than {ProjectEnvironment.MaximumCount} environments.")); + + project.Environments.Add(new ProjectEnvironment { Name = name, Notify = message.Environment.Notify, CreatedUtc = timeProvider.GetUtcNow().UtcDateTime }); + await repository.SaveAsync(project, o => o.Cache()); + + // Adding the name means those events are accepted from now on, so stop reporting them as discarded. + await discardedEnvironmentService.RemoveAsync(project.Id, name); + return MapEnvironmentSettings(project); + } + + public async Task> Handle(UpdateProjectEnvironmentMessage message) + { + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + var environment = project.FindEnvironment(ProjectEnvironment.NormalizeName(message.Name)); + if (environment is null) + return Result.NotFound("Environment not found."); + + if (message.Changes.Notify is { } notify && environment.Notify != notify) + { + environment.Notify = notify; + await repository.SaveAsync(project, o => o.Cache()); + } + + return MapEnvironmentSettings(project); + } + + public async Task> Handle(RemoveProjectEnvironment message) + { + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + // Removing a name never touches events. It only changes how new events are treated. + string? name = ProjectEnvironment.NormalizeName(message.Name); + if (name is not null && project.Environments.RemoveAll(e => ProjectEnvironment.NamesMatch(e.Name, name)) > 0) + await repository.SaveAsync(project, o => o.Cache()); + + return MapEnvironmentSettings(project); + } + + public async Task> Handle(UpdateProjectEnvironmentSettingsMessage message) + { + var project = await GetModelAsync(message.Id, message.Context, useCache: false); + if (project is null) + return Result.NotFound("Project not found."); + + var changes = message.Settings; + bool stoppedDiscardingWithoutEnvironment = changes.DiscardEventsWithoutEnvironment is false && project.DiscardEventsWithoutEnvironment; + project.DiscardUnlistedEnvironments = changes.DiscardUnlistedEnvironments ?? project.DiscardUnlistedEnvironments; + project.DiscardEventsWithoutEnvironment = changes.DiscardEventsWithoutEnvironment ?? project.DiscardEventsWithoutEnvironment; + project.NotifyOtherEnvironments = changes.NotifyOtherEnvironments ?? project.NotifyOtherEnvironments; + await repository.SaveAsync(project, o => o.Cache()); + + if (stoppedDiscardingWithoutEnvironment) + await discardedEnvironmentService.RemoveAsync(project.Id, null); + + return MapEnvironmentSettings(project); + } + + public async Task> Handle(GetDiscardedProjectEnvironments message) + { + var project = await GetModelAsync(message.Id, message.Context); + if (project is null) + return Result.NotFound("Project not found."); + + return new ProjectDiscardedEnvironments + { + RetentionDays = DiscardedEnvironmentService.RetentionDays, + Environments = await discardedEnvironmentService.GetAsync(project.Id) + }; + } + + private static ProjectEnvironmentSettings MapEnvironmentSettings(Project project) => new() + { + Environments = project.Environments, + DiscardUnlistedEnvironments = project.DiscardUnlistedEnvironments, + DiscardEventsWithoutEnvironment = project.DiscardEventsWithoutEnvironment, + NotifyOtherEnvironments = project.NotifyOtherEnvironments + }; + private async Task> GetConfigAsync(string? id, int? version, HttpContext httpContext) { if (String.IsNullOrEmpty(id)) diff --git a/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs b/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs index 0ae02c94ea..8583f8f341 100644 --- a/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs @@ -30,3 +30,8 @@ public record SetProjectData(string Id, string? Key, ValueFromBody Value public record DeleteProjectData(string Id, string? Key, HttpContext Context); public record AddProjectSlack(string Id, string Code, HttpContext Context); public record RemoveProjectSlack(string Id, HttpContext Context); +public record AddProjectEnvironment(string Id, NewProjectEnvironment Environment, HttpContext Context); +public record UpdateProjectEnvironmentMessage(string Id, string? Name, UpdateProjectEnvironment Changes, HttpContext Context); +public record RemoveProjectEnvironment(string Id, string? Name, HttpContext Context); +public record UpdateProjectEnvironmentSettingsMessage(string Id, UpdateProjectEnvironmentSettings Settings, HttpContext Context); +public record GetDiscardedProjectEnvironments(string Id, HttpContext Context); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts new file mode 100644 index 0000000000..dffba53906 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts @@ -0,0 +1,103 @@ +import { createReferenceId, expect, test } from '../fixtures/e2e-test'; + +test('operator can list, configure, and filter environments and allow discarded names', async ({ e2eApi, e2eScenario, page }) => { + const { projectId, projectToken, run, userToken } = e2eScenario; + + await test.step('seed events from environments that are not listed yet', async () => { + for (const [index, environment] of ['production', 'Staging'].entries()) { + const referenceId = createReferenceId(run, `-managed-env-${index}`); + await e2eApi.submitEvent(projectId, projectToken, { + environment, + message: `Managed environment ${environment} ${run}`, + reference_id: referenceId, + source: 'managed-environment-test', + type: 'log' + }); + await e2eApi.pollForEventByReference(userToken, projectId, referenceId); + } + }); + + await test.step('observed environments appear as unlisted rows', async () => { + await page.goto(`/project/${projectId}/environments`); + + await expect(page.getByRole('heading', { name: 'Environments in Use' })).toBeVisible(); + await expect(page.getByRole('row', { name: /^production Not listed/ })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('row', { name: /^staging Not listed/ })).toBeVisible(); + }); + + await test.step('adding a name lists it in the casing that was typed and merges the observed row', async () => { + await page.getByLabel('Environment name').fill(' Production '); + await page.getByRole('button', { name: 'Add environment' }).click(); + + await expect(page.getByText('Added Production.')).toBeVisible(); + await expect(page.getByRole('row', { name: /^Production Listed/ })).toBeVisible(); + await expect(page.getByRole('row', { name: /^production Not listed/ })).toBeHidden(); + + await page.getByLabel('Environment name').fill('PRODUCTION'); + await page.getByRole('button', { name: 'Add environment' }).click(); + await expect(page.getByText('PRODUCTION is already listed. Names are not case-sensitive.')).toBeVisible(); + }); + + await test.step('notifications can be turned off for a listed environment and persist', async () => { + const notify = page.getByRole('switch', { name: 'Send notifications for Production' }); + await expect(notify).toBeChecked(); + const saved = page.waitForResponse( + (response) => response.request().method() === 'PATCH' && response.url().includes(`/projects/${projectId}/environments`) + ); + await notify.click(); + expect((await saved).ok()).toBe(true); + + await page.reload(); + await expect(page.getByRole('switch', { name: 'Send notifications for Production' })).not.toBeChecked(); + }); + + await test.step('event filters offer the listed casing', async () => { + await page.goto(`/event?project=${projectId}&time=all`); + await page.getByRole('button', { name: 'Manage filters' }).click(); + await page.getByPlaceholder('Search...').fill('Environment'); + await page.getByText('Environment', { exact: true }).click(); + + await expect(page.getByRole('option', { exact: true, name: 'Production' })).toBeVisible(); + await expect(page.getByRole('option', { exact: true, name: 'staging' })).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + await test.step('discarding unlisted environments reports the names that were rejected', async () => { + await page.goto(`/project/${projectId}/environments`); + const discard = page.getByRole('switch', { name: 'Discard events from environments that are not listed' }); + const settingsSaved = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().endsWith('/environments/settings')); + await discard.click(); + expect((await settingsSaved).ok()).toBe(true); + await expect(page.getByRole('switch', { name: 'Also discard events that have no environment' })).toBeEnabled(); + + const referenceId = createReferenceId(run, '-managed-env-typo'); + await e2eApi.submitEvent(projectId, projectToken, { + environment: 'Prodution', + message: `Managed environment typo ${run}`, + reference_id: referenceId, + source: 'managed-environment-test', + type: 'log' + }); + + await expect(async () => { + await page.reload(); + await expect(page.getByRole('button', { name: 'Allow Prodution' })).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 60_000 }); + }); + + await test.step('allowing a discarded name lists it and clears it from the discarded events', async () => { + await page.getByRole('button', { name: 'Allow Prodution' }).click(); + + await expect(page.getByText('Added Prodution.')).toBeVisible(); + await expect(page.getByRole('row', { name: /^Prodution Listed/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Allow Prodution' })).toBeHidden(); + }); + + await test.step('removing a listed name keeps its events', async () => { + await page.getByRole('button', { name: 'Remove Prodution' }).click(); + await page.getByRole('button', { name: 'Remove Environment' }).click(); + + await expect(page.getByText('Removed Prodution. Its events were not changed.')).toBeVisible(); + await expect(page.getByRole('row', { name: /^Prodution Listed/ })).toBeHidden(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte index 1f4802ffd0..5453628ced 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte @@ -7,6 +7,8 @@ import * as FacetedFilter from '#comp/faceted-filter/index.ts'; import { getOrganizationCountQuery } from '#features/events/api.svelte.ts'; import { organization } from '#features/organizations/context.svelte.ts'; + import { getOrganizationProjectsQuery } from '#features/projects/api.svelte.ts'; + import { getEnvironmentKey, getListedEnvironmentNames, hasControlCharacters } from '#features/projects/environments.ts'; import { terms } from '#features/shared/api/aggregations.ts'; import { getProjectScopeFilter } from './helpers.svelte'; @@ -31,28 +33,41 @@ } } }); - const names = $derived( - [...new Set([...filter.value, ...(terms(countQuery.data?.aggregations, 'terms_environment')?.buckets?.map((bucket) => bucket.key) ?? [])])] - .filter(Boolean) - .sort() + const projectsQuery = getOrganizationProjectsQuery({ + enabled: () => !!organization.current, + route: { + get organizationId() { + return organization.current; + } + } + }); + // Listed environments show in the casing the project chose. Matching stays case-insensitive. + const listedNames = $derived( + getListedEnvironmentNames(projectsQuery.data?.data ?? [], getProjectScopeFilter(scope?.filters ?? []).match(/[a-fA-F0-9]{24}/g) ?? undefined) ); + // A selected value wins over the aggregation key with the same name so it stays selected. + const names = $derived.by(() => { + const candidates = [...filter.value, ...(terms(countQuery.data?.aggregations, 'terms_environment')?.buckets?.map((bucket) => bucket.key) ?? [])]; + return candidates + .filter((name, index) => !!name && candidates.findIndex((other) => getEnvironmentKey(other) === getEnvironmentKey(name)) === index) + .sort(); + }); const options = $derived([ { label: 'Unspecified', value: '' }, ...names.map((name) => ({ - label: name, + label: listedNames.get(getEnvironmentKey(name)) ?? name, value: name })) ]); function createOption(search: string) { const value = search.trim().toLowerCase(); - // eslint-disable-next-line no-control-regex -- Deployment names cannot contain control characters. - return value && value.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(value) + return value && value.length <= 64 && !hasControlCharacters(value) ? { - label: value, + label: listedNames.get(value) ?? value, value } : undefined; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte index ca60c6a077..ef9b413baf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte @@ -1,17 +1,19 @@ {#if changed} {:else} - {environment ?? 'Unspecified'} + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index 557b9f1f3e..ffe41afeed 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -190,7 +190,8 @@ export function getColumns renderComponent(EventEnvironmentCell, { changed: options?.onEnvironmentClick, - environment: prop.getValue() + environment: prop.getValue(), + projectId: getProjectId(prop.row.original) }), header: 'Environment', id: 'environment', @@ -378,6 +379,10 @@ function getProject>(sum return eventSummary.project_name ?? eventSummary.project_id; } +function getProjectId>(summary: TSummaryModel): string | undefined { + return (summary as Partial, 'project_id'>> & TSummaryModel).project_id; +} + function getSource>(summary: TSummaryModel): string | undefined { return getSummaryDataValue(summary, 'SourceShortName') ?? getSummaryDataValue(summary, 'Source'); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte index 7000be7e34..16d7132f18 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte @@ -19,6 +19,7 @@ getStackTrace, hasErrorOrSimpleError } from '#features/events/persistent-event.ts'; + import EnvironmentName from '#features/projects/components/environment-name.svelte'; import TagList from '#features/shared/components/tag-list.svelte'; import type { PersistentEvent } from '../../models/index'; @@ -78,7 +79,7 @@ Environment - {event.environment} + {/if} {#if isSessionStart} 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 a09d43ad55..86ecedcbc4 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 @@ -21,12 +21,16 @@ created_utc: '2024-01-01T00:00:00Z', data: undefined, delete_bot_data_enabled: false, + discard_events_without_environment: false, + discard_unlisted_environments: false, + environments: [], event_count: 150, has_premium_features: false, has_slack_integration: false, id: '1', is_configured: false, name: 'Frontend App', + notify_other_environments: true, organization_id: 'org1', organization_name: 'Test Org', promoted_tabs: [], diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts index 7edc6537e6..6116ba1261 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts @@ -24,11 +24,15 @@ describe('project read lifecycle', () => { const project: ViewProject = { created_utc: '2026-09-05T00:00:00Z', delete_bot_data_enabled: false, + discard_events_without_environment: false, + discard_unlisted_environments: false, + environments: [], event_count: 0, has_premium_features: false, has_slack_integration: false, id: 'project-id', name: 'Example', + notify_other_environments: true, organization_id: 'organization-id', organization_name: 'Example organization', promoted_tabs: [], diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index 28a3a0f4c9..f50e4ed499 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -1,7 +1,19 @@ import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; -import type { ClientConfiguration, NewProject, NotificationSettings, SourceMapArtifact, UpdateProject, ViewProject } from '#features/projects/models.ts'; +import type { + ClientConfiguration, + NewProject, + NewProjectEnvironment, + NotificationSettings, + ProjectDiscardedEnvironments, + ProjectEnvironmentSettings, + SourceMapArtifact, + UpdateProject, + UpdateProjectEnvironment, + UpdateProjectEnvironmentSettings, + ViewProject +} from '#features/projects/models.ts'; import type { StringValueFromBody, WorkInProgressResult } from '#features/shared/models/index.ts'; import type { WebSocketMessageValue } from '#features/websockets/models.ts'; @@ -50,11 +62,13 @@ export async function invalidateProjectSummaryQueries(queryClient: QueryClient): // TODO: Do we need to scope these all by organization? export const queryKeys = { + addEnvironment: (id: string | undefined) => [...queryKeys.id(id), 'add-environment'] as const, config: (id: string | undefined) => [...queryKeys.id(id), 'config'] as const, deleteConfig: (id: string | undefined) => [...queryKeys.id(id), 'delete-config'] as const, deleteProject: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const, deletePromotedTab: (id: string | undefined) => [...queryKeys.id(id), 'demote-tab'] as const, deleteSlack: (id: string | undefined) => [...queryKeys.id(id), 'delete-slack'] as const, + discardedEnvironments: (id: string | undefined) => [...queryKeys.id(id), 'discarded-environments'] as const, generateSampleData: (id: string | undefined) => [...queryKeys.id(id), 'sample-data'] as const, id: (id: string | undefined) => [...queryKeys.type, id] as const, ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const, @@ -68,9 +82,12 @@ export const queryKeys = { projects: () => [...queryKeys.type, 'projects'] as const, putIntegrationNotificationSettings: (id: string | undefined, integration: string) => [...queryKeys.id(id), integration, 'put-notification-settings'] as const, + removeEnvironment: (id: string | undefined) => [...queryKeys.id(id), 'remove-environment'] as const, resetData: (id: string | undefined) => [...queryKeys.id(id), 'reset-data'] as const, sourceMaps: (id: string | undefined) => [...queryKeys.id(id), 'source-maps'] as const, type: ['Project'] as const, + updateEnvironment: (id: string | undefined) => [...queryKeys.id(id), 'update-environment'] as const, + updateEnvironmentSettings: (id: string | undefined) => [...queryKeys.id(id), 'update-environment-settings'] as const, userNotificationSettings: (id: string | undefined, userId: string | undefined) => [...queryKeys.id(id), userId, 'notification-settings'] as const }; export interface DeleteConfigParams { @@ -105,6 +122,12 @@ export interface DeleteSlackRequest { }; } +export interface EnvironmentRequest { + route: { + id: string | undefined; + }; +} + export interface GenerateSampleDataRequest { route: { id: string | undefined; @@ -288,6 +311,26 @@ export function deleteProjectConfig(request: DeleteConfigRequest) { })); } +export function deleteProjectEnvironment(request: EnvironmentRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async (name: string) => { + const client = useFetchClient(); + const response = await client.deleteJSON(`projects/${request.route.id}/environments`, { + params: { + name + } + }); + return response.data!; + }, + mutationKey: queryKeys.removeEnvironment(request.route.id), + onError: () => invalidateEnvironmentQueries(queryClient, request.route.id), + onSuccess: (settings: ProjectEnvironmentSettings) => applyEnvironmentSettings(queryClient, request.route.id, settings) + })); +} + export function deletePromotedTab(request: DeletePromotedTabRequest) { const queryClient = useQueryClient(); return createMutation(() => ({ @@ -425,6 +468,20 @@ export function getProjectConfig(request: GetProjectConfigRequest) { })); } +export function getProjectDiscardedEnvironments(request: EnvironmentRequest) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`projects/${request.route.id}/environments/discarded`, { + signal + }); + return response.data!; + }, + queryKey: queryKeys.discardedEnvironments(request.route.id) + })); +} + export function getProjectIntegrationNotificationSettings(request: GetProjectIntegrationNotificationSettingsRequest) { return createQuery(() => ({ enabled: () => !!accessToken.current && !!request.route.id, @@ -521,6 +578,26 @@ export function getSourceMapsQuery(request: SourceMapRequest) { })); } +export function patchProjectEnvironment(request: EnvironmentRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async ({ changes, name }) => { + const client = useFetchClient(); + const response = await client.patchJSON(`projects/${request.route.id}/environments`, changes, { + params: { + name + } + }); + return response.data!; + }, + mutationKey: queryKeys.updateEnvironment(request.route.id), + onError: () => invalidateEnvironmentQueries(queryClient, request.route.id), + onSuccess: (settings: ProjectEnvironmentSettings) => applyEnvironmentSettings(queryClient, request.route.id, settings) + })); +} + export function postProject() { const queryClient = useQueryClient(); @@ -589,6 +666,21 @@ export function postProjectConfig(request: PostConfigRequest) { })); } +export function postProjectEnvironment(request: EnvironmentRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async (environment: NewProjectEnvironment) => { + const client = useFetchClient(); + const response = await client.postJSON(`projects/${request.route.id}/environments`, environment); + return response.data!; + }, + mutationKey: queryKeys.addEnvironment(request.route.id), + onSuccess: (settings: ProjectEnvironmentSettings) => applyEnvironmentSettings(queryClient, request.route.id, settings) + })); +} + export function postProjectUserNotificationSettings(request: PostProjectUserNotificationSettingsRequest) { const queryClient = useQueryClient(); @@ -689,6 +781,22 @@ export function postSourceMapMutation(request: SourceMapRequest) { })); } +export function putProjectEnvironmentSettings(request: EnvironmentRequest) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async (settings: UpdateProjectEnvironmentSettings) => { + const client = useFetchClient(); + const response = await client.putJSON(`projects/${request.route.id}/environments/settings`, settings); + return response.data!; + }, + mutationKey: queryKeys.updateEnvironmentSettings(request.route.id), + onError: () => invalidateEnvironmentQueries(queryClient, request.route.id), + onSuccess: (settings: ProjectEnvironmentSettings) => applyEnvironmentSettings(queryClient, request.route.id, settings) + })); +} + export function putProjectIntegrationNotificationSettings(request: PutProjectIntegrationNotificationSettingsRequest) { const queryClient = useQueryClient(); @@ -750,3 +858,36 @@ export function updateProject(request: UpdateProjectRequest) { } })); } + +/** Shows the saved environment settings on the project right away, then refreshes everything that lists environments. */ +async function applyEnvironmentSettings(queryClient: QueryClient, id: string | undefined, settings: ProjectEnvironmentSettings) { + const project = queryClient.getQueryData(queryKeys.id(id)); + if (project) { + queryClient.setQueryData(queryKeys.id(id), { + ...project, + discard_events_without_environment: settings.discard_events_without_environment, + discard_unlisted_environments: settings.discard_unlisted_environments, + environments: settings.environments, + notify_other_environments: settings.notify_other_environments + }); + } + + await invalidateEnvironmentQueries(queryClient, id, project?.organization_id); +} + +async function invalidateEnvironmentQueries(queryClient: QueryClient, id: string | undefined, organizationId?: string) { + await Promise.all([ + // The project key is a prefix of the discarded names key, so this refreshes both. + queryClient.invalidateQueries({ + queryKey: queryKeys.id(id) + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.projects() + }), + organizationId + ? queryClient.invalidateQueries({ + queryKey: queryKeys.organization(organizationId) + }) + : Promise.resolve() + ]); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte new file mode 100644 index 0000000000..3cec044613 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte @@ -0,0 +1,27 @@ + + +{name} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte.test.ts new file mode 100644 index 0000000000..4954327810 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte.test.ts @@ -0,0 +1,58 @@ +import { cleanup, render } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getProjectQuery: vi.fn() +})); + +vi.mock('#features/projects/api.svelte.ts', () => ({ + getProjectQuery: mocks.getProjectQuery +})); + +import EnvironmentName from './environment-name.svelte'; + +describe('EnvironmentName', () => { + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + it.each([ + ['production', 'Production'], + ['PRODUCTION', 'Production'], + ['qa-West', 'qa-West'] + ])('shows %s as %s using the listed casing', (environment, expected) => { + // Arrange + mocks.getProjectQuery.mockReturnValue({ data: { environments: [{ name: 'Production', notify: true }] } }); + + // Act + const { container } = render(EnvironmentName, { environment, projectId: 'project-id' }); + + // Assert + expect(container.textContent?.trim()).toBe(expected); + }); + + it('shows the placeholder without loading the project when there is no environment', () => { + // Arrange + mocks.getProjectQuery.mockReturnValue({ data: undefined }); + + // Act + const { container } = render(EnvironmentName, { projectId: 'project-id' }); + const request = mocks.getProjectQuery.mock.calls[0]![0] as { route: { id: string | undefined } }; + + // Assert + expect(container.textContent?.trim()).toBe('Unspecified'); + expect(request.route.id).toBeUndefined(); + }); + + it('keeps the event casing until the project has loaded', () => { + // Arrange + mocks.getProjectQuery.mockReturnValue({ data: undefined }); + + // Act + const { container } = render(EnvironmentName, { environment: 'production', projectId: 'project-id' }); + + // Assert + expect(container.textContent?.trim()).toBe('production'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte new file mode 100644 index 0000000000..60f6c45a14 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte @@ -0,0 +1,123 @@ + + +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} +> + state.errors}> + {#snippet children(errors)} +
+ {/snippet} +
+ + + {#snippet children(field)} + + Environment name + field.handleChange(event.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + + {#snippet children(field)} + + field.handleChange(checked)} /> + Send notifications + + {/snippet} + + + state.isSubmitting}> + {#snippet children(isSubmitting)} + + {/snippet} + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte new file mode 100644 index 0000000000..39e2825f02 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte @@ -0,0 +1,93 @@ + + +
+ + + + Environment + Events discarded ({retentionDays} days) + Last seen + Actions + + + + {#if isLoading} + + {:else if isError} + + {:else if !hasRows} + No events have been discarded because of their environment in the last {retentionDays} days. + {:else} + {#each named as environment (environment.key)} + + {environment.name ?? environment.key} + {environment.count.toLocaleString()} + + + + + + {/each} + {#if unspecified} + + No environment + {unspecified.count.toLocaleString()} + + + + + + {/if} + {/if} + + +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environment-settings.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environment-settings.svelte new file mode 100644 index 0000000000..512690fff6 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environment-settings.svelte @@ -0,0 +1,71 @@ + + +
+
+ + update({ + discard_unlisted_environments: checked + })} + /> +
+ +

+ Events whose environment is not in the list are dropped before they are stored, so they never create stacks. They count as discarded events in + your usage, not toward your plan limit. Matching ignores case. +

+
+
+ +
+ + update({ + discard_events_without_environment: checked + })} + /> +
+ +

+ Only applies while events from unlisted environments are discarded. Clients that do not send an environment will stop being recorded. +

+
+
+ +
+ + update({ + notify_other_environments: checked + })} + /> +
+ +

Covers environments that are not listed and events without an environment.

+
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments-table.svelte new file mode 100644 index 0000000000..59c29a255e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments-table.svelte @@ -0,0 +1,99 @@ + + +
+ + + + Environment + Listed + Notifications + Last seen + Events (30 days) + Actions + + + + {#if isLoading} + + {:else if isError} + + {:else if rows.length === 0} + No environments are listed and none have been seen in the last 30 days. + {:else} + {#each rows as row (row.key)} + + {row.name} + + {row.isListed ? 'Listed' : 'Not listed'} + + + {#if row.isListed} +
+ toggleNotify(row, checked)} + /> + {row.notify ? 'On' : 'Off'} +
+ {:else} + {notifyOtherEnvironments ? 'On' : 'Off'} (other environments) + {/if} +
+ + {#if row.lastSeen} + + {:else} + No recent events + {/if} + + {row.count.toLocaleString()} + + {#if row.isListed} + + {:else} + + {/if} + +
+ {/each} + {/if} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts new file mode 100644 index 0000000000..79bc651033 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts @@ -0,0 +1,155 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import AddEnvironmentForm from './add-environment-form.svelte'; +import DiscardedEnvironmentsTable from './discarded-environments-table.svelte'; +import EnvironmentSettings from './environment-settings.svelte'; +import EnvironmentsTable from './environments-table.svelte'; + +describe('EnvironmentsTable', () => { + afterEach(() => cleanup()); + + const rows = [ + { count: 120, isListed: true, key: 'production', lastSeen: '2026-11-02T10:00:00Z', name: 'Production', notify: true }, + { count: 0, isListed: true, key: 'dev', name: 'dev', notify: false }, + { count: 3, isListed: false, key: 'prodution', lastSeen: '2026-11-02T09:00:00Z', name: 'prodution' } + ]; + + it('shows listed names in their stored casing and offers Add only for unlisted names', async () => { + // Arrange + const add = vi.fn(); + const remove = vi.fn(); + render(EnvironmentsTable, { add, notifyOtherEnvironments: false, remove, rows, toggleNotify: vi.fn() }); + + // Act + await userEvent.click(screen.getByRole('button', { name: 'Add prodution' })); + await userEvent.click(screen.getByRole('button', { name: 'Remove Production' })); + + // Assert + expect(screen.getByText('Production')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Add Production' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Remove prodution' })).toBeNull(); + expect(screen.getByText('Off (other environments)')).toBeTruthy(); + expect(add).toHaveBeenCalledWith(rows[2]); + expect(remove).toHaveBeenCalledWith(rows[0]); + }); + + it('toggles notifications for a listed environment', async () => { + // Arrange + const toggleNotify = vi.fn(); + render(EnvironmentsTable, { add: vi.fn(), notifyOtherEnvironments: true, remove: vi.fn(), rows, toggleNotify }); + + // Act + await userEvent.click(screen.getByRole('switch', { name: 'Send notifications for dev' })); + + // Assert + expect(toggleNotify).toHaveBeenCalledWith(rows[1], true); + }); +}); + +describe('DiscardedEnvironmentsTable', () => { + afterEach(() => cleanup()); + + it('shows counts and lets the owner allow a name or stop discarding events without an environment', async () => { + // Arrange + const allow = vi.fn(); + const stopDiscardingUnspecified = vi.fn(); + const named = [{ count: 3412, key: 'prodution', last_seen_utc: '2026-11-02T09:00:00Z', name: 'Prodution' }]; + const unspecified = { count: 8, last_seen_utc: '2026-11-02T09:00:00Z' }; + render(DiscardedEnvironmentsTable, { allow, named, retentionDays: 7, stopDiscardingUnspecified, unspecified }); + + // Act + await userEvent.click(screen.getByRole('button', { name: 'Allow Prodution' })); + await userEvent.click(screen.getByRole('button', { name: 'Stop discarding events without an environment' })); + + // Assert + expect(screen.getByText((3412).toLocaleString())).toBeTruthy(); + expect(screen.getByText('Events discarded (7 days)')).toBeTruthy(); + expect(allow).toHaveBeenCalledWith(named[0]); + expect(stopDiscardingUnspecified).toHaveBeenCalledOnce(); + }); + + it('explains when nothing has been discarded', () => { + // Act + render(DiscardedEnvironmentsTable, { allow: vi.fn(), named: [], retentionDays: 7, stopDiscardingUnspecified: vi.fn() }); + + // Assert + expect(screen.getByText(/No events have been discarded because of their environment in the last 7 days/)).toBeTruthy(); + }); +}); + +describe('EnvironmentSettings', () => { + afterEach(() => cleanup()); + + it('only allows discarding events without an environment while unlisted environments are discarded', async () => { + // Arrange + const update = vi.fn(); + render(EnvironmentSettings, { + discardEventsWithoutEnvironment: false, + discardUnlistedEnvironments: false, + notifyOtherEnvironments: true, + update + }); + const withoutEnvironment = screen.getByRole('switch', { name: 'Also discard events that have no environment' }); + + // Act + await userEvent.click(withoutEnvironment); + await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + await userEvent.click(screen.getByRole('switch', { name: 'Send notifications for other environments' })); + + // Assert + expect(withoutEnvironment.hasAttribute('disabled')).toBe(true); + expect(update).toHaveBeenNthCalledWith(1, { discard_unlisted_environments: true }); + expect(update).toHaveBeenNthCalledWith(2, { notify_other_environments: false }); + expect(update).toHaveBeenCalledTimes(2); + }); +}); + +describe('AddEnvironmentForm', () => { + afterEach(() => cleanup()); + + it('adds the trimmed name with the chosen notification setting', async () => { + // Arrange + const add = vi.fn().mockResolvedValue(undefined); + render(AddEnvironmentForm, { add, listedNames: ['Production'] }); + + // Act + await userEvent.type(screen.getByLabelText('Environment name'), ' Staging West '); + await userEvent.click(screen.getByRole('switch', { name: 'Send notifications' })); + await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + + // Assert + await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'Staging West', notify: false })); + }); + + it.each([ + ['production', 'production is already listed'], + [' PRODUCTION ', 'PRODUCTION is already listed'] + ])('rejects %j because names are not case-sensitive', async (name, message) => { + // Arrange + const add = vi.fn(); + render(AddEnvironmentForm, { add, listedNames: ['Production'] }); + + // Act + await userEvent.type(screen.getByLabelText('Environment name'), name); + await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + + // Assert + expect(await screen.findByText(new RegExp(message))).toBeTruthy(); + expect(add).not.toHaveBeenCalled(); + }); + + it('requires a name', async () => { + // Arrange + const add = vi.fn(); + render(AddEnvironmentForm, { add, listedNames: [] }); + + // Act + await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + + // Assert + expect(await screen.findByText('Name is required')).toBeTruthy(); + expect(add).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts new file mode 100644 index 0000000000..23b3c95433 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildEnvironmentRows, + getEnvironmentDisplayName, + getListedEnvironmentNames, + normalizeEnvironmentName, + splitDiscardedEnvironments +} from './environments'; + +const listed = (name: string, notify = true) => ({ created_utc: '2026-11-01T00:00:00Z', name, notify }); + +describe('buildEnvironmentRows', () => { + it('merges listed and observed environments case-insensitively and keeps the listed casing', () => { + // Arrange + const environments = [listed('Staging', false), listed('Production')]; + const observed = [ + { count: 120, key: 'production', lastSeen: '2026-11-02T10:00:00Z' }, + { count: 3, key: 'prodution', lastSeen: '2026-11-02T09:00:00Z' }, + { count: 40, key: 'qa-west' } + ]; + + // Act + const rows = buildEnvironmentRows(environments, observed); + + // Assert + expect(rows.map((row) => row.name)).toEqual(['Production', 'Staging', 'qa-west', 'prodution']); + expect(rows[0]).toMatchObject({ count: 120, isListed: true, key: 'production', lastSeen: '2026-11-02T10:00:00Z', notify: true }); + expect(rows[1]).toMatchObject({ count: 0, isListed: true, lastSeen: undefined, notify: false }); + expect(rows[2]).toMatchObject({ count: 40, isListed: false }); + expect(rows[2]!.notify).toBeUndefined(); + }); + + it('lists environments that have no events yet and handles missing lists', () => { + // Act & Assert + expect(buildEnvironmentRows([listed('dev')], []).map((row) => row.name)).toEqual(['dev']); + expect(buildEnvironmentRows(undefined, [])).toEqual([]); + }); +}); + +describe('getEnvironmentDisplayName', () => { + it.each([ + ['production', 'Production'], + ['PRODUCTION', 'Production'], + ['qa-west', 'qa-west'], + ['QA-West', 'QA-West'] + ])('shows %s as %s', (name, expected) => { + expect(getEnvironmentDisplayName([listed('Production')], name)).toBe(expected); + }); + + it('returns the name when the project has no list', () => { + expect(getEnvironmentDisplayName(undefined, 'Staging')).toBe('Staging'); + }); +}); + +describe('getListedEnvironmentNames', () => { + it('uses the first project to decide the casing and can be limited to specific projects', () => { + // Arrange + const projects = [ + { environments: [listed('Production')], id: 'one' }, + { environments: [listed('PRODUCTION'), listed('Staging')], id: 'two' }, + { environments: undefined, id: 'three' } + ]; + + // Act & Assert + expect([...getListedEnvironmentNames(projects)]).toEqual([ + ['production', 'Production'], + ['staging', 'Staging'] + ]); + expect(getListedEnvironmentNames(projects, ['two']).get('production')).toBe('PRODUCTION'); + }); +}); + +describe('normalizeEnvironmentName', () => { + it.each([ + [' Production ', 'Production'], + ['qa west', 'qa west'], + ['a'.repeat(64), 'a'.repeat(64)], + ['', undefined], + [' ', undefined], + [undefined, undefined], + ['a'.repeat(65), undefined], + ['prod\tuction', undefined], + ['prod\nuction', undefined] + ])('normalizes %j to %j', (value, expected) => { + expect(normalizeEnvironmentName(value)).toBe(expected); + }); +}); + +describe('splitDiscardedEnvironments', () => { + it('separates the bucket without an environment and orders names by count', () => { + // Arrange + const discarded = [ + { count: 2, key: 'dev', last_seen_utc: '2026-11-02T00:00:00Z', name: 'dev' }, + { count: 9, last_seen_utc: '2026-11-02T00:00:00Z' }, + { count: 7, key: 'prodution', last_seen_utc: '2026-11-02T00:00:00Z', name: 'Prodution' } + ]; + + // Act + const { named, unspecified } = splitDiscardedEnvironments(discarded); + + // Assert + expect(named.map((environment) => environment.key)).toEqual(['prodution', 'dev']); + expect(unspecified?.count).toBe(9); + expect(splitDiscardedEnvironments(undefined)).toEqual({ named: [], unspecified: undefined }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts new file mode 100644 index 0000000000..05ec91b265 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts @@ -0,0 +1,120 @@ +import type { DiscardedEnvironment, ProjectEnvironment } from '#generated/api.ts'; + +export const MAX_ENVIRONMENT_NAME_LENGTH = 64; +export const MAX_LISTED_ENVIRONMENTS = 100; + +export interface EnvironmentRow { + count: number; + isListed: boolean; + key: string; + lastSeen?: string; + /** The listed casing, otherwise the aggregation key. */ + name: string; + /** Only set for listed environments. Unlisted ones follow the project's other-environments setting. */ + notify?: boolean; +} + +export interface ObservedEnvironment { + count: number; + key: string; + lastSeen?: string; +} + +interface ProjectWithEnvironments { + environments?: null | ProjectEnvironment[]; + id: string; +} + +/** + * Combines the listed environments with the ones seen in event data. Listed names keep their stored casing and come first; + * names that are only observed use the lowercase aggregation key and are ordered by how many events they have. + */ +export function buildEnvironmentRows(listed: null | ProjectEnvironment[] | undefined, observed: ObservedEnvironment[]): EnvironmentRow[] { + const observedByKey = new Map(observed.map((environment) => [getEnvironmentKey(environment.key), environment])); + const listedKeys = new Set(); + const listedRows = (listed ?? []) + .map((environment) => { + const key = getEnvironmentKey(environment.name); + listedKeys.add(key); + const seen = observedByKey.get(key); + + return { + count: seen?.count ?? 0, + isListed: true, + key, + lastSeen: seen?.lastSeen, + name: environment.name, + notify: environment.notify + } satisfies EnvironmentRow; + }) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.name.localeCompare(b.name)); + + const unlistedRows = observed + .filter((environment) => !listedKeys.has(getEnvironmentKey(environment.key))) + .map((environment) => ({ + count: environment.count, + isListed: false, + key: getEnvironmentKey(environment.key), + lastSeen: environment.lastSeen, + name: environment.key + })) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); + + return [...listedRows, ...unlistedRows]; +} + +/** Returns the listed casing for a listed environment, otherwise the name as it was given. */ +export function getEnvironmentDisplayName(environments: null | ProjectEnvironment[] | undefined, name: string): string { + const key = getEnvironmentKey(name); + return environments?.find((environment) => getEnvironmentKey(environment.name) === key)?.name ?? name; +} + +/** The case-insensitive key for a name. Event aggregations return lowercase keys. */ +export function getEnvironmentKey(name: string): string { + return name.toLowerCase(); +} + +/** + * Maps environment keys to their listed casing. When projects disagree about the casing, the first project wins. + * Pass the project ids a view is limited to so unrelated projects do not decide the casing. + */ +export function getListedEnvironmentNames(projects: ProjectWithEnvironments[], projectIds?: string[]): Map { + const names = new Map(); + for (const project of projects) { + if (projectIds?.length && !projectIds.includes(project.id)) { + continue; + } + + for (const environment of project.environments ?? []) { + const key = getEnvironmentKey(environment.name); + if (!names.has(key)) { + names.set(key, environment.name); + } + } + } + + return names; +} + +export function hasControlCharacters(value: string): boolean { + // eslint-disable-next-line no-control-regex -- Environment names cannot contain control characters. + return /[\u0000-\u001f\u007f-\u009f]/u.test(value); +} + +/** Applies the same rules as the server: trimmed, 1 to 64 characters, no control characters. */ +export function normalizeEnvironmentName(value: null | string | undefined): string | undefined { + const name = value?.trim(); + return name && name.length <= MAX_ENVIRONMENT_NAME_LENGTH && !hasControlCharacters(name) ? name : undefined; +} + +/** Discarded names with a name, most discarded first. The bucket without an environment has no name. */ +export function splitDiscardedEnvironments(discarded: DiscardedEnvironment[] | null | undefined): { + named: DiscardedEnvironment[]; + unspecified?: DiscardedEnvironment; +} { + const all = discarded ?? []; + return { + named: all.filter((environment) => !!environment.key).sort((a, b) => b.count - a.count), + unspecified: all.find((environment) => !environment.key) + }; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts index 32a7ec647e..07d1f2c7dd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/models.ts @@ -1,4 +1,17 @@ -export type { ClientConfiguration, NewProject, NotificationSettings, UpdateProject, ViewProject } from '#generated/api.ts'; +export type { + ClientConfiguration, + DiscardedEnvironment, + NewProject, + NewProjectEnvironment, + NotificationSettings, + ProjectDiscardedEnvironments, + ProjectEnvironment, + ProjectEnvironmentSettings, + UpdateProject, + UpdateProjectEnvironment, + UpdateProjectEnvironmentSettings, + ViewProject +} from '#generated/api.ts'; export interface ClientConfigurationSetting { key: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts index 05315abf68..669490bd17 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts @@ -1,4 +1,4 @@ -import { custom, type infer as Infer, object, string } from 'zod'; +import { boolean, custom, type infer as Infer, object, string } from 'zod'; import { UpdateProjectSchema as GeneratedUpdateProjectSchema, @@ -8,6 +8,8 @@ import { NotificationSettingsSchema } from '#generated/schemas.ts'; +import { hasControlCharacters, MAX_ENVIRONMENT_NAME_LENGTH } from './environments'; + export { type NewProjectFormData, NewProjectSchema, type NotificationSettingsFormData, NotificationSettingsSchema }; export const ClientConfigurationSettingSchema = object({ @@ -24,3 +26,13 @@ export const SourceMapUploadSchema = object({ generated_file_url: string().url('Enter the absolute URL of the generated JavaScript file') }); export type SourceMapUploadFormData = Infer; + +export const AddEnvironmentSchema = object({ + name: string() + .trim() + .min(1, 'Name is required') + .max(MAX_ENVIRONMENT_NAME_LENGTH, `Name must be at most ${MAX_ENVIRONMENT_NAME_LENGTH} characters`) + .refine((name) => !hasControlCharacters(name), 'Name cannot contain control characters'), + notify: boolean() +}); +export type AddEnvironmentFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts index 6fe7350f69..21636aeee3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/sessions/components/session-table-columns.ts @@ -6,6 +6,7 @@ import TimeAgo from '#comp/formatters/time-ago.svelte'; import { Checkbox } from '#comp/ui/checkbox/index.ts'; import Summary from '#features/events/components/summary/summary.svelte'; import EventsUserIdentitySummaryCell from '#features/events/components/table/events-user-identity-summary-cell.svelte'; +import EnvironmentName from '#features/projects/components/environment-name.svelte'; import SessionDurationCell from './session-duration-cell.svelte'; @@ -77,7 +78,7 @@ export function getSessionColumns(): ColumnDef prop.getValue() ?? 'Unspecified', + cell: (prop) => renderComponent(EnvironmentName, { environment: prop.getValue(), projectId: prop.row.original.project_id }), header: 'Environment', id: 'environment', maxSize: 640, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 4010d85b3e..68aaed416f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -163,6 +163,21 @@ export interface CountResult { data?: null | object; } +/** An environment name that recently had events discarded because it is not listed on the project. */ +export interface DiscardedEnvironment { + /** The lowercased key for the environment, or null for events that had no environment. */ + key?: null | string; + /** The first-seen casing of the name, or null for events that had no environment. */ + name?: null | string; + /** + * The number of events discarded within the retention window. + * @format int64 + */ + count: number; + /** @format date-time */ + last_seen_utc: string; +} + export interface EventSubmissionSettings { enabled: boolean; configured_enabled: boolean; @@ -253,6 +268,13 @@ export interface NewProject { promoted_tabs?: string[] | null; } +export interface NewProjectEnvironment { + /** The environment name. It is trimmed, limited to 64 characters, and is matched case-insensitively against event environments. */ + name: string; + /** If true, events from this environment can send notifications. */ + notify: boolean; +} + export interface NewSavedView { /** @pattern ^[a-fA-F0-9]{24}$ */ organization_id: string; @@ -507,6 +529,32 @@ export interface ProblemDetails { instance?: null | string; } +export interface ProjectDiscardedEnvironments { + /** + * The number of days that discarded environment names are remembered. + * @format int32 + */ + retention_days: number; + environments: DiscardedEnvironment[]; +} + +/** A deployment environment that a project has chosen to list, such as production or staging. */ +export interface ProjectEnvironment { + /** The environment name in the exact casing that was entered. Matching against event environments is case-insensitive. */ + name: string; + /** If true, events from this environment can send notifications. */ + notify: boolean; + /** @format date-time */ + created_utc: string; +} + +export interface ProjectEnvironmentSettings { + environments: ProjectEnvironment[]; + discard_unlisted_environments: boolean; + discard_events_without_environment: boolean; + notify_other_environments: boolean; +} + export interface RecordProductTourResult { /** @format date-time */ recorded_utc: string; @@ -621,6 +669,12 @@ export interface Stack { description?: null | string; /** If true, all future occurrences will be marked as critical. */ occurrences_are_critical: boolean; + /** + * True when the stack was created by an event from an environment that does not send notifications and no notifying + * environment has reported it yet. The first occurrence in a notifying environment clears this and is reported as new. + * Stacks created before this field existed are not pending, so they never produce a new notification for it. + */ + is_new_notification_pending: boolean; /** A list of references. */ references: string[]; /** A list of tags used to categorize this stack. */ @@ -681,6 +735,20 @@ export interface UpdateProject { promoted_tabs?: string[] | null; } +export interface UpdateProjectEnvironment { + /** If true, events from this environment can send notifications. */ + notify?: null | boolean; +} + +export interface UpdateProjectEnvironmentSettings { + /** If true, events whose environment is not listed are discarded. */ + discard_unlisted_environments?: null | boolean; + /** If true, events without an environment are also discarded. Only applies while unlisted environments are discarded. */ + discard_events_without_environment?: null | boolean; + /** If true, events from environments that are not listed and events without an environment can send notifications. */ + notify_other_environments?: null | boolean; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateSavedView { name?: null | string; @@ -755,6 +823,12 @@ export interface User { id: string; /** The organizations that the user has access to. */ organization_ids: string[]; + /** + * Session revocation generation, rotated when a password changes or resets, or an external login is removed or transferred away. + * Remains null until rotation; active users with a null generation accept only matching legacy null token generations. + * This value does not describe the password hashing format. + */ + authentication_version?: null | string; password?: null | string; salt?: null | string; password_reset_token?: null | string; @@ -919,6 +993,11 @@ export interface ViewProject { organization_name: string; name: string; delete_bot_data_enabled: boolean; + /** The environments that the project lists, in the casing they were entered. Change them with the project environment endpoints. */ + environments: ProjectEnvironment[]; + discard_unlisted_environments: boolean; + discard_events_without_environment: boolean; + notify_other_environments: boolean; data?: null | Record; promoted_tabs: string[]; is_configured?: null | boolean; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index a0ad26433e..8b344ca4b7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -208,6 +208,16 @@ export const CountResultSchema = object({ }); export type CountResultFormData = Infer; +export const DiscardedEnvironmentSchema = object({ + key: string().min(1, "Key is required").nullable().optional(), + name: string().min(1, "Name is required").nullable().optional(), + count: int(), + last_seen_utc: iso.datetime(), +}); +export type DiscardedEnvironmentFormData = Infer< + typeof DiscardedEnvironmentSchema +>; + export const EventSubmissionSettingsSchema = object({ enabled: boolean(), configured_enabled: boolean(), @@ -309,6 +319,14 @@ export const NewProjectSchema = object({ }); export type NewProjectFormData = Infer; +export const NewProjectEnvironmentSchema = object({ + name: string().min(1, "Name is required"), + notify: boolean(), +}); +export type NewProjectEnvironmentFormData = Infer< + typeof NewProjectEnvironmentSchema +>; + export const NewSavedViewSchema = object({ organization_id: string() .length(24, "Organization id must be exactly 24 characters") @@ -642,6 +660,33 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const ProjectDiscardedEnvironmentsSchema = object({ + retention_days: int32(), + environments: array(lazy(() => DiscardedEnvironmentSchema)), +}); +export type ProjectDiscardedEnvironmentsFormData = Infer< + typeof ProjectDiscardedEnvironmentsSchema +>; + +export const ProjectEnvironmentSchema = object({ + name: string() + .min(1, "Name is required") + .max(64, "Name must be at most 64 characters"), + notify: boolean(), + created_utc: iso.datetime(), +}); +export type ProjectEnvironmentFormData = Infer; + +export const ProjectEnvironmentSettingsSchema = object({ + environments: array(lazy(() => ProjectEnvironmentSchema)), + discard_unlisted_environments: boolean(), + discard_events_without_environment: boolean(), + notify_other_environments: boolean(), +}); +export type ProjectEnvironmentSettingsFormData = Infer< + typeof ProjectEnvironmentSettingsSchema +>; + export const RecordProductTourResultSchema = object({ recorded_utc: iso.datetime(), }); @@ -731,6 +776,7 @@ export const StackSchema = object({ last_occurrence: iso.datetime(), description: string().min(1, "Description is required").nullable().optional(), occurrences_are_critical: boolean(), + is_new_notification_pending: boolean(), references: array(string()), tags: array(string()), duplicate_signature: string().min(1, "Duplicate signature is required"), @@ -812,6 +858,22 @@ export const UpdateProjectSchema = object({ }); export type UpdateProjectFormData = Infer; +export const UpdateProjectEnvironmentSchema = object({ + notify: boolean().nullable(), +}); +export type UpdateProjectEnvironmentFormData = Infer< + typeof UpdateProjectEnvironmentSchema +>; + +export const UpdateProjectEnvironmentSettingsSchema = object({ + discard_unlisted_environments: boolean().nullable().optional(), + discard_events_without_environment: boolean().nullable().optional(), + notify_other_environments: boolean().nullable().optional(), +}); +export type UpdateProjectEnvironmentSettingsFormData = Infer< + typeof UpdateProjectEnvironmentSettingsSchema +>; + export const UpdateSavedViewSchema = object({ name: string().min(1, "Name is required").nullable().optional(), filter: string().min(1, "Filter is required").nullable().optional(), @@ -889,6 +951,7 @@ export const UserSchema = object({ .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), organization_ids: array(string()), + authentication_version: string().nullable().optional(), password: string().min(1, "Password is required").nullable().optional(), salt: string().min(1, "Salt is required").nullable().optional(), password_reset_token: string() @@ -1066,6 +1129,10 @@ export const ViewProjectSchema = object({ organization_name: string().min(1, "Organization name is required"), name: string().min(1, "Name is required"), delete_bot_data_enabled: boolean(), + environments: array(lazy(() => ProjectEnvironmentSchema)), + discard_unlisted_environments: boolean(), + discard_events_without_environment: boolean(), + notify_other_environments: boolean(), data: record(string(), unknown()).nullable().optional(), promoted_tabs: array(string()), is_configured: boolean().nullable().optional(), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte new file mode 100644 index 0000000000..3347de3309 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte @@ -0,0 +1,213 @@ + + +
+
+

Environments

+ + Clients report the environment an event came from, such as production or staging. + List an environment to show it with the casing you choose, choose whether it sends notifications, or discard events from environments you have not listed. + Names are not case-sensitive, and adding or removing a name never changes or deletes events. + +
+ +
+

Add Environment

+ addEnvironment.mutateAsync(environment)} listedNames={project?.environments?.map((e) => e.name) ?? []} /> +
+ +
+

Settings

+ {#if project} + + {/if} +
+ +
+

Environments in Use

+ Listed environments and the environments seen in this project's events over the last 30 days. + add(row.name)} + {busy} + isError={projectQuery.isError || observedQuery.isError} + isLoading={projectQuery.isLoading || observedQuery.isLoading} + notifyOtherEnvironments={project?.notify_other_environments ?? true} + remove={confirmRemove} + {rows} + {toggleNotify} + /> +
+ +
+

Discarded Events

+ + Clients are told an event was accepted before it is checked, so discarded events are only visible here. Allow an environment to start accepting its + events, for example when a name was misspelled in a client. + + allow(environment)} + {busy} + isError={discardedQuery.isError} + isLoading={discardedQuery.isLoading} + named={discarded.named} + retentionDays={discardedQuery.data?.retention_days ?? 7} + stopDiscardingUnspecified={() => + change({ + discard_events_without_environment: false + })} + unspecified={discarded.unspecified} + /> +
+
+ + + + + Remove Environment + + Remove {environmentToRemove?.name} from the list? Its events are not changed or deleted. New events from it will + follow the settings for environments that are not listed. + + + + Cancel + Remove Environment + + + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts index d49599b356..3b94685154 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts @@ -6,6 +6,7 @@ import SourceMaps from '@lucide/svelte/icons/file-code-2'; import ApiKey from '@lucide/svelte/icons/key'; import Stacks from '@lucide/svelte/icons/layers'; import Integration from '@lucide/svelte/icons/plug-2'; +import Environments from '@lucide/svelte/icons/server'; import Settings from '@lucide/svelte/icons/settings'; import type { NavigationItem } from '../../../routes.svelte'; @@ -40,6 +41,14 @@ export function routes(): NavigationItem[] { icon: Settings, title: 'Settings' }, + { + group: 'Project Settings', + href: resolve('/(app)/project/[projectId]/environments', { + projectId: page.params.projectId + }), + icon: Environments, + title: 'Environments' + }, { group: 'Project Settings', href: resolve('/(app)/project/[projectId]/configuration-values', { diff --git a/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs b/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs new file mode 100644 index 0000000000..dfb082c193 --- /dev/null +++ b/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs @@ -0,0 +1,63 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Models; + +namespace Exceptionless.Web.Models; + +public record NewProjectEnvironment +{ + /// + /// The environment name. It is trimmed, limited to 64 characters, and is matched case-insensitively against event environments. + /// + [Required] + public string Name { get; set; } = null!; + + /// + /// If true, events from this environment can send notifications. + /// + public bool Notify { get; set; } = true; +} + +public record UpdateProjectEnvironment +{ + /// + /// If true, events from this environment can send notifications. + /// + [Required] + public bool? Notify { get; set; } +} + +public record UpdateProjectEnvironmentSettings +{ + /// + /// If true, events whose environment is not listed are discarded. + /// + public bool? DiscardUnlistedEnvironments { get; set; } + + /// + /// If true, events without an environment are also discarded. Only applies while unlisted environments are discarded. + /// + public bool? DiscardEventsWithoutEnvironment { get; set; } + + /// + /// If true, events from environments that are not listed and events without an environment can send notifications. + /// + public bool? NotifyOtherEnvironments { get; set; } +} + +public record ProjectEnvironmentSettings +{ + public List Environments { get; set; } = []; + public bool DiscardUnlistedEnvironments { get; set; } + public bool DiscardEventsWithoutEnvironment { get; set; } + public bool NotifyOtherEnvironments { get; set; } = true; +} + +public record ProjectDiscardedEnvironments +{ + /// + /// The number of days that discarded environment names are remembered. + /// + public int RetentionDays { get; set; } + + public IReadOnlyCollection Environments { get; set; } = []; +} diff --git a/src/Exceptionless.Web/Models/Project/ViewProject.cs b/src/Exceptionless.Web/Models/Project/ViewProject.cs index 52f5576e9a..6fee6f7e71 100644 --- a/src/Exceptionless.Web/Models/Project/ViewProject.cs +++ b/src/Exceptionless.Web/Models/Project/ViewProject.cs @@ -16,6 +16,14 @@ public class ViewProject : IIdentity, IData, IHaveCreatedDate public string OrganizationName { get; set; } = null!; public string Name { get; set; } = null!; public bool DeleteBotDataEnabled { get; set; } + + /// + /// The environments that the project lists, in the casing they were entered. Change them with the project environment endpoints. + /// + public List Environments { get; set; } = []; + public bool DiscardUnlistedEnvironments { get; set; } + public bool DiscardEventsWithoutEnvironment { get; set; } + public bool NotifyOtherEnvironments { get; set; } = true; public Core.Models.DataDictionary? Data { get; set; } public List PromotedTabs { get; set; } = null!; public bool? IsConfigured { get; set; } diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index c0474d670b..0c4c1e0d5c 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -1851,6 +1851,76 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "DELETE", + "route": "/api/v2/projects/{id:objectid}/environments", + "displayName": "HTTP: DELETE api/v2/projects/{id:objectid}/environments", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PATCH", + "route": "/api/v2/projects/{id:objectid}/environments", + "displayName": "HTTP: PATCH api/v2/projects/{id:objectid}/environments", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/projects/{id:objectid}/environments", + "displayName": "HTTP: POST api/v2/projects/{id:objectid}/environments", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "GET", + "route": "/api/v2/projects/{id:objectid}/environments/discarded", + "displayName": "HTTP: GET api/v2/projects/{id:objectid}/environments/discarded", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "PUT", + "route": "/api/v2/projects/{id:objectid}/environments/settings", + "displayName": "HTTP: PUT api/v2/projects/{id:objectid}/environments/settings", + "tags": [ + "Project" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "GET", "route": "/api/v2/projects/{id:objectid}/notifications", diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 88be9cf072..bdfdfd115c 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -6167,6 +6167,310 @@ } } }, + "/api/v2/projects/{id}/environments": { + "post": { + "tags": [ + "Project" + ], + "summary": "Add environment", + "description": "Lists an environment on the project. Events are never changed, and a name that was being discarded is accepted from now on.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The environment to list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewProjectEnvironment" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/NewProjectEnvironment" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectEnvironmentSettings" + } + } + } + }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "The environment is already listed. Names are case-insensitive.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "The name is not a valid environment name, or the project already lists the maximum number of environments.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Project" + ], + "summary": "Update environment", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "name", + "in": "query", + "description": "The environment name. Matching is case-insensitive.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectEnvironment" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectEnvironment" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectEnvironmentSettings" + } + } + } + }, + "404": { + "description": "The project or the listed environment could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Project" + ], + "summary": "Remove environment", + "description": "Removes an environment from the project\u0027s list. Events are never deleted.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "name", + "in": "query", + "description": "The environment name. Matching is case-insensitive.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectEnvironmentSettings" + } + } + } + }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects/{id}/environments/settings": { + "put": { + "tags": [ + "Project" + ], + "summary": "Update environment settings", + "description": "Sets whether unlisted environments and events without an environment are discarded, and whether they send notifications. Omitted settings are unchanged.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The settings to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectEnvironmentSettings" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectEnvironmentSettings" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectEnvironmentSettings" + } + } + } + }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v2/projects/{id}/environments/discarded": { + "get": { + "tags": [ + "Project" + ], + "summary": "Get discarded environments", + "description": "Gets the environment names that recently had events discarded because they are not listed, with the number of events discarded.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the project.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDiscardedEnvironments" + } + } + } + }, + "404": { + "description": "The project could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v2/organizations/{organizationId}/projects/check-name": { "get": { "tags": [ @@ -12231,6 +12535,39 @@ } } }, + "DiscardedEnvironment": { + "required": [ + "count", + "last_seen_utc" + ], + "type": "object", + "properties": { + "key": { + "type": [ + "null", + "string" + ], + "description": "The lowercased key for the environment, or null for events that had no environment." + }, + "name": { + "type": [ + "null", + "string" + ], + "description": "The first-seen casing of the name, or null for events that had no environment." + }, + "count": { + "type": "integer", + "description": "The number of events discarded within the retention window.", + "format": "int64" + }, + "last_seen_utc": { + "type": "string", + "format": "date-time" + } + }, + "description": "An environment name that recently had events discarded because it is not listed on the project." + }, "EventSubmissionSettings": { "required": [ "enabled", @@ -12523,6 +12860,23 @@ } } }, + "NewProjectEnvironment": { + "required": [ + "name", + "notify" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The environment name. It is trimmed, limited to 64 characters, and is matched case-insensitively against event environments." + }, + "notify": { + "type": "boolean", + "description": "If true, events from this environment can send notifications." + } + } + }, "NewSavedView": { "required": [ "name", @@ -13448,6 +13802,77 @@ } } }, + "ProjectDiscardedEnvironments": { + "required": [ + "retention_days", + "environments" + ], + "type": "object", + "properties": { + "retention_days": { + "type": "integer", + "description": "The number of days that discarded environment names are remembered.", + "format": "int32" + }, + "environments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiscardedEnvironment" + } + } + } + }, + "ProjectEnvironment": { + "required": [ + "name", + "notify", + "created_utc" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 64, + "minLength": 1, + "type": "string", + "description": "The environment name in the exact casing that was entered. Matching against event environments is case-insensitive." + }, + "notify": { + "type": "boolean", + "description": "If true, events from this environment can send notifications." + }, + "created_utc": { + "type": "string", + "format": "date-time" + } + }, + "description": "A deployment environment that a project has chosen to list, such as production or staging." + }, + "ProjectEnvironmentSettings": { + "required": [ + "environments", + "discard_unlisted_environments", + "discard_events_without_environment", + "notify_other_environments" + ], + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectEnvironment" + } + }, + "discard_unlisted_environments": { + "type": "boolean" + }, + "discard_events_without_environment": { + "type": "boolean" + }, + "notify_other_environments": { + "type": "boolean" + } + } + }, "RecordProductTourResult": { "required": [ "recorded_utc" @@ -13618,6 +14043,7 @@ "first_occurrence", "last_occurrence", "occurrences_are_critical", + "is_new_notification_pending", "references", "tags", "duplicate_signature", @@ -13725,6 +14151,10 @@ "type": "boolean", "description": "If true, all future occurrences will be marked as critical." }, + "is_new_notification_pending": { + "type": "boolean", + "description": "True when the stack was created by an event from an environment that does not send notifications and no notifying\nenvironment has reported it yet. The first occurrence in a notifying environment clears this and is reported as new.\nStacks created before this field existed are not pending, so they never produce a new notification for it." + }, "references": { "type": "array", "items": { @@ -13914,6 +14344,47 @@ }, "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, + "UpdateProjectEnvironment": { + "required": [ + "notify" + ], + "type": "object", + "properties": { + "notify": { + "type": [ + "null", + "boolean" + ], + "description": "If true, events from this environment can send notifications." + } + } + }, + "UpdateProjectEnvironmentSettings": { + "type": "object", + "properties": { + "discard_unlisted_environments": { + "type": [ + "null", + "boolean" + ], + "description": "If true, events whose environment is not listed are discarded." + }, + "discard_events_without_environment": { + "type": [ + "null", + "boolean" + ], + "description": "If true, events without an environment are also discarded. Only applies while unlisted environments are discarded." + }, + "notify_other_environments": { + "type": [ + "null", + "boolean" + ], + "description": "If true, events from environments that are not listed and events without an environment can send notifications." + } + } + }, "UpdateSavedView": { "type": "object", "properties": { @@ -14741,6 +15212,10 @@ "organization_name", "name", "delete_bot_data_enabled", + "environments", + "discard_unlisted_environments", + "discard_events_without_environment", + "notify_other_environments", "promoted_tabs", "stack_count", "event_count", @@ -14776,6 +15251,22 @@ "delete_bot_data_enabled": { "type": "boolean" }, + "environments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectEnvironment" + }, + "description": "The environments that the project lists, in the casing they were entered. Change them with the project environment endpoints." + }, + "discard_unlisted_environments": { + "type": "boolean" + }, + "discard_events_without_environment": { + "type": "boolean" + }, + "notify_other_environments": { + "type": "boolean" + }, "data": { "type": [ "null", diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs new file mode 100644 index 0000000000..54823070d7 --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs @@ -0,0 +1,322 @@ +using System.Net; +using Exceptionless.Core.Models; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Api.Endpoints; + +public sealed partial class ProjectEndpointTests +{ + private const string EnvironmentsPath = "environments"; + + [Fact] + public async Task AddEnvironmentAsync_WithPaddedName_ListsTrimmedNameAndExposesItOnTheProject() + { + // Arrange + var body = new NewProjectEnvironment { Name = " Production ", Notify = false }; + + // Act + var settings = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .Content(body) + .StatusCodeShouldBeOk() + ); + var project = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(settings); + var listed = Assert.Single(settings.Environments); + Assert.Equal("Production", listed.Name); + Assert.False(listed.Notify); + Assert.NotEqual(default, listed.CreatedUtc); + Assert.Equal("Production", Assert.Single(project!.Environments).Name); + Assert.True(project.NotifyOtherEnvironments); + Assert.False(project.DiscardUnlistedEnvironments); + Assert.False(project.DiscardEventsWithoutEnvironment); + } + + [Fact] + public async Task AddEnvironmentAsync_WithNameDifferingOnlyByCase_ReturnsConflictAndKeepsTheListedCasing() + { + // Arrange + await AddEnvironmentAsync("Production"); + + // Act + using var response = await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .Content(new NewProjectEnvironment { Name = "PRODUCTION" }) + .ExpectedStatus(HttpStatusCode.Conflict) + ); + + // Assert + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.Equal(["Production"], project!.Environments.Select(e => e.Name)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("prod\tuction")] + [InlineData("0123456789012345678901234567890123456789012345678901234567890123456789")] + public async Task AddEnvironmentAsync_WithInvalidName_ReturnsUnprocessableEntity(string name) + { + // Act + using var response = await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .Content(new NewProjectEnvironment { Name = name }) + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.Empty(project!.Environments); + } + + [Fact] + public async Task AddEnvironmentAsync_WhenTheListIsFull_ReturnsUnprocessableEntity() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + project!.Environments = Enumerable.Range(0, ProjectEnvironment.MaximumCount).Select(i => new ProjectEnvironment { Name = $"env-{i}" }).ToList(); + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency().Cache()); + + // Act + using var response = await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .Content(new NewProjectEnvironment { Name = "one-too-many" }) + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.Equal(ProjectEnvironment.MaximumCount, project!.Environments.Count); + } + + [Fact] + public async Task AddEnvironmentAsync_ForDiscardedName_StopsReportingItAsDiscardedAndLeavesEventsAlone() + { + // Arrange + var discarded = GetService(); + await discarded.RecordAsync(SampleDataService.TEST_PROJECT_ID, ["Prodution", "dev"]); + var ev = await AddEventAsync("prodution"); + + // Act + await AddEnvironmentAsync("PRODUTION"); + var result = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath, "discarded") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.Equal(DiscardedEnvironmentService.RetentionDays, result.RetentionDays); + Assert.Equal(["dev"], result.Environments.Select(e => e.Key)); + Assert.Equal("prodution", (await _eventRepository.GetByIdAsync(ev.Id))!.Environment); + } + + [Fact] + public async Task UpdateEnvironmentAsync_WithDifferentCasing_TogglesNotifyOnTheListedName() + { + // Arrange + await AddEnvironmentAsync("Staging"); + + // Act + var settings = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Patch() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .QueryString("name", "STAGING") + .Content(new UpdateProjectEnvironment { Notify = false }) + .StatusCodeShouldBeOk() + ); + + // Assert + var listed = Assert.Single(settings!.Environments); + Assert.Equal("Staging", listed.Name); + Assert.False(listed.Notify); + } + + [Fact] + public async Task UpdateEnvironmentAsync_WithUnlistedNameOrMissingNotify_ReturnsNotFoundOrUnprocessableEntity() + { + // Arrange + await AddEnvironmentAsync("Staging"); + + // Act & Assert + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Patch() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .QueryString("name", "unlisted") + .Content(new UpdateProjectEnvironment { Notify = false }) + .StatusCodeShouldBeNotFound() + ); + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Patch() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .QueryString("name", "staging") + .Content(new UpdateProjectEnvironment()) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task RemoveEnvironmentAsync_WithDifferentCasing_RemovesTheNameButNeverDeletesEvents() + { + // Arrange + await AddEnvironmentAsync("Production"); + await AddEnvironmentAsync("Staging"); + var ev = await AddEventAsync("production"); + + // Act + var settings = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Delete() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .QueryString("name", "PRODUCTION") + .StatusCodeShouldBeOk() + ); + var again = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Delete() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .QueryString("name", "PRODUCTION") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.Equal(["Staging"], settings!.Environments.Select(e => e.Name)); + Assert.Equal(["Staging"], again!.Environments.Select(e => e.Name)); + Assert.Equal("production", (await _eventRepository.GetByIdAsync(ev.Id))!.Environment); + } + + [Fact] + public async Task UpdateEnvironmentSettingsAsync_WithPartialBody_ChangesOnlyTheProvidedSettings() + { + // Arrange + var discarded = GetService(); + await discarded.RecordAsync(SampleDataService.TEST_PROJECT_ID, [null, "dev"]); + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Put() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath, "settings") + .Content(new UpdateProjectEnvironmentSettings { DiscardUnlistedEnvironments = true, DiscardEventsWithoutEnvironment = true }) + .StatusCodeShouldBeOk() + ); + + // Act + var settings = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .Put() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath, "settings") + .Content(new UpdateProjectEnvironmentSettings { DiscardEventsWithoutEnvironment = false, NotifyOtherEnvironments = false }) + .StatusCodeShouldBeOk() + ); + var result = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath, "discarded") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(settings); + Assert.True(settings.DiscardUnlistedEnvironments); + Assert.False(settings.DiscardEventsWithoutEnvironment); + Assert.False(settings.NotifyOtherEnvironments); + Assert.Equal(["dev"], result!.Environments.Select(e => e.Key)); + } + + [Fact] + public async Task GetDiscardedEnvironmentsAsync_ReturnsCountsLastSeenAndFirstSeenCasing() + { + // Arrange + var discarded = GetService(); + await discarded.RecordAsync(SampleDataService.TEST_PROJECT_ID, ["Prodution", "prodution", null]); + + // Act + var result = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath, "discarded") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Environments.Count); + var typo = Assert.Single(result.Environments, e => e.Key == "prodution"); + Assert.Equal("Prodution", typo.Name); + Assert.Equal(2, typo.Count); + Assert.NotEqual(default, typo.LastSeenUtc); + var missing = Assert.Single(result.Environments, e => e.Key is null); + Assert.Equal(1, missing.Count); + } + + [Fact] + public async Task EnvironmentEndpoints_ForAnotherOrganizationsProject_ReturnNotFound() + { + // Arrange + string projectId = SampleDataService.TEST_PROJECT_ID; + + // Act & Assert + await SendRequestAsync(r => r.AsFreeOrganizationUser().Post().AppendPaths("projects", projectId, EnvironmentsPath).Content(new NewProjectEnvironment { Name = "x" }).StatusCodeShouldBeNotFound()); + await SendRequestAsync(r => r.AsFreeOrganizationUser().Patch().AppendPaths("projects", projectId, EnvironmentsPath).QueryString("name", "x").Content(new UpdateProjectEnvironment { Notify = false }).StatusCodeShouldBeNotFound()); + await SendRequestAsync(r => r.AsFreeOrganizationUser().Delete().AppendPaths("projects", projectId, EnvironmentsPath).QueryString("name", "x").StatusCodeShouldBeNotFound()); + await SendRequestAsync(r => r.AsFreeOrganizationUser().Put().AppendPaths("projects", projectId, EnvironmentsPath, "settings").Content(new UpdateProjectEnvironmentSettings { DiscardUnlistedEnvironments = true }).StatusCodeShouldBeNotFound()); + await SendRequestAsync(r => r.AsFreeOrganizationUser().AppendPaths("projects", projectId, EnvironmentsPath, "discarded").StatusCodeShouldBeNotFound()); + var project = await _projectRepository.GetByIdAsync(projectId); + Assert.Empty(project!.Environments); + Assert.False(project.DiscardUnlistedEnvironments); + } + + [Fact] + public async Task EnvironmentEndpoints_WithClientTokenOrAnonymous_AreRejected() + { + // Arrange + string projectId = SampleDataService.TEST_PROJECT_ID; + + // Act & Assert + await SendRequestAsync(r => r.AsTestOrganizationClientUser().Post().AppendPaths("projects", projectId, EnvironmentsPath).Content(new NewProjectEnvironment { Name = "x" }).StatusCodeShouldBeForbidden()); + await SendRequestAsync(r => r.AsTestOrganizationClientUser().AppendPaths("projects", projectId, EnvironmentsPath, "discarded").StatusCodeShouldBeForbidden()); + await SendRequestAsync(r => r.AsAnonymousUser().Post().AppendPaths("projects", projectId, EnvironmentsPath).Content(new NewProjectEnvironment { Name = "x" }).StatusCodeShouldBeUnauthorized()); + await SendRequestAsync(r => r.AsAnonymousUser().AppendPaths("projects", projectId, EnvironmentsPath, "discarded").StatusCodeShouldBeUnauthorized()); + var project = await _projectRepository.GetByIdAsync(projectId); + Assert.Empty(project!.Environments); + } + + private Task AddEnvironmentAsync(string name) + { + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath) + .Content(new NewProjectEnvironment { Name = name }) + .StatusCodeShouldBeOk() + ); + } + + private Task AddEventAsync(string environment) + { + var ev = GetService().GenerateEvent(organizationId: SampleDataService.TEST_ORG_ID, projectId: SampleDataService.TEST_PROJECT_ID, stackId: Foundatio.Repositories.Utility.ObjectId.GenerateNewId().ToString(), generateData: false, generateTags: false); + ev.Environment = environment; + return _eventRepository.AddAsync(ev, o => o.ImmediateConsistency()); + } +} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.cs index 23f5b402f8..38c43a419f 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.cs @@ -17,7 +17,7 @@ namespace Exceptionless.Tests.Api.Endpoints; -public sealed class ProjectEndpointTests : IntegrationTestsBase +public sealed partial class ProjectEndpointTests : IntegrationTestsBase { private readonly IEventRepository _eventRepository; private readonly IOrganizationRepository _organizationRepository; diff --git a/tests/Exceptionless.Tests/Extensions/ProjectEnvironmentExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ProjectEnvironmentExtensionsTests.cs new file mode 100644 index 0000000000..269e4861df --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/ProjectEnvironmentExtensionsTests.cs @@ -0,0 +1,113 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public class ProjectEnvironmentExtensionsTests +{ + [Theory] + [InlineData(null)] + [InlineData("production")] + [InlineData("Staging")] + [InlineData("qa-west")] + public void Settings_WithDefaultProject_DiscardNothingAndNotifyEverything(string? environment) + { + // Arrange + var project = new Project(); + + // Act + bool discarded = project.ShouldDiscardEnvironment(environment); + bool notifies = project.ShouldNotifyForEnvironment(environment); + + // Assert + Assert.False(discarded); + Assert.True(notifies); + Assert.Equal(environment, project.GetEnvironmentDisplayName(environment)); + } + + [Theory] + [InlineData("Production")] + [InlineData("production")] + [InlineData("PRODUCTION")] + public void FindEnvironment_WithDifferentCasing_MatchesAndKeepsListedCasing(string eventEnvironment) + { + // Arrange + var project = new Project { Environments = [new ProjectEnvironment { Name = "Production" }] }; + + // Act + var listed = project.FindEnvironment(eventEnvironment); + + // Assert + Assert.NotNull(listed); + Assert.Equal("Production", project.GetEnvironmentDisplayName(eventEnvironment)); + } + + [Fact] + public void ShouldDiscardEnvironment_WithDiscardUnlisted_DiscardsOnlyUnlistedNamesCaseInsensitively() + { + // Arrange + var project = new Project + { + DiscardUnlistedEnvironments = true, + Environments = [new ProjectEnvironment { Name = "Production" }, new ProjectEnvironment { Name = "staging" }] + }; + + // Act & Assert + Assert.False(project.ShouldDiscardEnvironment("PRODUCTION")); + Assert.False(project.ShouldDiscardEnvironment("Staging")); + Assert.True(project.ShouldDiscardEnvironment("prodution")); + Assert.True(project.ShouldDiscardEnvironment("dev")); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + public void ShouldDiscardEnvironment_WithoutEnvironment_DiscardsOnlyWhenBothSettingsAreOn(bool discardUnlisted, bool discardWithout, bool expected) + { + // Arrange + var project = new Project + { + DiscardUnlistedEnvironments = discardUnlisted, + DiscardEventsWithoutEnvironment = discardWithout, + Environments = [new ProjectEnvironment { Name = "production" }] + }; + + // Act + bool discarded = project.ShouldDiscardEnvironment(null); + + // Assert + Assert.Equal(expected, discarded); + } + + [Theory] + [InlineData("production", false, true)] + [InlineData("PRODUCTION", false, true)] + [InlineData("dev", true, false)] + [InlineData("DEV", true, false)] + [InlineData("qa", true, true)] + [InlineData("qa", false, false)] + [InlineData(null, true, true)] + [InlineData(null, false, false)] + public void ShouldNotifyForEnvironment_ListedUsesItsOwnSettingAndOthersUseNotifyOtherEnvironments(string? environment, bool notifyOthers, bool expected) + { + // Arrange + var project = new Project + { + NotifyOtherEnvironments = notifyOthers, + Environments = + [ + new ProjectEnvironment { Name = "Production", Notify = true }, + new ProjectEnvironment { Name = "dev", Notify = false } + ] + }; + + // Act + bool notifies = project.ShouldNotifyForEnvironment(environment); + + // Assert + Assert.Equal(expected, notifies); + } +} diff --git a/tests/Exceptionless.Tests/Jobs/EventNotificationsJobEnvironmentTests.cs b/tests/Exceptionless.Tests/Jobs/EventNotificationsJobEnvironmentTests.cs new file mode 100644 index 0000000000..2cc8db1b6f --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/EventNotificationsJobEnvironmentTests.cs @@ -0,0 +1,99 @@ +using Exceptionless.Core; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Jobs; +using Exceptionless.Core.Mail; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Mail; +using Exceptionless.Tests.Utility; +using Foundatio.Jobs; +using Foundatio.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using DataDictionary = Exceptionless.Core.Models.DataDictionary; + +namespace Exceptionless.Tests.Jobs; + +public sealed class EventNotificationsJobEnvironmentTests : IntegrationTestsBase +{ + private CountingMailer Mailer => GetService(); + + public EventNotificationsJobEnvironmentTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { } + + protected override void RegisterServices(IServiceCollection services) + { + base.RegisterServices(services); + services.AddSingleton(); + services.ReplaceSingleton(sp => sp.GetRequiredService()); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + Mailer.Reset(); + await GetService().CreateDataAsync(); + + var userRepository = GetService(); + var user = (await userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL))!; + user.MarkEmailAddressVerified(); + await userRepository.SaveAsync(user, o => o.Cache().ImmediateConsistency()); + GetService().EmailOptions.AllowedOutboundAddresses = ["exceptionless.test"]; + } + + [Fact] + public async Task RunAsync_ForNotifyingEnvironment_SendsNewErrorNoticeOnlyAfterTheStackReachesIt() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = [new ProjectEnvironment { Name = "dev", Notify = false }, new ProjectEnvironment { Name = "Production" }]); + await RunPipelineAsync(project, "dev"); + await RunPipelineAsync(project, "production"); + + // Act + await GetService().RunUntilEmptyAsync(TestCancellationToken); + + // Assert + var call = Assert.Single(Mailer.EventNoticeCalls); + Assert.True(call.IsNew); + Assert.False(call.IsRegression); + } + + [Fact] + public async Task RunAsync_WhenTheEnvironmentStopsNotifyingWhileQueued_SendsNothing() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = [new ProjectEnvironment { Name = "Production" }]); + await RunPipelineAsync(project, "production"); + await ConfigureProjectAsync(p => p.Environments[0].Notify = false); + + // Act + await GetService().RunUntilEmptyAsync(TestCancellationToken); + + // Assert + Assert.Empty(Mailer.EventNoticeCalls); + } + + private async Task RunPipelineAsync(Project project, string environment) + { + var ev = GetService().GenerateEvent(organizationId: SampleDataService.TEST_ORG_ID, projectId: SampleDataService.TEST_PROJECT_ID, type: Event.KnownTypes.Error, source: "boom", generateData: false, generateTags: false, occurrenceDate: DateTimeOffset.Now); + ev.Tags = []; + ev.Environment = environment; + ev.Data = new DataDictionary { { Event.KnownDataKeys.SimpleError, new SimpleError { Message = "Boom", Type = "Test.BoomException", StackTrace = "at Boom()" } } }; + var organization = (await GetService().GetByIdAsync(SampleDataService.TEST_ORG_ID))!; + + var context = await GetService().RunAsync(ev, organization, project); + await RefreshDataAsync(); + + Assert.True(context.IsProcessed, context.ErrorMessage); + } + + private async Task ConfigureProjectAsync(Action configure) + { + var repository = GetService(); + var project = (await repository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID))!; + configure(project); + return await repository.SaveAsync(project, o => o.ImmediateConsistency().Cache()); + } +} diff --git a/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs b/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs index 9bbddb2f9e..7e3ca6c62f 100644 --- a/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs @@ -152,6 +152,39 @@ public async Task CanRunJobWithDiscardedEventUsage() Assert.Equal(0, usage.CurrentUsage.Blocked); } + [Fact] + public async Task CanRunJobWithEnvironmentDiscardsCountedAsDiscardedUsageOnly() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + project.DiscardUnlistedEnvironments = true; + project.DiscardEventsWithoutEnvironment = true; + project.Environments = [new ProjectEnvironment { Name = "Production" }]; + await _projectRepository.SaveAsync(project, o => o.Cache().ImmediateConsistency()); + + var listed = GenerateEvent(type: Event.KnownTypes.Log, source: "test"); + listed.Environment = "PRODUCTION"; + var typo = GenerateEvent(type: Event.KnownTypes.Log, source: "test"); + typo.Environment = "Prodution"; + var missing = GenerateEvent(type: Event.KnownTypes.Log, source: "test"); + await EnqueueEventPostAsync([listed, typo, missing]); + + // Act + var result = await _job.RunAsync(TestCancellationToken); + await RefreshDataAsync(); + + // Assert + Assert.True(result.IsSuccess); + var usage = await _usageService.GetUsageAsync(TestConstants.OrganizationId, TestConstants.ProjectId); + Assert.Equal(1, usage.CurrentUsage.Total); + Assert.Equal(2, usage.CurrentUsage.Discarded); + Assert.Equal(0, usage.CurrentUsage.Blocked); + Assert.Equal(1, await _eventRepository.CountAsync()); + Assert.Equal(1, await _stackRepository.CountAsync()); + Assert.Equal(["PRODUCTION"], (await _eventRepository.GetAllAsync()).Documents.Select(e => e.Environment)); + } + [Fact] public async Task RunAsync_RetryingParsedBatch_PreservesEnvironmentWithoutCustomData() { diff --git a/tests/Exceptionless.Tests/Mail/CountingMailer.cs b/tests/Exceptionless.Tests/Mail/CountingMailer.cs index 2967a84886..05a87c29d5 100644 --- a/tests/Exceptionless.Tests/Mail/CountingMailer.cs +++ b/tests/Exceptionless.Tests/Mail/CountingMailer.cs @@ -22,8 +22,15 @@ public Task SendContactRequestAsync(string name, string emailAddress, stri return Task.FromResult(true); } + public List EventNoticeCalls { get; } = []; + public Task SendEventNoticeAsync(User user, PersistentEvent ev, Project project, bool isNew, bool isRegression, int totalOccurrences) { + lock (EventNoticeCalls) + { + EventNoticeCalls.Add(new EventNoticeCall(user.Id, ev.Id, isNew, isRegression)); + } + return Task.FromResult(true); } @@ -77,8 +84,15 @@ public void Reset() { OrganizationNoticeCalls.Clear(); } + + lock (EventNoticeCalls) + { + EventNoticeCalls.Clear(); + } ShouldThrow = false; } } public record OrganizationNoticeCall(string UserId, string OrganizationId, bool IsOverMonthlyLimit, bool IsOverHourlyLimit); + +public record EventNoticeCall(string UserId, string EventId, bool IsNew, bool IsRegression); diff --git a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs new file mode 100644 index 0000000000..648d22c4fa --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs @@ -0,0 +1,433 @@ +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.Queues.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Utility; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Xunit; +using DataDictionary = Exceptionless.Core.Models.DataDictionary; + +namespace Exceptionless.Tests.Pipeline; + +public sealed class EnvironmentManagementPipelineTests : IntegrationTestsBase +{ + private readonly EventPipeline _pipeline; + private readonly IEventRepository _eventRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; + private readonly IStackRepository _stackRepository; + private readonly IWebHookRepository _webHookRepository; + private readonly IQueue _notificationQueue; + private readonly IQueue _webHookQueue; + private readonly DiscardedEnvironmentService _discardedEnvironments; + private readonly EventData _eventData; + + public EnvironmentManagementPipelineTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _pipeline = GetService(); + _eventRepository = GetService(); + _organizationRepository = GetService(); + _projectRepository = GetService(); + _stackRepository = GetService(); + _webHookRepository = GetService(); + _notificationQueue = GetService>(); + _webHookQueue = GetService>(); + _discardedEnvironments = GetService(); + _eventData = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task RunAsync_WithDefaultProject_AcceptsEveryEnvironmentAndNotifiesOnlyForTheNewStack() + { + // Arrange + await AddWebHookAsync(); + var project = await ConfigureProjectAsync(_ => { }); + + // Act + var first = await RunAsync(project, "Anything"); + var second = await RunAsync(project, "other"); + var third = await RunAsync(project, null); + + // Assert + Assert.All(new[] { first, second, third }, c => Assert.True(c.IsProcessed)); + Assert.False(first.Stack!.IsNewNotificationPending); + var notifications = await DequeueNotificationsAsync(); + Assert.Equal(first.Event.Id, Assert.Single(notifications).EventId); + Assert.True(notifications[0].IsNew); + Assert.Equal(1, (await _webHookQueue.GetQueueStatsAsync()).Enqueued); + Assert.Empty((await _projectRepository.GetByIdAsync(project.Id))!.Environments); + } + + [Fact] + public async Task RunAsync_WithUnlistedEnvironmentAndDiscardEnabled_DiscardsBeforeStackingAndRecordsTheName() + { + // Arrange + var project = await ConfigureProjectAsync(p => + { + p.DiscardUnlistedEnvironments = true; + p.Environments = [new ProjectEnvironment { Name = "Production" }]; + }); + + // Act + var typo = await RunAsync(project, "Prodution"); + var typoAgain = await RunAsync(project, "PRODUTION"); + var listed = await RunAsync(project, "PRODUCTION"); + await RefreshDataAsync(); + + // Assert + Assert.True(typo.IsDiscarded); + Assert.True(typo.IsCancelled); + Assert.False(typo.IsProcessed); + Assert.Null(typo.Stack); + Assert.True(typoAgain.IsDiscarded); + Assert.True(listed.IsProcessed); + Assert.False(listed.IsDiscarded); + Assert.Equal(1, await _stackRepository.CountAsync()); + Assert.Equal(1, await _eventRepository.CountAsync()); + var discarded = Assert.Single(await _discardedEnvironments.GetAsync(project.Id)); + Assert.Equal("prodution", discarded.Key); + Assert.Equal("Prodution", discarded.Name); + Assert.Equal(2, discarded.Count); + Assert.Equal(["Production"], (await _projectRepository.GetByIdAsync(project.Id))!.Environments.Select(e => e.Name)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public async Task RunAsync_WithoutEnvironment_DiscardsOnlyWhenEventsWithoutEnvironmentAreDiscarded(bool discardWithoutEnvironment, bool expectedDiscard) + { + // Arrange + var project = await ConfigureProjectAsync(p => + { + p.DiscardUnlistedEnvironments = true; + p.DiscardEventsWithoutEnvironment = discardWithoutEnvironment; + p.Environments = [new ProjectEnvironment { Name = "production" }]; + }); + + // Act + var context = await RunAsync(project, null); + var discarded = await _discardedEnvironments.GetAsync(project.Id); + + // Assert + Assert.Equal(expectedDiscard, context.IsDiscarded); + Assert.Equal(!expectedDiscard, context.IsProcessed); + var unspecified = discarded.SingleOrDefault(); + Assert.Equal(expectedDiscard, unspecified is not null); + Assert.Null(unspecified?.Key); + Assert.Null(unspecified?.Name); + } + + [Fact] + public async Task RunAsync_WithDiscardWithoutEnvironmentAndSessionHeartbeat_DoesNotDiscardTheHeartbeat() + { + // Arrange + var project = await ConfigureProjectAsync(p => + { + p.DiscardUnlistedEnvironments = true; + p.DiscardEventsWithoutEnvironment = true; + }); + var heartbeat = new EventContext(NewEvent(null, "heartbeat", Event.KnownTypes.SessionHeartbeat), await GetOrganizationAsync(), project); + var log = new EventContext(NewEvent(null, "log"), await GetOrganizationAsync(), project); + + // Act + await GetService().ProcessBatchAsync([heartbeat, log]); + + // Assert + Assert.False(heartbeat.IsDiscarded); + Assert.True(log.IsDiscarded); + } + + [Fact] + public async Task RunAsync_WhenFirstSeenInDevelopmentThenProduction_NotifiesAsNewOnlyForTheFirstProductionEvent() + { + // Arrange + await AddWebHookAsync(); + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "Production", Notify = true } + ]); + + // Act + var dev = await RunAsync(project, "DEV"); + var devAgain = await RunAsync(project, "dev"); + var devNotifications = await DequeueNotificationsAsync(); + var devWebHooks = (await _webHookQueue.GetQueueStatsAsync()).Enqueued; + var production = await RunAsync(project, "production"); + var productionNotifications = await DequeueNotificationsAsync(); + var productionAgain = await RunAsync(project, "PRODUCTION"); + var laterNotifications = await DequeueNotificationsAsync(); + + // Assert + Assert.True(dev.IsNew); + Assert.True(dev.Stack!.IsNewNotificationPending); + Assert.False(devAgain.IsNew); + Assert.Empty(devNotifications); + Assert.Equal(0, devWebHooks); + + Assert.False(production.IsNew); + var notification = Assert.Single(productionNotifications); + Assert.Equal(production.Event.Id, notification.EventId); + Assert.True(notification.IsNew); + Assert.Equal(1, (await _webHookQueue.GetQueueStatsAsync()).Enqueued); + Assert.False((await _stackRepository.GetByIdAsync(production.Event.StackId))!.IsNewNotificationPending); + + Assert.True(productionAgain.IsProcessed); + Assert.Empty(laterNotifications); + Assert.Equal(1, (await _webHookQueue.GetQueueStatsAsync()).Enqueued); + } + + [Fact] + public async Task RunAsync_WhenProductionEventsArriveConcurrentlyForPendingStack_NotifiesAsNewExactlyOnce() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + await RunAsync(project, "dev"); + + // Act + var contexts = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => RunAsync(project, "production"))); + var notifications = await DequeueNotificationsAsync(); + + // Assert + Assert.All(contexts, c => Assert.True(c.IsProcessed)); + Assert.Single(notifications); + Assert.True(notifications[0].IsNew); + } + + [Fact] + public async Task RunAsync_WhenNewStackStartsInNotifyingEnvironment_NotifiesOnceAndNeverMarksItPending() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + + // Act + var production = await RunAsync(project, "production"); + var dev = await RunAsync(project, "dev"); + var laterProduction = await RunAsync(project, "production"); + var notifications = await DequeueNotificationsAsync(); + + // Assert + Assert.False(production.Stack!.IsNewNotificationPending); + var notification = Assert.Single(notifications); + Assert.Equal(production.Event.Id, notification.EventId); + Assert.True(dev.IsProcessed); + Assert.True(laterProduction.IsProcessed); + } + + [Fact] + public async Task RunAsync_WithLegacyStackFromBeforeTheField_NeverNotifiesAsNew() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var existing = await RunAsync(project, "dev"); + Assert.True(existing.Stack!.IsNewNotificationPending); + + // Stacks created before the field existed have no value in the index at all. + await _stackRepository.PatchAsync(existing.Stack.Id, new ScriptPatch("ctx._source.remove('is_new_notification_pending');"), o => o.ImmediateConsistency()); + var legacy = await _stackRepository.GetByIdAsync(existing.Stack.Id); + + // Act + var production = await RunAsync(project, "production"); + var dev = await RunAsync(project, "dev"); + var notifications = await DequeueNotificationsAsync(); + + // Assert + Assert.False(legacy!.IsNewNotificationPending); + Assert.True(production.IsProcessed); + Assert.True(dev.IsProcessed); + Assert.Empty(notifications); + } + + [Fact] + public async Task RunAsync_WithCriticalEvent_NotifiesOnlyFromNotifyingEnvironments() + { + // Arrange + var project = await ConfigureProjectAsync(p => + { + p.NotifyOtherEnvironments = false; + p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]; + }); + await RunAsync(project, "production", source: "warmup"); + await DequeueNotificationsAsync(); + + // Act + var dev = await RunAsync(project, "dev", critical: true); + var unlisted = await RunAsync(project, "qa", critical: true); + var unspecified = await RunAsync(project, null, critical: true); + var devNotifications = await DequeueNotificationsAsync(); + var production = await RunAsync(project, "PRODUCTION", critical: true); + var productionNotifications = await DequeueNotificationsAsync(); + + // Assert + Assert.All(new[] { dev, unlisted, unspecified, production }, c => Assert.True(c.IsProcessed)); + Assert.Empty(devNotifications); + var notification = Assert.Single(productionNotifications); + Assert.Equal(production.Event.Id, notification.EventId); + Assert.False(notification.IsRegression); + } + + [Fact] + public async Task RunAsync_WithNotifyOtherEnvironments_NotifiesForUnlistedAndUnspecifiedEnvironments() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = [new ProjectEnvironment { Name = "dev", Notify = false }]); + + // Act + await RunAsync(project, "qa", source: "one"); + await RunAsync(project, null, source: "two"); + await RunAsync(project, "dev", source: "three"); + var notifications = await DequeueNotificationsAsync(); + + // Assert + Assert.Equal(2, notifications.Count); + Assert.All(notifications, n => Assert.True(n.IsNew)); + } + + [Fact] + public async Task RunAsync_WithRegression_NotifiesOnlyWhenTheEnvironmentNotifies() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var first = await RunAsync(project, "production"); + await DequeueNotificationsAsync(); + var stack = await _stackRepository.GetByIdAsync(first.Event.StackId); + stack!.Status = StackStatus.Fixed; + stack.DateFixed = DateTime.UtcNow.AddHours(-1); + await _stackRepository.SaveAsync(stack, o => o.ImmediateConsistency()); + + // Act + var dev = await RunAsync(project, "dev"); + var devNotifications = await DequeueNotificationsAsync(); + var devStack = await _stackRepository.GetByIdAsync(first.Event.StackId); + devStack!.Status = StackStatus.Fixed; + devStack.DateFixed = DateTime.UtcNow.AddHours(-1); + await _stackRepository.SaveAsync(devStack, o => o.ImmediateConsistency()); + var production = await RunAsync(project, "production"); + var productionNotifications = await DequeueNotificationsAsync(); + + // Assert + Assert.True(dev.IsRegression); + Assert.Empty(devNotifications); + Assert.True(production.IsRegression); + var notification = Assert.Single(productionNotifications); + Assert.True(notification.IsRegression); + } + + [Fact] + public async Task TryClaimNewNotificationAsync_WithConcurrentCallers_ReturnsTrueForExactlyOne() + { + // Arrange + var stack = await _stackRepository.AddAsync(GetService().GenerateStack(projectId: SampleDataService.TEST_PROJECT_ID, organizationId: SampleDataService.TEST_ORG_ID), o => o.ImmediateConsistency()); + stack.IsNewNotificationPending = true; + stack = await _stackRepository.SaveAsync(stack, o => o.ImmediateConsistency()); + + // Act + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => _stackRepository.TryClaimNewNotificationAsync(stack))); + bool claimedAgain = await _stackRepository.TryClaimNewNotificationAsync(stack); + + // Assert + Assert.Equal(1, results.Count(r => r)); + Assert.False(claimedAgain); + Assert.False((await _stackRepository.GetByIdAsync(stack.Id))!.IsNewNotificationPending); + } + + [Fact] + public async Task TryClaimNewNotificationAsync_WithStackThatIsNotPending_ReturnsFalse() + { + // Arrange + var stack = await _stackRepository.AddAsync(GetService().GenerateStack(projectId: SampleDataService.TEST_PROJECT_ID, organizationId: SampleDataService.TEST_ORG_ID), o => o.ImmediateConsistency()); + + // Act + bool claimed = await _stackRepository.TryClaimNewNotificationAsync(stack); + + // Assert + Assert.False(claimed); + } + + private async Task RunAsync(Project project, string? environment, string source = "test", bool critical = false) + { + var ev = NewEvent(environment, source); + if (critical) + ev.Tags!.Add(Event.KnownTags.Critical); + + var context = await _pipeline.RunAsync(ev, await GetOrganizationAsync(), project); + await RefreshDataAsync(); + return context; + } + + private PersistentEvent NewEvent(string? environment, string source, string type = Event.KnownTypes.Error) + { + var ev = _eventData.GenerateEvent(organizationId: SampleDataService.TEST_ORG_ID, projectId: SampleDataService.TEST_PROJECT_ID, type: type, source: source, generateData: false, generateTags: false, occurrenceDate: DateTimeOffset.Now); + ev.Tags = []; + ev.Environment = environment; + ev.Data = new DataDictionary { { Event.KnownDataKeys.SimpleError, new SimpleError { Message = "Boom", Type = $"Test.{source}Exception", StackTrace = $"at {source}()" } } }; + return ev; + } + + private async Task GetOrganizationAsync() => (await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID))!; + + private async Task ConfigureProjectAsync(Action configure) + { + var project = (await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID))!; + configure(project); + return await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency().Cache()); + } + + private Task AddWebHookAsync() + { + return _webHookRepository.AddAsync(new WebHook + { + OrganizationId = SampleDataService.TEST_ORG_ID, + ProjectId = SampleDataService.TEST_PROJECT_ID, + Url = "https://example.com/hook", + Version = WebHook.KnownVersions.Version2, + EventTypes = [WebHook.KnownEventTypes.NewError, WebHook.KnownEventTypes.CriticalError, WebHook.KnownEventTypes.StackRegression], + IsEnabled = true + }, o => o.ImmediateConsistency()); + } + + private async Task> DequeueNotificationsAsync() + { + var notifications = new List(); + while (await _notificationQueue.DequeueAsync(TimeSpan.Zero) is { } entry) + { + notifications.Add(entry.Value); + await entry.CompleteAsync(); + } + + return notifications; + } +} diff --git a/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs b/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs new file mode 100644 index 0000000000..f5eaf5b805 --- /dev/null +++ b/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs @@ -0,0 +1,75 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Plugins.Formatting; +using Xunit; + +namespace Exceptionless.Tests.Plugins; + +public sealed class NotificationEnvironmentFormattingTests(ITestOutputHelper output) : TestWithServices(output) +{ + [Theory] + [InlineData("production", "New Error in Production: Boom")] + [InlineData("PRODUCTION", "New Error in Production: Boom")] + [InlineData("qa-West", "New Error in qa-West: Boom")] + [InlineData(null, "New Error: Boom")] + public void GetEventNotificationMailMessageData_WithEnvironment_MentionsItUsingTheListedCasing(string? environment, string expectedSubject) + { + // Arrange + var project = CreateProject(); + var ev = CreateError(environment); + + // Act + var message = GetService().GetEventNotificationMailMessageData(ev, project, false, true, false); + + // Assert + Assert.Equal(expectedSubject, message.Subject); + } + + [Theory] + [InlineData("production", "[Notification project] A new error in Production: ")] + [InlineData("qa-West", "[Notification project] A new error in qa-West: ")] + [InlineData(null, "[Notification project] A new error: ")] + public void GetSlackEventNotificationMessage_WithEnvironment_MentionsItUsingTheListedCasing(string? environment, string expectedPrefix) + { + // Arrange + var project = CreateProject(); + var ev = CreateError(environment); + + // Act + var message = GetService().GetSlackEventNotificationMessage(ev, project, false, true, false); + + // Assert + Assert.StartsWith(expectedPrefix, message.Text); + } + + [Fact] + public void GetEventNotificationMailMessageData_WithCriticalRegressionInEnvironment_KeepsTheNotificationTypeBeforeTheEnvironment() + { + // Arrange + var project = CreateProject(); + var ev = CreateError("production"); + + // Act + var message = GetService().GetEventNotificationMailMessageData(ev, project, true, false, true); + + // Assert + Assert.Equal("Critical Error regression in Production: Boom", message.Subject); + } + + private static Project CreateProject() => new() + { + Id = "537650f3b77efe23a47914f4", + Name = "Notification project", + Environments = [new ProjectEnvironment { Name = "Production" }] + }; + + private static PersistentEvent CreateError(string? environment) => new() + { + Id = "event-id", + StackId = "stack-id", + ProjectId = "537650f3b77efe23a47914f4", + Type = Event.KnownTypes.Error, + Environment = environment, + Data = new DataDictionary { { Event.KnownDataKeys.SimpleError, new SimpleError { Message = "Boom" } } } + }; +} diff --git a/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs b/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs new file mode 100644 index 0000000000..07680130a7 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs @@ -0,0 +1,121 @@ +using Exceptionless.Core.Services; +using Foundatio.Caching; +using Foundatio.Xunit; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public class DiscardedEnvironmentServiceTests : TestWithLoggingBase +{ + private const string ProjectId = "537650f3b77efe23a47914f4"; + private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 11, 2, 12, 0, 0, TimeSpan.Zero)); + private readonly InMemoryCacheClient _cache; + private readonly DiscardedEnvironmentService _service; + + public DiscardedEnvironmentServiceTests(ITestOutputHelper output) : base(output) + { + _cache = new InMemoryCacheClient(o => o.TimeProvider(_timeProvider).LoggerFactory(Log)); + _service = new DiscardedEnvironmentService(_cache, _timeProvider); + } + + [Fact] + public async Task RecordAsync_WithDifferentCasing_CountsOneLowercasedKeyWithFirstSeenCasing() + { + // Arrange + await _service.RecordAsync(ProjectId, ["Prodution", "prodution"]); + _timeProvider.Advance(TimeSpan.FromMinutes(5)); + + // Act + await _service.RecordAsync(ProjectId, ["PRODUTION"]); + var discarded = await _service.GetAsync(ProjectId); + + // Assert + var entry = Assert.Single(discarded); + Assert.Equal("prodution", entry.Key); + Assert.Equal("Prodution", entry.Name); + Assert.Equal(3, entry.Count); + Assert.Equal(_timeProvider.GetUtcNow().UtcDateTime, entry.LastSeenUtc); + } + + [Fact] + public async Task RecordAsync_WithMissingEnvironments_TracksThemInTheirOwnBucket() + { + // Arrange + await _service.RecordAsync(ProjectId, [null, "", "dev", null]); + + // Act + var discarded = await _service.GetAsync(ProjectId); + + // Assert + Assert.Equal(2, discarded.Count); + var unspecified = Assert.Single(discarded, d => d.Key is null); + Assert.Null(unspecified.Name); + Assert.Equal(3, unspecified.Count); + Assert.Equal(1, Assert.Single(discarded, d => d.Key == "dev").Count); + } + + [Fact] + public async Task RecordAsync_WithMoreNamesThanTheCap_TracksOnlyTheFirstNamesAndKeepsCountingKnownOnes() + { + // Arrange + var names = Enumerable.Range(0, DiscardedEnvironmentService.MaximumNamesPerProject + 10).Select(i => $"env-{i}").ToList(); + foreach (string name in names) + await _service.RecordAsync(ProjectId, [name]); + + // Act + await _service.RecordAsync(ProjectId, ["ENV-0", "env-0"]); + var discarded = await _service.GetAsync(ProjectId); + + // Assert + Assert.Equal(DiscardedEnvironmentService.MaximumNamesPerProject, discarded.Count); + Assert.DoesNotContain(discarded, d => d.Key == names[^1]); + Assert.Equal(3, discarded.Single(d => d.Key == "env-0").Count); + } + + [Fact] + public async Task GetAsync_AfterTheRetentionWindow_ForgetsTheName() + { + // Arrange + await _service.RecordAsync(ProjectId, ["old"]); + _timeProvider.Advance(TimeSpan.FromDays(DiscardedEnvironmentService.RetentionDays - 1)); + await _service.RecordAsync(ProjectId, ["recent"]); + + // Act + _timeProvider.Advance(TimeSpan.FromDays(2)); + var discarded = await _service.GetAsync(ProjectId); + + // Assert + var entry = Assert.Single(discarded); + Assert.Equal("recent", entry.Key); + } + + [Fact] + public async Task GetAsync_WithOtherProject_ReturnsOnlyThatProjectsNames() + { + // Arrange + await _service.RecordAsync(ProjectId, ["dev"]); + + // Act + var discarded = await _service.GetAsync("537650f3b77efe23a47914f5"); + + // Assert + Assert.Empty(discarded); + } + + [Fact] + public async Task RemoveAsync_WithDifferentCasing_ForgetsOnlyThatName() + { + // Arrange + await _service.RecordAsync(ProjectId, ["Prodution", "dev", null]); + + // Act + await _service.RemoveAsync(ProjectId, "PRODUTION"); + await _service.RemoveAsync(ProjectId, null); + var discarded = await _service.GetAsync(ProjectId); + + // Assert + var entry = Assert.Single(discarded); + Assert.Equal("dev", entry.Key); + } +} diff --git a/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs b/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs index 9c5df0271c..4d06578ce3 100644 --- a/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs +++ b/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs @@ -25,6 +25,87 @@ private Project CreateValidProject() }; } + [Fact] + public async Task Validate_WhenEnvironmentsAreValid_ReturnsSuccess() + { + // Arrange + var project = CreateValidProject(); + project.Environments = [new ProjectEnvironment { Name = "Production" }, new ProjectEnvironment { Name = "qa west", Notify = false }]; + + // Act + var (isValid, _) = await _validator.ValidateAsync(project); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" production")] + [InlineData("production ")] + [InlineData("prod\tuction")] + [InlineData("prod\nuction")] + public async Task Validate_WhenEnvironmentNameIsInvalid_ReturnsError(string name) + { + // Arrange + var project = CreateValidProject(); + project.Environments = [new ProjectEnvironment { Name = name }]; + + // Act + var (isValid, errors) = await _validator.ValidateAsync(project); + + // Assert + Assert.False(isValid); + Assert.Contains(errors.Keys, k => String.Equals(k, nameof(Project.Environments))); + } + + [Fact] + public async Task Validate_WhenEnvironmentNameIsTooLong_ReturnsError() + { + // Arrange + var project = CreateValidProject(); + project.Environments = [new ProjectEnvironment { Name = new string('a', 65) }]; + + // Act + var (isValid, errors) = await _validator.ValidateAsync(project); + + // Assert + Assert.False(isValid); + Assert.Contains(errors.Keys, k => String.Equals(k, nameof(Project.Environments))); + } + + [Fact] + public async Task Validate_WhenEnvironmentNamesDifferOnlyByCase_ReturnsError() + { + // Arrange + var project = CreateValidProject(); + project.Environments = [new ProjectEnvironment { Name = "Production" }, new ProjectEnvironment { Name = "PRODUCTION" }]; + + // Act + var (isValid, errors) = await _validator.ValidateAsync(project); + + // Assert + Assert.False(isValid); + Assert.Contains(errors.Keys, k => String.Equals(k, nameof(Project.Environments))); + } + + [Theory] + [InlineData(100, true)] + [InlineData(101, false)] + public async Task Validate_WhenEnvironmentCountIsAtOrOverTheLimit_ReturnsExpectedResult(int count, bool expected) + { + // Arrange + var project = CreateValidProject(); + project.Environments = Enumerable.Range(0, count).Select(i => new ProjectEnvironment { Name = $"env-{i}" }).ToList(); + + // Act + var (isValid, _) = await _validator.ValidateAsync(project); + + // Assert + Assert.Equal(expected, isValid); + } + [Fact] public async Task Validate_WhenOrganizationIdIsValidObjectId_ReturnsSuccess() { From 5feccfa434dd216903ecade4de1b9caac7baf5c8 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 16:08:04 -0600 Subject: [PATCH 02/10] Harden environment notifications after review Escape client-controlled text in Slack messages. Environment names, project names and the notification type could mention @channel or inject links. Guard against a stale whole-document stack save restoring a claimed notification: a successful Elasticsearch claim also takes a per-stack cache key, so a restored flag is cleared again without notifying twice. The ingestion paths that saved the whole stack (tags and regressions) now patch only the fields they change. Regressions follow the same rule as new stacks. A regression seen only in a non-notifying environment is reported by the first notifying occurrence, and a status change drops anything still pending. Notifying environments behave as before. Names beyond the discarded-environment cap are no longer silently skipped: the page reports how many events came from how many other names. Matching and keys now share one normalization. Co-Authored-By: Claude Opus 5.5 --- docs/docs/environments.md | 3 +- .../Extensions/StackExtensions.cs | 11 ++ .../Models/DiscardedEnvironment.cs | 23 +++ .../Models/PendingStackNotification.cs | 10 ++ src/Exceptionless.Core/Models/Project.cs | 4 +- .../Models/ProjectEnvironment.cs | 8 +- src/Exceptionless.Core/Models/Stack.cs | 7 + .../Pipeline/010_AssignToStackAction.cs | 6 +- .../Pipeline/030_CheckForRegressionAction.cs | 9 +- .../Pipeline/070_QueueNotificationAction.cs | 40 +++-- .../Default/10_SimpleErrorFormattingPlugin.cs | 2 +- .../Default/20_ErrorFormattingPlugin.cs | 2 +- .../Default/30_NotFoundFormattingPlugin.cs | 2 +- .../Default/40_UsageFormattingPlugin.cs | 2 +- .../Default/60_LogFormattingPlugin.cs | 2 +- .../Default/99_DefaultFormattingPlugin.cs | 2 +- .../Formatting/FormattingPluginBase.cs | 10 +- .../Interfaces/IStackRepository.cs | 15 +- .../Repositories/StackRepository.cs | 79 +++++++-- .../Services/DiscardedEnvironmentService.cs | 57 ++++++- .../Api/Endpoints/ProjectEndpoints.cs | 6 +- .../Api/Handlers/ProjectHandler.cs | 10 +- .../Api/Handlers/StackHandler.cs | 2 + .../Api/Messages/ProjectMessages.cs | 4 +- .../Mcp/ExceptionlessMcpTools.cs | 2 + .../Project/ProjectEnvironmentModels.cs | 15 ++ .../ProjectEndpointTests.Environments.cs | 23 +++ .../Api/Endpoints/StackEndpointTests.cs | 29 ++++ tests/Exceptionless.Tests/Mail/MailerTests.cs | 31 ++++ .../EnvironmentManagementPipelineTests.cs | 154 +++++++++++++++++- .../NotificationEnvironmentFormattingTests.cs | 31 ++++ .../Repositories/StackRepositoryTests.cs | 65 +++++++- .../DiscardedEnvironmentServiceTests.cs | 55 +++++-- 33 files changed, 638 insertions(+), 83 deletions(-) create mode 100644 src/Exceptionless.Core/Models/PendingStackNotification.cs diff --git a/docs/docs/environments.md b/docs/docs/environments.md index a337029178..13c71ea72d 100644 --- a/docs/docs/environments.md +++ b/docs/docs/environments.md @@ -80,8 +80,9 @@ Ingestion acknowledges events before they are processed, so a client cannot see An event can send notifications (email, Slack, and web hooks) only when its environment notifies: a listed environment with notifications on, or an unlisted environment or no environment while **Send notifications for other environments** is on. -- Critical error, critical event, and regression notifications are sent only for events from a notifying environment. +- Critical error and critical event notifications are sent only for events from a notifying environment. - A new error or new event notification is sent the first time a stack occurs in a notifying environment. If an error is first seen in `dev` and `dev` does not notify, nothing is sent; the first time the same error occurs in `Production`, you get the new error notification, and it is not reported as new again in other environments. +- A regression works the same way as a new stack. If a fixed stack regresses in an environment that does not notify, nothing is sent; the first time the regressed stack occurs in a notifying environment, you get the regression notification. A regression in a notifying environment notifies immediately, as it always has. Changing the stack's status (for example marking it fixed or ignored) drops a regression or new notification that was still waiting. - Stacks that existed before environments could be managed are treated as already reported, so deploying this change does not send new notifications for old stacks. - Notification emails and Slack messages mention the environment, for example "New error in Production". - Daily summaries are unchanged and still include events from every environment. diff --git a/src/Exceptionless.Core/Extensions/StackExtensions.cs b/src/Exceptionless.Core/Extensions/StackExtensions.cs index 0f11c42880..da66f20520 100644 --- a/src/Exceptionless.Core/Extensions/StackExtensions.cs +++ b/src/Exceptionless.Core/Extensions/StackExtensions.cs @@ -8,6 +8,7 @@ public static class StackExtensions public static void MarkFixed(this Stack stack, SemanticVersion? version, TimeProvider timeProvider) { stack.Status = StackStatus.Fixed; + stack.ClearPendingNotifications(); stack.DateFixed = timeProvider.GetUtcNow().UtcDateTime; stack.FixedInVersion = version?.ToString(); stack.SnoozeUntilUtc = null; @@ -16,11 +17,21 @@ public static void MarkFixed(this Stack stack, SemanticVersion? version, TimePro public static void MarkOpen(this Stack stack) { stack.Status = StackStatus.Open; + stack.ClearPendingNotifications(); stack.DateFixed = null; stack.FixedInVersion = null; stack.SnoozeUntilUtc = null; } + /// + /// A status change settles any notification that was waiting for a notifying environment. Call this whenever the status is set directly. + /// + public static void ClearPendingNotifications(this Stack stack) + { + stack.IsNewNotificationPending = false; + stack.IsRegressionNotificationPending = false; + } + public static Stack ApplyOffset(this Stack stack, TimeSpan offset) { if (stack.DateFixed.HasValue) diff --git a/src/Exceptionless.Core/Models/DiscardedEnvironment.cs b/src/Exceptionless.Core/Models/DiscardedEnvironment.cs index aa711c2540..44996114a6 100644 --- a/src/Exceptionless.Core/Models/DiscardedEnvironment.cs +++ b/src/Exceptionless.Core/Models/DiscardedEnvironment.cs @@ -22,3 +22,26 @@ public record DiscardedEnvironment public DateTime LastSeenUtc { get; init; } } + +/// +/// The names that recently had events discarded, plus how many events came from names that could not be listed individually. +/// +public record DiscardedEnvironmentSummary +{ + public IReadOnlyCollection Environments { get; init; } = []; + + /// + /// Events within the retention window from names beyond the per-project limit. + /// + public long OtherEvents { get; init; } + + /// + /// How many different names those events came from, up to a limit. + /// + public int OtherNames { get; init; } + + /// + /// True when there were more other names than are counted. + /// + public bool OtherNamesCapped { get; init; } +} diff --git a/src/Exceptionless.Core/Models/PendingStackNotification.cs b/src/Exceptionless.Core/Models/PendingStackNotification.cs new file mode 100644 index 0000000000..ecf23a9785 --- /dev/null +++ b/src/Exceptionless.Core/Models/PendingStackNotification.cs @@ -0,0 +1,10 @@ +namespace Exceptionless.Core.Models; + +/// +/// A notification that waits for the first occurrence in a notifying environment. +/// +public enum PendingStackNotification +{ + New, + Regression +} diff --git a/src/Exceptionless.Core/Models/Project.cs b/src/Exceptionless.Core/Models/Project.cs index 3506bd10fa..dd3e877709 100644 --- a/src/Exceptionless.Core/Models/Project.cs +++ b/src/Exceptionless.Core/Models/Project.cs @@ -124,7 +124,7 @@ public static IEnumerable ValidateEnvironments(IReadOnlyCollec if (environments.Count > ProjectEnvironment.MaximumCount) yield return new ValidationResult($"A project cannot list more than {ProjectEnvironment.MaximumCount} environments.", [nameof(Environments)]); - var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var names = new HashSet(StringComparer.Ordinal); foreach (var environment in environments) { string? name = environment?.Name; @@ -134,7 +134,7 @@ public static IEnumerable ValidateEnvironments(IReadOnlyCollec continue; } - if (!names.Add(name)) + if (!names.Add(ProjectEnvironment.GetKey(name))) yield return new ValidationResult($"The environment '{name}' is listed more than once. Names are case-insensitive.", [nameof(Environments)]); } } diff --git a/src/Exceptionless.Core/Models/ProjectEnvironment.cs b/src/Exceptionless.Core/Models/ProjectEnvironment.cs index 2ff75ea665..f0c1f9ea32 100644 --- a/src/Exceptionless.Core/Models/ProjectEnvironment.cs +++ b/src/Exceptionless.Core/Models/ProjectEnvironment.cs @@ -36,5 +36,11 @@ public class ProjectEnvironment /// public static string GetKey(string name) => name.ToLowerInvariant(); - public static bool NamesMatch(string? first, string? second) => String.Equals(first, second, StringComparison.OrdinalIgnoreCase); + /// + /// Compares by so matching and keys can never disagree. Lowercasing keeps the length, which rules out most names without allocating. + /// + public static bool NamesMatch(string? first, string? second) + { + return first is not null && second is not null && first.Length == second.Length && String.Equals(GetKey(first), GetKey(second), StringComparison.Ordinal); + } } diff --git a/src/Exceptionless.Core/Models/Stack.cs b/src/Exceptionless.Core/Models/Stack.cs index 57420e35c5..0376273bc4 100644 --- a/src/Exceptionless.Core/Models/Stack.cs +++ b/src/Exceptionless.Core/Models/Stack.cs @@ -108,6 +108,13 @@ public class Stack : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, ISu /// public bool IsNewNotificationPending { get; set; } + /// + /// True when the stack regressed from an environment that does not send notifications and no notifying environment has + /// reported the regression yet. The first notifying occurrence clears this and is reported as the regression. + /// Changing the stack's status clears it. + /// + public bool IsRegressionNotificationPending { get; set; } + /// /// A list of references. /// diff --git a/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs b/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs index bc092f7bf7..def8756887 100644 --- a/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs +++ b/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs @@ -169,9 +169,9 @@ await _publisher.PublishAsync(new EntityChanged }); } - var stacksToSave = stacks.Where(s => s.Value.ShouldSave).Select(kvp => kvp.Value.Stack).ToList(); - if (stacksToSave.Count > 0) - await _stackRepository.SaveAsync(stacksToSave, o => o.Cache().Notifications(false)); // notification will get sent later in the update stats step + // Only the tags changed, so update just those. Saving the whole stack could overwrite a notification another batch just claimed. + foreach (var stack in stacks.Where(s => s.Value.ShouldSave).Select(kvp => kvp.Value.Stack)) + await _stackRepository.UpdateTagsAsync(stack); // Set stack ids after they have been saved and created contexts.ForEach(ctx => diff --git a/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs b/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs index f7a8c79a62..e97e11f1e7 100644 --- a/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs +++ b/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs @@ -1,4 +1,5 @@ -using Exceptionless.Core.Models; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.EventProcessor; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; @@ -73,8 +74,12 @@ public override async Task ProcessBatchAsync(ICollection contexts) continue; _logger.LogDebug("Marking stack and events as regressed in version: {Version}", regressedVersion); + // A regression seen only in an environment that does not notify is reported when a notifying environment first sees it. + bool notificationPending = !regressedContext.Project.ShouldNotifyForEnvironment(regressedContext.Event.Environment); stack.Status = StackStatus.Regressed; - await _stackRepository.MarkAsRegressedAsync(stack.Id); + stack.IsNewNotificationPending = false; + stack.IsRegressionNotificationPending = notificationPending; + await _stackRepository.MarkAsRegressedAsync(stack, notificationPending); foreach (var ctx in stackGroup) ctx.IsRegression = ctx == regressedContext; diff --git a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs index 65bfdf0d02..4c8e2c3095 100644 --- a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs +++ b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs @@ -42,22 +42,23 @@ public override async Task ProcessAsync(EventContext ctx) return; bool isNew = await IsNewForNotificationAsync(ctx); - if (ShouldQueueNotification(ctx, isNew)) + bool isRegression = ctx.IsRegression || await ClaimPendingAsync(ctx, PendingStackNotification.Regression); + if (ShouldQueueNotification(ctx, isNew, isRegression)) await _notificationQueue.EnqueueAsync(new EventNotification { EventId = ctx.Event.Id, IsNew = isNew, - IsRegression = ctx.IsRegression, + IsRegression = isRegression, TotalOccurrences = ctx.Stack.TotalOccurrences }); var webHooks = await _webHookRepository.GetByOrganizationIdOrProjectIdAsync(ctx.Event.OrganizationId, ctx.Event.ProjectId); foreach (var hook in webHooks.Documents) { - if (!ShouldCallWebHook(hook, ctx, isNew)) + if (!ShouldCallWebHook(hook, ctx, isNew, isRegression)) continue; - var context = new WebHookDataContext(hook, ctx.Organization, ctx.Project, ctx.Stack, ctx.Event, isNew, ctx.IsRegression); + var context = new WebHookDataContext(hook, ctx.Organization, ctx.Project, ctx.Stack, ctx.Event, isNew, isRegression); var notification = new WebHookNotification { OrganizationId = ctx.Event.OrganizationId, @@ -89,21 +90,28 @@ private async Task IsNewForNotificationAsync(EventContext ctx) if (ctx.IsNew) return true; - if (ctx.Stack is not { IsNewNotificationPending: true }) + return await ClaimPendingAsync(ctx, PendingStackNotification.New); + } + + /// + /// The repository clears the flag atomically, so concurrent events can only produce one notification. + /// + private async Task ClaimPendingAsync(EventContext ctx, PendingStackNotification kind) + { + bool isPending = kind == PendingStackNotification.New ? ctx.Stack!.IsNewNotificationPending : ctx.Stack!.IsRegressionNotificationPending; + if (!isPending) return false; - // The repository clears the flag atomically, so concurrent events can only produce one new notification. - if (!await _stackRepository.TryClaimNewNotificationAsync(ctx.Stack)) - { + bool claimed = await _stackRepository.TryClaimNotificationAsync(ctx.Stack, kind); + if (kind == PendingStackNotification.New) ctx.Stack.IsNewNotificationPending = false; - return false; - } + else + ctx.Stack.IsRegressionNotificationPending = false; - ctx.Stack.IsNewNotificationPending = false; - return true; + return claimed; } - private bool ShouldCallWebHook(WebHook hook, EventContext ctx, bool isNew) + private bool ShouldCallWebHook(WebHook hook, EventContext ctx, bool isNew, bool isRegression) { if (!hook.IsEnabled) return false; @@ -117,7 +125,7 @@ private bool ShouldCallWebHook(WebHook hook, EventContext ctx, bool isNew) if (ctx.Event.IsCritical() && ctx.Event.IsError() && hook.EventTypes.Contains(WebHook.KnownEventTypes.CriticalError)) return true; - if (ctx.IsRegression && hook.EventTypes.Contains(WebHook.KnownEventTypes.StackRegression)) + if (isRegression && hook.EventTypes.Contains(WebHook.KnownEventTypes.StackRegression)) return true; if (isNew && hook.EventTypes.Contains(WebHook.KnownEventTypes.NewEvent)) @@ -129,7 +137,7 @@ private bool ShouldCallWebHook(WebHook hook, EventContext ctx, bool isNew) return false; } - private bool ShouldQueueNotification(EventContext ctx, bool isNew) + private bool ShouldQueueNotification(EventContext ctx, bool isNew, bool isRegression) { if (ctx.Project.NotificationSettings.Count == 0) return false; @@ -140,7 +148,7 @@ private bool ShouldQueueNotification(EventContext ctx, bool isNew) if (ctx.Event.IsCritical() && ctx.Event.IsError() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportCriticalErrors)) return true; - if (ctx.IsRegression && ctx.Project.NotificationSettings.Any(n => n.Value.ReportEventRegressions)) + if (isRegression && ctx.Project.NotificationSettings.Any(n => n.Value.ReportEventRegressions)) return true; if (isNew && ctx.Project.NotificationSettings.Any(n => n.Value.ReportNewEvents)) diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs index 2246be7804..f375bd66e6 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/10_SimpleErrorFormattingPlugin.cs @@ -151,7 +151,7 @@ private bool ShouldHandle(PersistentEvent ev) AddDefaultSlackFields(ev, attachment.Fields); notificationType = InEnvironment(notificationType, ev, project); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, error.Message?.Truncate(120))}*"; + string subject = $"[{EscapeSlack(project.Name)}] A {EscapeSlack(notificationType)}: *{GetSlackEventUrl(ev.Id, error.Message?.Truncate(120))}*"; return new SlackMessage(subject) { Attachments = [attachment] diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs index 20b78a080c..b42b6f527f 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/20_ErrorFormattingPlugin.cs @@ -166,7 +166,7 @@ private bool ShouldHandle(PersistentEvent ev) AddDefaultSlackFields(ev, attachment.Fields); notificationType = InEnvironment(notificationType, ev, project); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, stackingTarget.Error.Message?.Truncate(120))}*"; + string subject = $"[{EscapeSlack(project.Name)}] A {EscapeSlack(notificationType)}: *{GetSlackEventUrl(ev.Id, stackingTarget.Error.Message?.Truncate(120))}*"; return new SlackMessage(subject) { Attachments = [attachment] diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs index 56a75b17a4..192071f7ce 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/30_NotFoundFormattingPlugin.cs @@ -103,7 +103,7 @@ private bool ShouldHandle(PersistentEvent ev) AddDefaultSlackFields(ev, attachment.Fields, false); notificationType = InEnvironment(notificationType, ev, project); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, ev.Source?.Truncate(120))}*"; + string subject = $"[{EscapeSlack(project.Name)}] A {EscapeSlack(notificationType)}: *{GetSlackEventUrl(ev.Id, ev.Source?.Truncate(120))}*"; return new SlackMessage(subject) { Attachments = [attachment] diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs index 42505806cf..44c940e403 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/40_UsageFormattingPlugin.cs @@ -70,7 +70,7 @@ private bool ShouldHandle(PersistentEvent ev) }; AddDefaultSlackFields(ev, attachment.Fields, false); - string subject = $"[{project.Name}] Feature: *{GetSlackEventUrl(ev.Id, ev.Source).Truncate(120)}*"; + string subject = $"[{EscapeSlack(project.Name)}] Feature: *{GetSlackEventUrl(ev.Id, ev.Source).Truncate(120)}*"; return new SlackMessage(subject) { Attachments = [attachment] diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs index 16efa3d957..7493b185e7 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/60_LogFormattingPlugin.cs @@ -156,7 +156,7 @@ private bool ShouldHandle(PersistentEvent ev) AddDefaultSlackFields(ev, attachment.Fields); notificationType = InEnvironment(notificationType, ev, project); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, source.Truncate(120))}*"; + string subject = $"[{EscapeSlack(project.Name)}] A {EscapeSlack(notificationType)}: *{GetSlackEventUrl(ev.Id, source.Truncate(120))}*"; return new SlackMessage(subject) { Attachments = [attachment] diff --git a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs index 8a42e579f7..57483b8967 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/Default/99_DefaultFormattingPlugin.cs @@ -100,7 +100,7 @@ public override SlackMessage GetSlackEventNotification(PersistentEvent ev, Proje AddDefaultSlackFields(ev, attachment.Fields); notificationType = InEnvironment(notificationType, ev, project); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, messageOrSource.Truncate(120))}*"; + string subject = $"[{EscapeSlack(project.Name)}] A {EscapeSlack(notificationType)}: *{GetSlackEventUrl(ev.Id, messageOrSource.Truncate(120))}*"; return new SlackMessage(subject) { Attachments = [attachment] diff --git a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs index a77065c4cf..aa4d4a67de 100644 --- a/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs +++ b/src/Exceptionless.Core/Plugins/Formatting/FormattingPluginBase.cs @@ -80,11 +80,19 @@ protected void AddDefaultSlackFields(PersistentEvent ev, List + /// Escapes text that came from a client or a user so Slack shows it literally instead of as a mention or link. + /// + protected static string EscapeSlack(string? value) + { + return value?.Replace("&", "&").Replace("<", "<").Replace(">", ">") ?? String.Empty; + } + protected string GetSlackEventUrl(string eventId, string? message = null) { var parts = new List { new AppUrlBuilder(_options.BaseURL).Event(eventId) }; if (!String.IsNullOrEmpty(message)) - parts.Add($"|{message.Replace("&", "&").Replace("<", "<").Replace(">", ">")}"); + parts.Add($"|{EscapeSlack(message)}"); return $"<{String.Join(String.Empty, parts)}>"; } diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs index 786933ba0f..08d8b5b246 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs @@ -9,12 +9,21 @@ public interface IStackRepository : IRepositoryOwnedByOrganizationAndProject GetStackBySignatureHashAsync(string projectId, string signatureHash); Task> GetIdsByQueryAsync(RepositoryQueryDescriptor query, CommandOptionsDescriptor? options = null); Task> GetExpiredSnoozedStatuses(DateTime utcNow, CommandOptionsDescriptor? options = null); - Task MarkAsRegressedAsync(string stackId); + /// + /// Marks the stack as regressed. When is true the regression came from an environment that + /// does not notify and is reported by the first notifying occurrence. + /// + Task MarkAsRegressedAsync(Stack stack, bool notificationPending = false); + + /// + /// Atomically clears a pending notification. Returns true only for the single caller that should send it. + /// + Task TryClaimNotificationAsync(Stack stack, PendingStackNotification kind); /// - /// Atomically clears . Returns true only for the single caller that cleared it. + /// Updates only the tags of the stack. /// - Task TryClaimNewNotificationAsync(Stack stack); + Task UpdateTagsAsync(Stack stack); Task IncrementEventCounterAsync(string organizationId, string projectId, string stackId, DateTime minOccurrenceDateUtc, DateTime maxOccurrenceDateUtc, int count, bool sendNotifications = true); Task SetEventCounterAsync(string stackId, DateTime firstOccurrenceUtc, DateTime lastOccurrenceUtc, long totalOccurrences, bool sendNotifications = true); Task> GetStacksForCleanupAsync(string organizationId, DateTime cutoff); diff --git a/src/Exceptionless.Core/Repositories/StackRepository.cs b/src/Exceptionless.Core/Repositories/StackRepository.cs index 4c70c4958d..fd66faf51a 100644 --- a/src/Exceptionless.Core/Repositories/StackRepository.cs +++ b/src/Exceptionless.Core/Repositories/StackRepository.cs @@ -14,6 +14,7 @@ public class StackRepository : RepositoryOwnedByOrganizationAndProject, I { private readonly TimeProvider _timeProvider; private const string STACKING_VERSION = "v2"; + private static readonly TimeSpan _claimGuardExpiration = TimeSpan.FromDays(7); public StackRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) : base(configuration.Stacks, validator, options) @@ -151,24 +152,29 @@ Instant parseDate(def dt) { } } - public async Task TryClaimNewNotificationAsync(Stack stack) + public async Task TryClaimNotificationAsync(Stack stack, PendingStackNotification kind) { // The update is applied by Elasticsearch to the current document, so only one caller can see the flag set. // Everyone else (and stacks that were never pending) becomes a no-op, which PatchAsync reports as not modified. - const string script = @" -if (ctx._source.is_new_notification_pending == true) { - ctx._source.is_new_notification_pending = false; -} else { + string field = GetPendingField(kind); + string script = $@" +if (ctx._source.{field} == true) {{ + ctx._source.{field} = false; +}} else {{ ctx.op = 'noop'; -}"; +}}"; try { bool claimed = await PatchAsync(stack.Id, new ScriptPatch(script.TrimScript()), o => o.Notifications(false)); - if (claimed) - await InvalidateCacheAsync([new ModifiedDocument(stack, stack)]); + if (!claimed) + return false; + + await InvalidateStackCacheAsync(stack); - return claimed; + // A whole-document save of a stale copy can put the flag back after it was claimed. Remembering the claim lets the next + // claim clear the restored flag again without reporting the same notification twice. + return await Cache.AddAsync(GetClaimCacheKey(stack.Id, kind), true, _claimGuardExpiration); } catch (DocumentNotFoundException) { @@ -176,6 +182,21 @@ public async Task TryClaimNewNotificationAsync(Stack stack) } } + public async Task UpdateTagsAsync(Stack stack) + { + // Only the tags change, so patch them instead of saving the whole document. A stale copy must not overwrite fields that other + // events changed while this batch was running, such as a claimed notification. + try + { + await PatchAsync(stack.Id, new PartialPatch(new { tags = stack.Tags }), o => o.Notifications(false)); + await InvalidateStackCacheAsync(stack); + } + catch (DocumentNotFoundException) + { + _logger.LogWarning("Stack {StackId} not found when updating tags", stack.Id); + } + } + public async Task GetStackBySignatureHashAsync(string projectId, string signatureHash) { string key = GetStackSignatureCacheKey(projectId, signatureHash); @@ -188,17 +209,30 @@ public Task> GetIdsByQueryAsync(RepositoryQueryDescriptor query.Configure().OnlyIds(), options); } - public async Task MarkAsRegressedAsync(string stackId) + public async Task MarkAsRegressedAsync(Stack stack, bool notificationPending = false) { - var stack = await GetByIdAsync(stackId); - if (stack is null) + // A script update changes only these fields on the current document, so a regression cannot restore or drop a claimed flag. + const string script = @" +ctx._source.status = 'regressed'; +ctx._source.is_new_notification_pending = false; +ctx._source.is_regression_notification_pending = params.notificationPending;"; + + try { - _logger.LogWarning("Stack {StackId} not found when marking as regressed", stackId); - return; - } + await PatchAsync(stack.Id, new ScriptPatch(script.TrimScript()) + { + Params = new Dictionary(1) { { "notificationPending", notificationPending } } + }); - stack.Status = StackStatus.Regressed; - await SaveAsync(stack, o => o.Cache()); + if (notificationPending) + await Cache.RemoveAsync(GetClaimCacheKey(stack.Id, PendingStackNotification.Regression)); + + await InvalidateStackCacheAsync(stack); + } + catch (DocumentNotFoundException) + { + _logger.LogWarning("Stack {StackId} not found when marking as regressed", stack.Id); + } } public Task SoftDeleteByProjectIdAsync(string organizationId, string projectId) @@ -231,6 +265,17 @@ protected override async Task InvalidateCacheAsync(IReadOnlyCollection InvalidateCacheAsync([new ModifiedDocument(stack, stack)]); + + private static string GetPendingField(PendingStackNotification kind) => kind switch + { + PendingStackNotification.New => "is_new_notification_pending", + PendingStackNotification.Regression => "is_regression_notification_pending", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + + private static string GetClaimCacheKey(string stackId, PendingStackNotification kind) => String.Concat("notification-claim:", stackId, ":", GetPendingField(kind)); + private static string GetStackSignatureCacheKey(Stack stack) => GetStackSignatureCacheKey(stack.ProjectId, stack.SignatureHash); private static string GetStackSignatureCacheKey(string projectId, string signatureHash) => String.Concat(projectId, ":", signatureHash, ":", STACKING_VERSION); } diff --git a/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs b/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs index e48a616649..8023d382d8 100644 --- a/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs +++ b/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs @@ -17,9 +17,11 @@ public class DiscardedEnvironmentService { public const int RetentionDays = 7; public const int MaximumNamesPerProject = 50; + public const int MaximumOtherNamesPerProject = 100; private const string UnspecifiedBucket = "unspecified"; private const string NamePrefix = "name:"; + private const string OtherBucket = "other"; private static readonly TimeSpan _retention = TimeSpan.FromDays(RetentionDays); private static readonly TimeSpan _counterRetention = TimeSpan.FromDays(RetentionDays + 1); @@ -57,10 +59,16 @@ public async Task RecordAsync(string projectId, IEnumerable environment var utcNow = _timeProvider.GetUtcNow().UtcDateTime; string day = GetDay(utcNow); + var otherBuckets = new List(); + int otherEvents = 0; foreach ((string bucket, (string? name, int count)) in groups) { if (!trackedBuckets.Contains(bucket) && trackedBuckets.Count >= MaximumNamesPerProject) + { + otherBuckets.Add(bucket); + otherEvents += count; continue; + } await Task.WhenAll( _cache.ListAddAsync(namesKey, bucket, _retention), @@ -71,23 +79,57 @@ await Task.WhenAll( trackedBuckets.Add(bucket); } + + if (otherEvents > 0) + await RecordOtherAsync(projectId, otherBuckets, otherEvents, day); + } + + private async Task RecordOtherAsync(string projectId, List buckets, int events, string day) + { + // Names past the limit are not tracked, but the page still reports that events were dropped and roughly from how many names. + string otherNamesKey = GetOtherNamesKey(projectId); + var known = await _cache.GetListAsync(otherNamesKey); + var knownBuckets = known.HasValue ? new HashSet(known.Value, StringComparer.Ordinal) : []; + var newBuckets = buckets.Where(b => !knownBuckets.Contains(b)).Take(Math.Max(MaximumOtherNamesPerProject - knownBuckets.Count, 0)).ToList(); + + var tasks = new List { _cache.IncrementAsync(GetCountKey(projectId, OtherBucket, day), events, _counterRetention) }; + if (newBuckets.Count > 0) + tasks.Add(_cache.ListAddAsync(otherNamesKey, newBuckets, _retention)); + + await Task.WhenAll(tasks); } /// - /// Gets the discarded names, most discarded first. + /// Gets the discarded names, most discarded first, and the events from names that were not tracked. /// - public async Task> GetAsync(string projectId) + public async Task GetAsync(string projectId) { ArgumentException.ThrowIfNullOrEmpty(projectId); - var tracked = await _cache.GetListAsync(GetNamesKey(projectId)); - if (!tracked.HasValue || tracked.Value.Count == 0) - return []; - - var buckets = tracked.Value.Distinct(StringComparer.Ordinal).ToList(); var utcNow = _timeProvider.GetUtcNow().UtcDateTime; var days = Enumerable.Range(0, RetentionDays).Select(offset => GetDay(utcNow.AddDays(-offset))).ToList(); + var otherCounts = await _cache.GetAllAsync(days.Select(d => GetCountKey(projectId, OtherBucket, d))); + long otherEvents = otherCounts.Values.Sum(v => v.HasValue ? v.Value : 0); + var otherNames = otherEvents > 0 ? await _cache.GetListAsync(GetOtherNamesKey(projectId)) : default; + int otherNameCount = otherNames is { HasValue: true } ? otherNames.Value.Count : 0; + + var tracked = await _cache.GetListAsync(GetNamesKey(projectId)); + var environments = tracked.HasValue && tracked.Value.Count > 0 ? await GetTrackedAsync(projectId, tracked.Value, days) : []; + + return new DiscardedEnvironmentSummary + { + Environments = environments, + OtherEvents = otherEvents, + OtherNames = otherNameCount, + OtherNamesCapped = otherNameCount >= MaximumOtherNamesPerProject + }; + } + + private async Task> GetTrackedAsync(string projectId, ICollection tracked, List days) + { + var buckets = tracked.Distinct(StringComparer.Ordinal).ToList(); + var displayNames = await _cache.GetAllAsync(buckets.Select(b => GetDisplayKey(projectId, b))); var lastSeen = await _cache.GetAllAsync(buckets.Select(b => GetLastSeenKey(projectId, b))); var counts = await _cache.GetAllAsync(buckets.SelectMany(b => days.Select(d => GetCountKey(projectId, b, d)))); @@ -141,6 +183,7 @@ public async Task RemoveAsync(string projectId, string? environment) private static string GetBucket(string? environment) => String.IsNullOrEmpty(environment) ? UnspecifiedBucket : NamePrefix + ProjectEnvironment.GetKey(environment); private static string GetDay(DateTime utc) => utc.ToString("yyyyMMdd"); private static string GetPrefix(string projectId) => String.Concat("project:", projectId, ":discarded-environments"); + private static string GetOtherNamesKey(string projectId) => String.Concat(GetPrefix(projectId), ":other-names"); private static string GetNamesKey(string projectId) => String.Concat(GetPrefix(projectId), ":names"); private static string GetDisplayKey(string projectId, string bucket) => String.Concat(GetPrefix(projectId), ":display:", bucket); private static string GetLastSeenKey(string projectId, string bucket) => String.Concat(GetPrefix(projectId), ":last:", bucket); diff --git a/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs index 346c2b874a..3816697594 100644 --- a/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs @@ -504,7 +504,7 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild }); group.MapPatch("projects/{id:objectid}/environments", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProjectEnvironment? changes, string? name = null) - => changes is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectEnvironmentMessage(id, name, changes, httpContext))).ToHttpResult(resultMapper)) + => changes is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new ProjectMessages.ChangeProjectEnvironment(id, name, changes, httpContext))).ToHttpResult(resultMapper)) .RequireAuthorization(AuthorizationRoles.UserPolicy) .Accepts("application/json", "application/*+json") .Produces() @@ -541,7 +541,7 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild }); group.MapPut("projects/{id:objectid}/environments/settings", async (string id, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, [FromBody] UpdateProjectEnvironmentSettings? settings) - => settings is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new ProjectMessages.UpdateProjectEnvironmentSettingsMessage(id, settings, httpContext))).ToHttpResult(resultMapper)) + => settings is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync>(new ProjectMessages.ChangeProjectEnvironmentSettings(id, settings, httpContext))).ToHttpResult(resultMapper)) .RequireAuthorization(AuthorizationRoles.UserPolicy) .Accepts("application/json", "application/*+json") .Produces() @@ -575,7 +575,7 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild } }); - group.MapGet("organizations/{organizationId:objectid}/projects/check-name",async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? name = null, string? projectId = null) + group.MapGet("organizations/{organizationId:objectid}/projects/check-name", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? name = null, string? projectId = null) => (await mediator.InvokeAsync(new ProjectMessages.CheckProjectName(name, organizationId, projectId, httpContext))).ToHttpResult(resultMapper)) .RequireAuthorization(AuthorizationRoles.UserPolicy) .Produces(StatusCodes.Status201Created) diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 7da0713896..44c800fbad 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -487,7 +487,7 @@ public async Task> Handle(AddProjectEnvironme return MapEnvironmentSettings(project); } - public async Task> Handle(UpdateProjectEnvironmentMessage message) + public async Task> Handle(ChangeProjectEnvironment message) { var project = await GetModelAsync(message.Id, message.Context, useCache: false); if (project is null) @@ -520,7 +520,7 @@ public async Task> Handle(RemoveProjectEnviro return MapEnvironmentSettings(project); } - public async Task> Handle(UpdateProjectEnvironmentSettingsMessage message) + public async Task> Handle(ChangeProjectEnvironmentSettings message) { var project = await GetModelAsync(message.Id, message.Context, useCache: false); if (project is null) @@ -545,10 +545,14 @@ public async Task> Handle(GetDiscardedProje if (project is null) return Result.NotFound("Project not found."); + var discarded = await discardedEnvironmentService.GetAsync(project.Id); return new ProjectDiscardedEnvironments { RetentionDays = DiscardedEnvironmentService.RetentionDays, - Environments = await discardedEnvironmentService.GetAsync(project.Id) + Environments = discarded.Environments, + OtherEvents = discarded.OtherEvents, + OtherNames = discarded.OtherNames, + OtherNamesCapped = discarded.OtherNamesCapped }; } diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs index 5602041978..1da7cb11ea 100644 --- a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -112,6 +112,7 @@ public async Task Handle(SnoozeStacks message) foreach (var stack in stacks) { stack.Status = StackStatus.Snoozed; + stack.ClearPendingNotifications(); stack.SnoozeUntilUtc = message.SnoozeUntilUtc; stack.FixedInVersion = null; stack.DateFixed = null; @@ -228,6 +229,7 @@ public async Task Handle(ChangeStacksStatus message) foreach (var stack in stacks) { stack.Status = message.Status; + stack.ClearPendingNotifications(); if (message.Status == StackStatus.Fixed) { stack.DateFixed = timeProvider.GetUtcNow().UtcDateTime; diff --git a/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs b/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs index 8583f8f341..ba0ffee9d3 100644 --- a/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/ProjectMessages.cs @@ -31,7 +31,7 @@ public record DeleteProjectData(string Id, string? Key, HttpContext Context); public record AddProjectSlack(string Id, string Code, HttpContext Context); public record RemoveProjectSlack(string Id, HttpContext Context); public record AddProjectEnvironment(string Id, NewProjectEnvironment Environment, HttpContext Context); -public record UpdateProjectEnvironmentMessage(string Id, string? Name, UpdateProjectEnvironment Changes, HttpContext Context); +public record ChangeProjectEnvironment(string Id, string? Name, UpdateProjectEnvironment Changes, HttpContext Context); public record RemoveProjectEnvironment(string Id, string? Name, HttpContext Context); -public record UpdateProjectEnvironmentSettingsMessage(string Id, UpdateProjectEnvironmentSettings Settings, HttpContext Context); +public record ChangeProjectEnvironmentSettings(string Id, UpdateProjectEnvironmentSettings Settings, HttpContext Context); public record GetDiscardedProjectEnvironments(string Id, HttpContext Context); diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 1b7a66970b..6a230fd7e1 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -777,6 +777,7 @@ public async Task> UpdateStackStatusAsync( else if (changed) { stack.Status = stackStatus; + stack.ClearPendingNotifications(); stack.DateFixed = null; stack.FixedInVersion = null; stack.SnoozeUntilUtc = null; @@ -832,6 +833,7 @@ public async Task> SnoozeStackAsync( if (changed) { stack.Status = StackStatus.Snoozed; + stack.ClearPendingNotifications(); stack.SnoozeUntilUtc = untilUtc; stack.FixedInVersion = null; stack.DateFixed = null; diff --git a/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs b/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs index dfb082c193..05ec6f67bf 100644 --- a/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs +++ b/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs @@ -60,4 +60,19 @@ public record ProjectDiscardedEnvironments public int RetentionDays { get; set; } public IReadOnlyCollection Environments { get; set; } = []; + + /// + /// Events from names beyond the per-project limit, which are counted but not listed individually. + /// + public long OtherEvents { get; set; } + + /// + /// How many different names those events came from, up to a limit. + /// + public int OtherNames { get; set; } + + /// + /// True when there were more other names than are counted. + /// + public bool OtherNamesCapped { get; set; } } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs index 54823070d7..e1e3fa5803 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs @@ -268,6 +268,29 @@ public async Task GetDiscardedEnvironmentsAsync_ReturnsCountsLastSeenAndFirstSee Assert.NotEqual(default, typo.LastSeenUtc); var missing = Assert.Single(result.Environments, e => e.Key is null); Assert.Equal(1, missing.Count); + Assert.Equal(0, result.OtherEvents); + } + + [Fact] + public async Task GetDiscardedEnvironmentsAsync_WithMoreNamesThanAreTracked_ReportsTheOtherEvents() + { + // Arrange + var discarded = GetService(); + await discarded.RecordAsync(SampleDataService.TEST_PROJECT_ID, Enumerable.Range(0, DiscardedEnvironmentService.MaximumNamesPerProject + 3).Select(i => (string?)$"env-{i}")); + + // Act + var result = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, EnvironmentsPath, "discarded") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.Equal(DiscardedEnvironmentService.MaximumNamesPerProject, result.Environments.Count); + Assert.Equal(3, result.OtherEvents); + Assert.Equal(3, result.OtherNames); + Assert.False(result.OtherNamesCapped); } [Fact] diff --git a/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs index 47cb1f1636..892656dc24 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs @@ -202,6 +202,35 @@ await SendRequestAsync(r => r Assert.NotNull(stack.DateFixed); } + [Theory] + [InlineData("status")] + [InlineData("snooze")] + public async Task StatusChange_WithPendingNotifications_DropsThem(string action) + { + // Arrange + var ev = await SubmitErrorEventAsync(); + var pending = (await _stackRepository.GetByIdAsync(ev.StackId))!; + pending.IsNewNotificationPending = true; + pending.IsRegressionNotificationPending = true; + await _stackRepository.SaveAsync(pending, o => o.ImmediateConsistency()); + + // Act + if (action == "status") + { + await SendRequestAsync(r => r.Post().AsGlobalAdminUser().AppendPath($"stacks/{ev.StackId}/change-status").QueryString("status", "fixed").StatusCodeShouldBeOk()); + } + else + { + await SendRequestAsync(r => r.Post().AsGlobalAdminUser().AppendPath($"stacks/{ev.StackId}/mark-snoozed").QueryString("snoozeUntilUtc", TimeProvider.GetUtcNow().AddDays(1).ToString("o")).StatusCodeShouldBeOk()); + } + + // Assert + var stack = await _stackRepository.GetByIdAsync(ev.StackId); + Assert.NotNull(stack); + Assert.False(stack.IsNewNotificationPending); + Assert.False(stack.IsRegressionNotificationPending); + } + [Theory] [InlineData("ignored", StackStatus.Ignored)] [InlineData("DISCARDED", StackStatus.Discarded)] diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 9aa950e99c..0e2e50516b 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -229,6 +229,37 @@ public async Task SendEventNoticeAsync_WithSimpleError_RendersEventNotice() Assert.Contains(exception.Message, body, StringComparison.Ordinal); } + [Fact] + public async Task SendEventNoticeAsync_WithHostileEnvironment_EncodesItInTheBody() + { + // Arrange + var ev = new PersistentEvent + { + Type = Event.KnownTypes.Error, + Environment = " login", + Data = new Core.Models.DataDictionary { + { Event.KnownDataKeys.SimpleError, new SimpleError { Message = "Boom" } } + } + }; + + ev.Id = TestConstants.EventId; + ev.OrganizationId = TestConstants.OrganizationId; + ev.ProjectId = TestConstants.ProjectId; + ev.StackId = TestConstants.StackId; + var project = _projectData.GenerateSampleProject(); + project.Name = ""; + + // Act + await _mailer.SendEventNoticeAsync(_userData.GenerateSampleUser(), ev, project, true, false, 1); + string body = await RunMailJobAsync(); + + // Assert + Assert.DoesNotContain("login", body, StringComparison.Ordinal); + Assert.DoesNotContain("", body, StringComparison.Ordinal); + Assert.DoesNotContain("", body, StringComparison.Ordinal); + Assert.Contains("<script>alert(1)</script>", body, StringComparison.Ordinal); + } + [Fact] public async Task SendEventNoticeAsync_WithStructuredError_RendersEventNotice() { diff --git a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs index 648d22c4fa..678b6dabf3 100644 --- a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs @@ -97,7 +97,7 @@ public async Task RunAsync_WithUnlistedEnvironmentAndDiscardEnabled_DiscardsBefo Assert.False(listed.IsDiscarded); Assert.Equal(1, await _stackRepository.CountAsync()); Assert.Equal(1, await _eventRepository.CountAsync()); - var discarded = Assert.Single(await _discardedEnvironments.GetAsync(project.Id)); + var discarded = Assert.Single((await _discardedEnvironments.GetAsync(project.Id)).Environments); Assert.Equal("prodution", discarded.Key); Assert.Equal("Prodution", discarded.Name); Assert.Equal(2, discarded.Count); @@ -119,7 +119,7 @@ public async Task RunAsync_WithoutEnvironment_DiscardsOnlyWhenEventsWithoutEnvir // Act var context = await RunAsync(project, null); - var discarded = await _discardedEnvironments.GetAsync(project.Id); + var discarded = (await _discardedEnvironments.GetAsync(project.Id)).Environments; // Assert Assert.Equal(expectedDiscard, context.IsDiscarded); @@ -346,6 +346,150 @@ public async Task RunAsync_WithRegression_NotifiesOnlyWhenTheEnvironmentNotifies Assert.True(notification.IsRegression); } + [Fact] + public async Task RunAsync_WhenAStaleStackSaveRestoresTheClaimedFlag_DoesNotNotifyAsNewTwice() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var dev = await RunAsync(project, "dev"); + var stale = (await _stackRepository.GetByIdAsync(dev.Event.StackId))!; + Assert.True(stale.IsNewNotificationPending); + await RunAsync(project, "production"); + Assert.Single(await DequeueNotificationsAsync()); + + // Act + await _stackRepository.SaveAsync(stale, o => o.ImmediateConsistency()); + Assert.True((await _stackRepository.GetByIdAsync(stale.Id))!.IsNewNotificationPending); + await RunAsync(project, "production"); + var notifications = await DequeueNotificationsAsync(); + + // Assert + Assert.Empty(notifications); + Assert.False((await _stackRepository.GetByIdAsync(stale.Id))!.IsNewNotificationPending); + } + + [Fact] + public async Task RunAsync_WhenAStackRegressesInANonNotifyingEnvironment_NotifiesOnTheFirstNotifyingOccurrence() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var first = await RunAsync(project, "production"); + await DequeueNotificationsAsync(); + await MarkFixedAsync(first.Event.StackId); + + // Act + var dev = await RunAsync(project, "dev"); + var devNotifications = await DequeueNotificationsAsync(); + var pendingStack = (await _stackRepository.GetByIdAsync(first.Event.StackId))!; + var production = await RunAsync(project, "production"); + var productionNotifications = await DequeueNotificationsAsync(); + await RunAsync(project, "production"); + var laterNotifications = await DequeueNotificationsAsync(); + + // Assert + Assert.True(dev.IsRegression); + Assert.Empty(devNotifications); + Assert.Equal(StackStatus.Regressed, pendingStack.Status); + Assert.True(pendingStack.IsRegressionNotificationPending); + Assert.False(production.IsRegression); + var notification = Assert.Single(productionNotifications); + Assert.True(notification.IsRegression); + Assert.False(notification.IsNew); + Assert.Empty(laterNotifications); + Assert.False((await _stackRepository.GetByIdAsync(first.Event.StackId))!.IsRegressionNotificationPending); + } + + [Fact] + public async Task RunAsync_WhenAStackRegressesInANotifyingEnvironment_NotifiesImmediatelyAndNeverMarksItPending() + { + // Arrange + var project = await ConfigureProjectAsync(_ => { }); + var first = await RunAsync(project, "production"); + await DequeueNotificationsAsync(); + await MarkFixedAsync(first.Event.StackId); + + // Act + var regression = await RunAsync(project, "production"); + var notifications = await DequeueNotificationsAsync(); + await RunAsync(project, "production"); + var laterNotifications = await DequeueNotificationsAsync(); + + // Assert + Assert.True(regression.IsRegression); + Assert.Equal(regression.Event.Id, Assert.Single(notifications).EventId); + Assert.True(notifications[0].IsRegression); + Assert.False((await _stackRepository.GetByIdAsync(first.Event.StackId))!.IsRegressionNotificationPending); + Assert.Empty(laterNotifications); + } + + [Fact] + public async Task RunAsync_WhenTheStatusChangesWhileARegressionIsPending_DropsThePendingNotification() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var first = await RunAsync(project, "production"); + await DequeueNotificationsAsync(); + await MarkFixedAsync(first.Event.StackId); + await RunAsync(project, "dev"); + var stack = (await _stackRepository.GetByIdAsync(first.Event.StackId))!; + Assert.True(stack.IsRegressionNotificationPending); + + // Act + stack.MarkOpen(); + await _stackRepository.SaveAsync(stack, o => o.ImmediateConsistency()); + await RunAsync(project, "production"); + var notifications = await DequeueNotificationsAsync(); + + // Assert + Assert.Empty(notifications); + Assert.False((await _stackRepository.GetByIdAsync(first.Event.StackId))!.IsRegressionNotificationPending); + } + + [Fact] + public async Task RunAsync_WhenTwoBatchesTouchAPendingStack_TagsUpdateDoesNotRestoreAClaimedFlag() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var dev = await RunAsync(project, "dev"); + var staleBatchStack = (await _stackRepository.GetByIdAsync(dev.Event.StackId))!; + await RunAsync(project, "production"); + await DequeueNotificationsAsync(); + + // Act + staleBatchStack.Tags.Add("late"); + await _stackRepository.UpdateTagsAsync(staleBatchStack); + await RefreshDataAsync(); + + // Assert + var stack = (await _stackRepository.GetByIdAsync(dev.Event.StackId))!; + Assert.False(stack.IsNewNotificationPending); + Assert.Contains("late", stack.Tags); + } + + private async Task MarkFixedAsync(string stackId) + { + var stack = (await _stackRepository.GetByIdAsync(stackId))!; + stack.MarkFixed(null, TimeProvider); + stack.DateFixed = DateTime.UtcNow.AddHours(-1); + await _stackRepository.SaveAsync(stack, o => o.ImmediateConsistency()); + } + [Fact] public async Task TryClaimNewNotificationAsync_WithConcurrentCallers_ReturnsTrueForExactlyOne() { @@ -355,8 +499,8 @@ public async Task TryClaimNewNotificationAsync_WithConcurrentCallers_ReturnsTrue stack = await _stackRepository.SaveAsync(stack, o => o.ImmediateConsistency()); // Act - var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => _stackRepository.TryClaimNewNotificationAsync(stack))); - bool claimedAgain = await _stackRepository.TryClaimNewNotificationAsync(stack); + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => _stackRepository.TryClaimNotificationAsync(stack, PendingStackNotification.New))); + bool claimedAgain = await _stackRepository.TryClaimNotificationAsync(stack, PendingStackNotification.New); // Assert Assert.Equal(1, results.Count(r => r)); @@ -371,7 +515,7 @@ public async Task TryClaimNewNotificationAsync_WithStackThatIsNotPending_Returns var stack = await _stackRepository.AddAsync(GetService().GenerateStack(projectId: SampleDataService.TEST_PROJECT_ID, organizationId: SampleDataService.TEST_ORG_ID), o => o.ImmediateConsistency()); // Act - bool claimed = await _stackRepository.TryClaimNewNotificationAsync(stack); + bool claimed = await _stackRepository.TryClaimNotificationAsync(stack, PendingStackNotification.New); // Assert Assert.False(claimed); diff --git a/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs b/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs index f5eaf5b805..77e6324718 100644 --- a/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs +++ b/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs @@ -56,6 +56,37 @@ public void GetEventNotificationMailMessageData_WithCriticalRegressionInEnvironm Assert.Equal("Critical Error regression in Production: Boom", message.Subject); } + [Theory] + [InlineData(Event.KnownTypes.Error)] + [InlineData(Event.KnownTypes.Log)] + [InlineData(Event.KnownTypes.NotFound)] + [InlineData(Event.KnownTypes.FeatureUsage)] + [InlineData("custom")] + public void GetSlackEventNotificationMessage_WithHostileEnvironmentAndProjectName_EscapesThemSoSlackShowsThemLiterally(string type) + { + // Arrange + const string hostile = " & more"; + var project = new Project { Id = "537650f3b77efe23a47914f4", Name = " project" }; + var ev = CreateError(hostile); + ev.Type = type; + ev.Source = "source"; + + // Act + var message = GetService().GetSlackEventNotificationMessage(ev, project, false, true, false); + + // Assert + int linkStart = message.Text.IndexOf("*<", StringComparison.Ordinal); + string text = message.Text[..linkStart]; + Assert.DoesNotContain("<", text); + Assert.DoesNotContain(">", text); + Assert.Contains("<!here> project", text); + if (type != Event.KnownTypes.FeatureUsage) + Assert.Contains("<!channel> <https://evil.example|login> & more", text); + + // The only real link is the event link. + Assert.Equal(1, message.Text.Count(c => c == '<')); + } + private static Project CreateProject() => new() { Id = "537650f3b77efe23a47914f4", diff --git a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs index 54b4093187..0101f78614 100644 --- a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs @@ -120,6 +120,69 @@ public async Task CanGetByFixedAsync() Assert.Equal(0, results.Total); } + [Fact] + public async Task TryClaimNotificationAsync_WhenAStaleSaveRestoresTheFlag_ClearsItAgainWithoutClaiming() + { + // Arrange + var stack = await AddPendingStackAsync(); + var stale = (await _repository.GetByIdAsync(stack.Id))!; + bool claimed = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.New); + + // Act + await _repository.SaveAsync(stale, o => o.ImmediateConsistency()); + bool restored = (await _repository.GetByIdAsync(stack.Id))!.IsNewNotificationPending; + bool claimedAgain = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.New); + + // Assert + Assert.True(claimed); + Assert.True(restored); + Assert.False(claimedAgain); + Assert.False((await _repository.GetByIdAsync(stack.Id))!.IsNewNotificationPending); + } + + [Fact] + public async Task MarkAsRegressedAsync_WithPendingNotification_AllowsANewClaimEvenAfterAnEarlierOne() + { + // Arrange + var stack = await AddPendingStackAsync(); + await _repository.MarkAsRegressedAsync(stack, notificationPending: true); + bool first = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.Regression); + + // Act + await _repository.MarkAsRegressedAsync(stack, notificationPending: true); + bool second = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.Regression); + + // Assert + Assert.True(first); + Assert.True(second); + } + + [Fact] + public async Task MarkAsRegressedAsync_WithStaleStack_ChangesOnlyTheStatusAndNotificationFlags() + { + // Arrange + var stack = await AddPendingStackAsync(); + var stale = (await _repository.GetByIdAsync(stack.Id))!; + await _repository.IncrementEventCounterAsync(stack.OrganizationId, stack.ProjectId, stack.Id, DateTime.UtcNow, DateTime.UtcNow, 5); + + // Act + await _repository.MarkAsRegressedAsync(stale); + + // Assert + var saved = (await _repository.GetByIdAsync(stack.Id))!; + Assert.Equal(StackStatus.Regressed, saved.Status); + Assert.Equal(5, saved.TotalOccurrences); + Assert.False(saved.IsNewNotificationPending); + Assert.False(saved.IsRegressionNotificationPending); + } + + private async Task AddPendingStackAsync() + { + var stack = await _repository.AddAsync(_stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId), o => o.ImmediateConsistency()); + stack.IsNewNotificationPending = true; + return await _repository.SaveAsync(stack, o => o.ImmediateConsistency()); + } + [Fact] public async Task CanMarkAsRegressedAsync() { @@ -128,7 +191,7 @@ public async Task CanMarkAsRegressedAsync() Assert.False(stack.Status == StackStatus.Regressed); Assert.NotNull(stack.DateFixed); - await _repository.MarkAsRegressedAsync(stack.Id); + await _repository.MarkAsRegressedAsync(stack); stack = await _repository.GetByIdAsync(stack.Id); Assert.NotNull(stack); diff --git a/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs b/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs index 07680130a7..e0b73c043d 100644 --- a/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs +++ b/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs @@ -28,7 +28,7 @@ public async Task RecordAsync_WithDifferentCasing_CountsOneLowercasedKeyWithFirs // Act await _service.RecordAsync(ProjectId, ["PRODUTION"]); - var discarded = await _service.GetAsync(ProjectId); + var discarded = (await _service.GetAsync(ProjectId)).Environments; // Assert var entry = Assert.Single(discarded); @@ -45,7 +45,7 @@ public async Task RecordAsync_WithMissingEnvironments_TracksThemInTheirOwnBucket await _service.RecordAsync(ProjectId, [null, "", "dev", null]); // Act - var discarded = await _service.GetAsync(ProjectId); + var discarded = (await _service.GetAsync(ProjectId)).Environments; // Assert Assert.Equal(2, discarded.Count); @@ -56,7 +56,7 @@ public async Task RecordAsync_WithMissingEnvironments_TracksThemInTheirOwnBucket } [Fact] - public async Task RecordAsync_WithMoreNamesThanTheCap_TracksOnlyTheFirstNamesAndKeepsCountingKnownOnes() + public async Task RecordAsync_WithMoreNamesThanTheCap_TracksTheFirstNamesAndCountsTheRestAsOtherEvents() { // Arrange var names = Enumerable.Range(0, DiscardedEnvironmentService.MaximumNamesPerProject + 10).Select(i => $"env-{i}").ToList(); @@ -65,12 +65,47 @@ public async Task RecordAsync_WithMoreNamesThanTheCap_TracksOnlyTheFirstNamesAnd // Act await _service.RecordAsync(ProjectId, ["ENV-0", "env-0"]); - var discarded = await _service.GetAsync(ProjectId); + await _service.RecordAsync(ProjectId, [names[^1], names[^1]]); + var summary = await _service.GetAsync(ProjectId); // Assert - Assert.Equal(DiscardedEnvironmentService.MaximumNamesPerProject, discarded.Count); - Assert.DoesNotContain(discarded, d => d.Key == names[^1]); - Assert.Equal(3, discarded.Single(d => d.Key == "env-0").Count); + Assert.Equal(DiscardedEnvironmentService.MaximumNamesPerProject, summary.Environments.Count); + Assert.DoesNotContain(summary.Environments, d => d.Key == names[^1]); + Assert.Equal(3, summary.Environments.Single(d => d.Key == "env-0").Count); + Assert.Equal(12, summary.OtherEvents); + Assert.Equal(10, summary.OtherNames); + Assert.False(summary.OtherNamesCapped); + } + + [Fact] + public async Task RecordAsync_WithManyMoreNamesThanTheCap_CapsTheCountOfOtherNames() + { + // Arrange + int total = DiscardedEnvironmentService.MaximumNamesPerProject + DiscardedEnvironmentService.MaximumOtherNamesPerProject + 20; + await _service.RecordAsync(ProjectId, Enumerable.Range(0, total).Select(i => (string?)$"env-{i}")); + + // Act + var summary = await _service.GetAsync(ProjectId); + + // Assert + Assert.Equal(DiscardedEnvironmentService.MaximumNamesPerProject, summary.Environments.Count); + Assert.Equal(total - DiscardedEnvironmentService.MaximumNamesPerProject, summary.OtherEvents); + Assert.Equal(DiscardedEnvironmentService.MaximumOtherNamesPerProject, summary.OtherNames); + Assert.True(summary.OtherNamesCapped); + } + + [Fact] + public async Task GetAsync_WhenNothingOverflowed_ReportsNoOtherEvents() + { + // Arrange + await _service.RecordAsync(ProjectId, ["dev"]); + + // Act + var summary = await _service.GetAsync(ProjectId); + + // Assert + Assert.Equal(0, summary.OtherEvents); + Assert.Equal(0, summary.OtherNames); } [Fact] @@ -83,7 +118,7 @@ public async Task GetAsync_AfterTheRetentionWindow_ForgetsTheName() // Act _timeProvider.Advance(TimeSpan.FromDays(2)); - var discarded = await _service.GetAsync(ProjectId); + var discarded = (await _service.GetAsync(ProjectId)).Environments; // Assert var entry = Assert.Single(discarded); @@ -97,7 +132,7 @@ public async Task GetAsync_WithOtherProject_ReturnsOnlyThatProjectsNames() await _service.RecordAsync(ProjectId, ["dev"]); // Act - var discarded = await _service.GetAsync("537650f3b77efe23a47914f5"); + var discarded = (await _service.GetAsync("537650f3b77efe23a47914f5")).Environments; // Assert Assert.Empty(discarded); @@ -112,7 +147,7 @@ public async Task RemoveAsync_WithDifferentCasing_ForgetsOnlyThatName() // Act await _service.RemoveAsync(ProjectId, "PRODUTION"); await _service.RemoveAsync(ProjectId, null); - var discarded = await _service.GetAsync(ProjectId); + var discarded = (await _service.GetAsync(ProjectId)).Environments; // Assert var entry = Assert.Single(discarded); From 0a4018fc4270007326e62add5aba6de963e509a6 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 16:17:32 -0600 Subject: [PATCH 03/10] Confirm risky environment settings and let users fix observed casing Adding an observed environment now pre-fills the add form instead of saving the lowercase aggregation key, so the casing can be confirmed or corrected first. Turning on discarding of unlisted environments asks for confirmation when no environment is listed or none of the listed ones has had recent events. The discarded table reports events from names beyond the tracked limit. The add form uses the generated project environment schema, the filter trigger and event cell titles show the listed casing, and the change-environment messages are named like their siblings. Co-Authored-By: Claude Opus 5.5 --- .../e2e/tests/project-environments.e2e.ts | 23 ++- .../environment-faceted-filter-trigger.svelte | 6 +- .../table/event-environment-cell.svelte | 8 +- .../event-environment-cell.svelte.test.ts | 51 ++++++ .../events/components/views/overview.svelte | 10 +- .../components/environment-name.svelte | 17 +- .../environments/add-environment-form.svelte | 37 +++- .../discarded-environments-table.svelte | 28 ++- .../environments/environments.svelte.test.ts | 46 +++++ .../projects/environment-display.svelte.ts | 29 ++++ .../features/projects/environments.test.ts | 26 +++ .../src/lib/features/projects/environments.ts | 16 ++ .../src/lib/features/projects/schemas.ts | 25 ++- .../ClientApp/src/lib/generated/api.ts | 18 ++ .../ClientApp/src/lib/generated/schemas.ts | 4 + .../[projectId]/environments/+page.svelte | 97 +++++++++-- .../environments-page.svelte.test.ts | 159 ++++++++++++++++++ .../Exceptionless.Tests/Api/Data/openapi.json | 24 ++- 18 files changed, 574 insertions(+), 50 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/environment-display.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts index dffba53906..050e94ac6c 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts @@ -25,6 +25,17 @@ test('operator can list, configure, and filter environments and allow discarded await expect(page.getByRole('row', { name: /^staging Not listed/ })).toBeVisible(); }); + await test.step('discarding unlisted environments asks for confirmation while nothing is listed', async () => { + const discard = page.getByRole('switch', { name: 'Discard events from environments that are not listed' }); + await discard.click(); + + const dialog = page.getByRole('alertdialog'); + await expect(dialog.getByText(/every event that has an environment will be discarded/)).toBeVisible(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + await expect(discard).not.toBeChecked(); + }); + await test.step('adding a name lists it in the casing that was typed and merges the observed row', async () => { await page.getByLabel('Environment name').fill(' Production '); await page.getByRole('button', { name: 'Add environment' }).click(); @@ -38,6 +49,16 @@ test('operator can list, configure, and filter environments and allow discarded await expect(page.getByText('PRODUCTION is already listed. Names are not case-sensitive.')).toBeVisible(); }); + await test.step('adding an observed name pre-fills the form so the casing can be corrected', async () => { + await page.getByRole('button', { name: 'Add staging' }).click(); + await expect(page.getByLabel('Environment name')).toHaveValue('staging'); + await expect(page.getByRole('row', { name: /^staging Not listed/ })).toBeVisible(); + + await page.getByLabel('Environment name').fill('Staging'); + await page.getByRole('button', { name: 'Add environment' }).click(); + await expect(page.getByRole('row', { name: /^Staging Listed/ })).toBeVisible(); + }); + await test.step('notifications can be turned off for a listed environment and persist', async () => { const notify = page.getByRole('switch', { name: 'Send notifications for Production' }); await expect(notify).toBeChecked(); @@ -58,7 +79,7 @@ test('operator can list, configure, and filter environments and allow discarded await page.getByText('Environment', { exact: true }).click(); await expect(page.getByRole('option', { exact: true, name: 'Production' })).toBeVisible(); - await expect(page.getByRole('option', { exact: true, name: 'staging' })).toBeVisible(); + await expect(page.getByRole('option', { exact: true, name: 'Staging' })).toBeVisible(); await page.keyboard.press('Escape'); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-trigger.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-trigger.svelte index 1723b68631..edf715325f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-trigger.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-trigger.svelte @@ -7,16 +7,18 @@ type Props = Omit & { changed: (filter: EnvironmentFilter) => void; + /** How the environment is shown, which is the listed casing for a listed environment. */ + label?: null | string; value?: null | string; }; - let { changed, children, value, ...props }: Props = $props(); + let { changed, children, label, value, ...props }: Props = $props(); {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte.test.ts new file mode 100644 index 0000000000..3722ae1058 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte.test.ts @@ -0,0 +1,51 @@ +import { cleanup, render, screen } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getProjectQuery: vi.fn() +})); + +vi.mock('#features/projects/api.svelte.ts', () => ({ + getProjectQuery: mocks.getProjectQuery +})); + +import EnvironmentTrigger from '../filters/environment-faceted-filter-trigger.svelte'; +import EventEnvironmentCell from './event-environment-cell.svelte'; + +describe('environment display casing', () => { + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + it('shows the listed casing in the cell and in its filter title', () => { + // Arrange + mocks.getProjectQuery.mockReturnValue({ data: { environments: [{ name: 'Production', notify: true }] } }); + + // Act + render(EventEnvironmentCell, { changed: vi.fn(), environment: 'production', projectId: 'project-id' }); + + // Assert + const button = screen.getByRole('button', { name: 'Production' }); + expect(button.getAttribute('title')).toBe('Filter by environment: Production'); + }); + + it('keeps the reported casing for an unlisted environment', () => { + // Arrange + mocks.getProjectQuery.mockReturnValue({ data: { environments: [] } }); + + // Act + render(EventEnvironmentCell, { changed: vi.fn(), environment: 'qa-West', projectId: 'project-id' }); + + // Assert + expect(screen.getByRole('button', { name: 'qa-West' }).getAttribute('title')).toBe('Filter by environment: qa-West'); + }); + + it('titles the filter trigger with the label it is given', () => { + // Act + render(EnvironmentTrigger, { changed: vi.fn(), label: 'Production', value: 'production' }); + + // Assert + expect(screen.getByRole('button').getAttribute('title')).toBe('Filter by environment: Production'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte index 16d7132f18..e354db7dbe 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte @@ -20,6 +20,7 @@ hasErrorOrSimpleError } from '#features/events/persistent-event.ts'; import EnvironmentName from '#features/projects/components/environment-name.svelte'; + import { useEnvironmentDisplayName } from '#features/projects/environment-display.svelte.ts'; import TagList from '#features/shared/components/tag-list.svelte'; import type { PersistentEvent } from '../../models/index'; @@ -71,6 +72,11 @@ let requestUrl = $derived(getRequestInfoUrl(event)); let requestUrlPath = $derived(getRequestInfoPath(event)); let version = $derived(event.data?.['@version']); + + const environmentDisplay = useEnvironmentDisplayName({ + environment: () => event.environment, + projectId: () => event.project_id + }); @@ -78,7 +84,9 @@ {#if event.environment} Environment - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte index 3cec044613..6d2a7af86e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte @@ -1,6 +1,5 @@ -{name} +{display.name ?? unspecified} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte index 60f6c45a14..b960b26457 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte @@ -2,6 +2,7 @@ import { ProblemDetails } from '@foundatiofx/fetchclient'; import Plus from '@lucide/svelte/icons/plus'; import { createForm } from '@tanstack/svelte-form'; + import { untrack } from 'svelte'; import { toast } from 'svelte-sonner'; import type { NewProjectEnvironment } from '#features/projects/models.ts'; @@ -12,27 +13,39 @@ import { Input } from '#comp/ui/input/index.ts'; import { Spinner } from '#comp/ui/spinner/index.ts'; import { Switch } from '#comp/ui/switch/index.ts'; - import { getEnvironmentKey, MAX_LISTED_ENVIRONMENTS } from '#features/projects/environments.ts'; - import { type AddEnvironmentFormData, AddEnvironmentSchema } from '#features/projects/schemas.ts'; + import { getEnvironmentKey, MAX_LISTED_ENVIRONMENTS, normalizeEnvironmentName } from '#features/projects/environments.ts'; + import { type NewProjectEnvironmentFormData, NewProjectEnvironmentSchema } from '#features/projects/schemas.ts'; import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '#features/shared/validation.ts'; interface Props { add: (environment: NewProjectEnvironment) => Promise; /** The names that are already listed. Names are compared case-insensitively. */ listedNames: string[]; + /** Fills the name so it can be confirmed or corrected before adding. A new object fills it again. */ + prefill?: { name: string }; } - let { add, listedNames }: Props = $props(); + let { add, listedNames, prefill }: Props = $props(); + + let nameInput = $state(null); const form = createForm(() => ({ defaultValues: { name: '', notify: true - } as AddEnvironmentFormData, + } as NewProjectEnvironmentFormData, validators: { - onSubmit: AddEnvironmentSchema, + onSubmit: NewProjectEnvironmentSchema, onSubmitAsync: async ({ value }) => { - const name = value.name.trim(); + const name = normalizeEnvironmentName(value.name); + if (!name) { + return { + fields: { + name: 'Name must be 1 to 64 characters and cannot contain control characters.' + } + }; + } + if (listedNames.some((listed) => getEnvironmentKey(listed) === getEnvironmentKey(name))) { return { fields: { @@ -67,6 +80,17 @@ } } })); + + $effect(() => { + const name = prefill?.name; + if (name !== undefined) { + // Only a new prefill should fill the field, not the form state that setting it changes. + untrack(() => { + form.setFieldValue('name', name); + nameInput?.focus(); + }); + } + });
Environment name Promise | unknown; unspecified?: DiscardedEnvironment; } - let { allow, busy = false, isError = false, isLoading = false, named, retentionDays, stopDiscardingUnspecified, unspecified }: Props = $props(); + let { + allow, + busy = false, + isError = false, + isLoading = false, + named, + otherEvents = 0, + otherNames = 0, + otherNamesCapped = false, + retentionDays, + stopDiscardingUnspecified, + unspecified + }: Props = $props(); - const hasRows = $derived(named.length > 0 || !!unspecified); + const hasRows = $derived(named.length > 0 || !!unspecified || otherEvents > 0);
@@ -69,6 +85,14 @@ {/each} + {#if otherEvents > 0} + + + {otherEvents.toLocaleString()} events from {otherNamesCapped ? 'at least ' : ''}{otherNames.toLocaleString()} other names were discarded. + Only the first {named.length.toLocaleString()} names are listed. Fix the clients that send these names, or list the ones you want. + + + {/if} {#if unspecified} No environment diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts index 79bc651033..0b9430d2a1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts @@ -70,6 +70,22 @@ describe('DiscardedEnvironmentsTable', () => { expect(stopDiscardingUnspecified).toHaveBeenCalledOnce(); }); + it('reports events from names that were too many to list', () => { + // Act + render(DiscardedEnvironmentsTable, { + allow: vi.fn(), + named: [{ count: 5, key: 'dev', last_seen_utc: '2026-11-02T09:00:00Z', name: 'dev' }], + otherEvents: 1200, + otherNames: 100, + otherNamesCapped: true, + retentionDays: 7, + stopDiscardingUnspecified: vi.fn() + }); + + // Assert + expect(screen.getByText(/1,200 events from at least 100 other names were discarded/)).toBeTruthy(); + }); + it('explains when nothing has been discarded', () => { // Act render(DiscardedEnvironmentsTable, { allow: vi.fn(), named: [], retentionDays: 7, stopDiscardingUnspecified: vi.fn() }); @@ -140,6 +156,36 @@ describe('AddEnvironmentForm', () => { expect(add).not.toHaveBeenCalled(); }); + it('fills in the name it is given so the casing can be confirmed before adding', async () => { + // Arrange + const add = vi.fn().mockResolvedValue(undefined); + render(AddEnvironmentForm, { add, listedNames: [], prefill: { name: 'qa-west' } }); + const input = screen.getByLabelText('Environment name') as HTMLInputElement; + await waitFor(() => expect(input.value).toBe('qa-west')); + + // Act + await userEvent.clear(input); + await userEvent.type(input, 'QA-West'); + await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + + // Assert + await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'QA-West', notify: true })); + }); + + it('rejects a name that is only whitespace', async () => { + // Arrange + const add = vi.fn(); + render(AddEnvironmentForm, { add, listedNames: [] }); + + // Act + await userEvent.type(screen.getByLabelText('Environment name'), ' '); + await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + + // Assert + expect(await screen.findByText(/Name must be 1 to 64 characters/)).toBeTruthy(); + expect(add).not.toHaveBeenCalled(); + }); + it('requires a name', async () => { // Arrange const add = vi.fn(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environment-display.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environment-display.svelte.ts new file mode 100644 index 0000000000..cc7e13c1ea --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environment-display.svelte.ts @@ -0,0 +1,29 @@ +import { getProjectQuery } from '#features/projects/api.svelte.ts'; +import { getEnvironmentDisplayName } from '#features/projects/environments.ts'; + +interface Request { + /** The environment as stored on the event. */ + environment: () => null | string | undefined; + projectId: () => string | undefined; +} + +/** + * Resolves how an event's environment is shown. Listed environments always show in their listed casing; anything else shows the + * way the event reported it. The project is only loaded when there is an environment to show. + */ +export function useEnvironmentDisplayName(request: Request) { + const projectQuery = getProjectQuery({ + route: { + get id() { + return request.environment() ? request.projectId() : undefined; + } + } + }); + + return { + get name(): string | undefined { + const environment = request.environment(); + return environment ? getEnvironmentDisplayName(projectQuery.data?.environments, environment) : undefined; + } + }; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts index 23b3c95433..5d9e23ef44 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildEnvironmentRows, + getDiscardUnlistedWarning, getEnvironmentDisplayName, getListedEnvironmentNames, normalizeEnvironmentName, @@ -105,3 +106,28 @@ describe('splitDiscardedEnvironments', () => { expect(splitDiscardedEnvironments(undefined)).toEqual({ named: [], unspecified: undefined }); }); }); + +describe('getDiscardUnlistedWarning', () => { + const rows = (...counts: (number | undefined)[]) => + buildEnvironmentRows( + counts.map((_, index) => listed(`env-${index}`)), + counts.flatMap((count, index) => (count === undefined ? [] : [{ count, key: `env-${index}` }])) + ); + + it.each([ + ['no listed environments', [], true, 'empty-list'], + ['no listed environment has events', [undefined, 0], true, 'no-recent-events'], + ['a listed environment has events', [undefined, 3], true, undefined], + ['recent activity has not loaded', [undefined], false, undefined] + ])('returns the right warning when %s', (_, counts, loaded, expected) => { + expect(getDiscardUnlistedWarning(rows(...(counts as (number | undefined)[])), loaded)).toBe(expected); + }); + + it('does not count unlisted environments that have events', () => { + // Arrange + const mixed = buildEnvironmentRows([listed('Production')], [{ count: 50, key: 'qa-west' }]); + + // Act & Assert + expect(getDiscardUnlistedWarning(mixed, true)).toBe('no-recent-events'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts index 05ec91b265..2667ae55e4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts @@ -3,6 +3,8 @@ import type { DiscardedEnvironment, ProjectEnvironment } from '#generated/api.ts export const MAX_ENVIRONMENT_NAME_LENGTH = 64; export const MAX_LISTED_ENVIRONMENTS = 100; +export type DiscardUnlistedWarning = 'empty-list' | 'no-recent-events'; + export interface EnvironmentRow { count: number; isListed: boolean; @@ -63,6 +65,20 @@ export function buildEnvironmentRows(listed: null | ProjectEnvironment[] | undef return [...listedRows, ...unlistedRows]; } +/** + * Discarding unlisted environments drops every event whose environment is not in the list. That is almost never intended when the list + * is empty, or when none of the listed environments has sent events recently, so the page asks for confirmation first. + * Recent activity is only judged once it has loaded. + */ +export function getDiscardUnlistedWarning(rows: EnvironmentRow[], observedLoaded: boolean): DiscardUnlistedWarning | undefined { + const listed = rows.filter((row) => row.isListed); + if (listed.length === 0) { + return 'empty-list'; + } + + return observedLoaded && listed.every((row) => row.count === 0) ? 'no-recent-events' : undefined; +} + /** Returns the listed casing for a listed environment, otherwise the name as it was given. */ export function getEnvironmentDisplayName(environments: null | ProjectEnvironment[] | undefined, name: string): string { const key = getEnvironmentKey(name); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts index 669490bd17..3ac27db022 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts @@ -1,16 +1,23 @@ -import { boolean, custom, type infer as Infer, object, string } from 'zod'; +import { custom, type infer as Infer, object, string } from 'zod'; import { UpdateProjectSchema as GeneratedUpdateProjectSchema, + type NewProjectEnvironmentFormData, + NewProjectEnvironmentSchema, type NewProjectFormData, NewProjectSchema, type NotificationSettingsFormData, NotificationSettingsSchema } from '#generated/schemas.ts'; -import { hasControlCharacters, MAX_ENVIRONMENT_NAME_LENGTH } from './environments'; - -export { type NewProjectFormData, NewProjectSchema, type NotificationSettingsFormData, NotificationSettingsSchema }; +export { + type NewProjectEnvironmentFormData, + NewProjectEnvironmentSchema, + type NewProjectFormData, + NewProjectSchema, + type NotificationSettingsFormData, + NotificationSettingsSchema +}; export const ClientConfigurationSettingSchema = object({ key: string().min(1, 'Key is required'), @@ -26,13 +33,3 @@ export const SourceMapUploadSchema = object({ generated_file_url: string().url('Enter the absolute URL of the generated JavaScript file') }); export type SourceMapUploadFormData = Infer; - -export const AddEnvironmentSchema = object({ - name: string() - .trim() - .min(1, 'Name is required') - .max(MAX_ENVIRONMENT_NAME_LENGTH, `Name must be at most ${MAX_ENVIRONMENT_NAME_LENGTH} characters`) - .refine((name) => !hasControlCharacters(name), 'Name cannot contain control characters'), - notify: boolean() -}); -export type AddEnvironmentFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 68aaed416f..bc0c1b8ead 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -536,6 +536,18 @@ export interface ProjectDiscardedEnvironments { */ retention_days: number; environments: DiscardedEnvironment[]; + /** + * Events from names beyond the per-project limit, which are counted but not listed individually. + * @format int64 + */ + other_events: number; + /** + * How many different names those events came from, up to a limit. + * @format int32 + */ + other_names: number; + /** True when there were more other names than are counted. */ + other_names_capped: boolean; } /** A deployment environment that a project has chosen to list, such as production or staging. */ @@ -675,6 +687,12 @@ export interface Stack { * Stacks created before this field existed are not pending, so they never produce a new notification for it. */ is_new_notification_pending: boolean; + /** + * True when the stack regressed from an environment that does not send notifications and no notifying environment has + * reported the regression yet. The first notifying occurrence clears this and is reported as the regression. + * Changing the stack's status clears it. + */ + is_regression_notification_pending: boolean; /** A list of references. */ references: string[]; /** A list of tags used to categorize this stack. */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 8b344ca4b7..9d9f3cd5ea 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -663,6 +663,9 @@ export type ProblemDetailsFormData = Infer; export const ProjectDiscardedEnvironmentsSchema = object({ retention_days: int32(), environments: array(lazy(() => DiscardedEnvironmentSchema)), + other_events: int(), + other_names: int32(), + other_names_capped: boolean(), }); export type ProjectDiscardedEnvironmentsFormData = Infer< typeof ProjectDiscardedEnvironmentsSchema @@ -777,6 +780,7 @@ export const StackSchema = object({ description: string().min(1, "Description is required").nullable().optional(), occurrences_are_critical: boolean(), is_new_notification_pending: boolean(), + is_regression_notification_pending: boolean(), references: array(string()), tags: array(string()), duplicate_signature: string().min(1, "Duplicate signature is required"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte index 3347de3309..678769958f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte @@ -20,7 +20,7 @@ import DiscardedEnvironmentsTable from '#features/projects/components/environments/discarded-environments-table.svelte'; import EnvironmentSettings from '#features/projects/components/environments/environment-settings.svelte'; import EnvironmentsTable from '#features/projects/components/environments/environments-table.svelte'; - import { buildEnvironmentRows, type EnvironmentRow, splitDiscardedEnvironments } from '#features/projects/environments.ts'; + import { buildEnvironmentRows, type EnvironmentRow, getDiscardUnlistedWarning, splitDiscardedEnvironments } from '#features/projects/environments.ts'; import * as agg from '#features/shared/api/aggregations.ts'; const projectId = $derived(page.params.projectId || ''); @@ -53,6 +53,11 @@ let environmentToRemove = $state(); let showRemoveDialog = $state(false); + let prefill = $state<{ name: string }>(); + let pendingSettings = $state(); + let showDiscardDialog = $state(false); + // Changing this remounts the settings so a switch that was cancelled goes back to the saved value. + let settingsVersion = $state(0); const project = $derived(projectQuery.data); const busy = $derived(addEnvironment.isPending || updateEnvironment.isPending || removeEnvironment.isPending || updateSettings.isPending); @@ -65,6 +70,7 @@ ); const rows = $derived(buildEnvironmentRows(project?.environments, observed)); const discarded = $derived(splitDiscardedEnvironments(discardedQuery.data?.environments)); + const discardWarning = $derived(getDiscardUnlistedWarning(rows, observedQuery.isSuccess)); async function add(name: string, display = name) { try { @@ -82,6 +88,28 @@ await add(environment.name ?? environment.key!, environment.name ?? environment.key!); } + function cancelDiscard() { + pendingSettings = undefined; + settingsVersion++; + } + + function requestChange(settings: UpdateProjectEnvironmentSettings) { + if (settings.discard_unlisted_environments && discardWarning) { + pendingSettings = settings; + showDiscardDialog = true; + return; + } + + return change(settings); + } + + // Cancel, Escape and clicking outside all close the dialog without confirming. + $effect(() => { + if (!showDiscardDialog && pendingSettings) { + cancelDiscard(); + } + }); + async function change(settings: UpdateProjectEnvironmentSettings) { try { await updateSettings.mutateAsync(settings); @@ -91,6 +119,15 @@ } } + async function confirmDiscard() { + const settings = pendingSettings; + pendingSettings = undefined; + showDiscardDialog = false; + if (settings) { + await change(settings); + } + } + function confirmRemove(row: EnvironmentRow) { environmentToRemove = row; showRemoveDialog = true; @@ -138,19 +175,25 @@

Add Environment

- addEnvironment.mutateAsync(environment)} listedNames={project?.environments?.map((e) => e.name) ?? []} /> + addEnvironment.mutateAsync(environment)} + listedNames={project?.environments?.map((e) => e.name) ?? []} + {prefill} + />

Settings

{#if project} - + {#key settingsVersion} + + {/key} {/if}
@@ -158,7 +201,10 @@

Environments in Use

Listed environments and the environments seen in this project's events over the last 30 days. add(row.name)} + add={(row) => + (prefill = { + name: row.name + })} {busy} isError={projectQuery.isError || observedQuery.isError} isLoading={projectQuery.isLoading || observedQuery.isLoading} @@ -181,6 +227,9 @@ isError={discardedQuery.isError} isLoading={discardedQuery.isLoading} named={discarded.named} + otherEvents={discardedQuery.data?.other_events} + otherNames={discardedQuery.data?.other_names} + otherNamesCapped={discardedQuery.data?.other_names_capped} retentionDays={discardedQuery.data?.retention_days ?? 7} stopDiscardingUnspecified={() => change({ @@ -191,6 +240,34 @@
+ + + + Discard Events From Unlisted Environments? + + {#if discardWarning === 'empty-list'} + No environments are listed, so every event that has an environment will be discarded until you list them. Clients + are told the events were accepted, so nothing will look wrong on their side. + {:else} + None of the listed environments has had events in the last 30 days, so events from the environments you are sending now will be discarded. Clients are told the events were accepted, so nothing will look wrong on their side. + {/if} + You can turn this off again at any time, but discarded events are not recovered. + + + + Cancel + Discard Unlisted Environments + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts new file mode 100644 index 0000000000..1c87b443a2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts @@ -0,0 +1,159 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + addEnvironment: vi.fn(), + countData: undefined as unknown, + project: undefined as unknown, + updateSettings: vi.fn() +})); + +vi.mock('$app/state', () => ({ + page: { params: { projectId: 'project-id' } } +})); + +vi.mock('#features/events/api.svelte.ts', () => ({ + getProjectCountQuery: () => ({ + get data() { + return mocks.countData; + }, + isError: false, + isLoading: false, + isSuccess: true + }) +})); + +vi.mock('#features/projects/api.svelte.ts', () => ({ + deleteProjectEnvironment: () => ({ isPending: false, mutateAsync: vi.fn() }), + getProjectDiscardedEnvironments: () => ({ + data: { environments: [], other_events: 0, other_names: 0, other_names_capped: false, retention_days: 7 }, + isError: false, + isLoading: false + }), + getProjectQuery: () => ({ + get data() { + return mocks.project; + }, + isError: false, + isLoading: false + }), + patchProjectEnvironment: () => ({ isPending: false, mutateAsync: vi.fn() }), + postProjectEnvironment: () => ({ isPending: false, mutateAsync: mocks.addEnvironment }), + putProjectEnvironmentSettings: () => ({ isPending: false, mutateAsync: mocks.updateSettings }) +})); + +import EnvironmentsPage from './+page.svelte'; + +const project = (environments: { created_utc: string; name: string; notify: boolean }[]) => ({ + discard_events_without_environment: false, + discard_unlisted_environments: false, + environments, + id: 'project-id', + notify_other_environments: true +}); + +const observed = (...environments: { count: number; key: string }[]) => ({ + aggregations: { + terms_environment: { + items: environments.map((environment) => ({ + aggregations: { max_date: { value: '2026-11-02T10:00:00Z' } }, + key: environment.key, + total: environment.count + })) + } + } +}); + +describe('Environments page', () => { + beforeEach(() => { + mocks.addEnvironment.mockResolvedValue({}); + mocks.updateSettings.mockResolvedValue({}); + mocks.countData = observed(); + mocks.project = project([]); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('asks for confirmation before discarding unlisted environments when no environment is listed', async () => { + // Arrange + render(EnvironmentsPage); + + // Act + await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + + // Assert + expect(await screen.findByText(/every event that has an environment will be discarded/)).toBeTruthy(); + expect(mocks.updateSettings).not.toHaveBeenCalled(); + }); + + it('saves the setting once the warning is confirmed', async () => { + // Arrange + render(EnvironmentsPage); + await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + + // Act + await userEvent.click(await screen.findByRole('button', { name: 'Discard Unlisted Environments' })); + + // Assert + await waitFor(() => expect(mocks.updateSettings).toHaveBeenCalledWith({ discard_unlisted_environments: true })); + }); + + it('does not save the setting when the warning is cancelled and puts the switch back', async () => { + // Arrange + render(EnvironmentsPage); + const discard = () => screen.getByRole('switch', { name: 'Discard events from environments that are not listed' }); + await userEvent.click(discard()); + + // Act + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + + // Assert + await waitFor(() => expect(discard().getAttribute('aria-checked')).toBe('false')); + expect(mocks.updateSettings).not.toHaveBeenCalled(); + }); + + it('warns when none of the listed environments has had events recently', async () => { + // Arrange + mocks.project = project([{ created_utc: '2026-11-01T00:00:00Z', name: 'Production', notify: true }]); + mocks.countData = observed({ count: 4, key: 'qa-west' }); + render(EnvironmentsPage); + + // Act + await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + + // Assert + expect(await screen.findByText(/None of the listed environments has had events in the last 30 days/)).toBeTruthy(); + expect(mocks.updateSettings).not.toHaveBeenCalled(); + }); + + it('saves immediately when a listed environment has recent events', async () => { + // Arrange + mocks.project = project([{ created_utc: '2026-11-01T00:00:00Z', name: 'Production', notify: true }]); + mocks.countData = observed({ count: 4, key: 'production' }); + render(EnvironmentsPage); + + // Act + await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + + // Assert + await waitFor(() => expect(mocks.updateSettings).toHaveBeenCalledWith({ discard_unlisted_environments: true })); + expect(screen.queryByRole('button', { name: 'Discard Unlisted Environments' })).toBeNull(); + }); + + it('fills the add form with the observed name so the casing can be confirmed instead of adding it', async () => { + // Arrange + mocks.countData = observed({ count: 4, key: 'qa-west' }); + render(EnvironmentsPage); + + // Act + await userEvent.click(await screen.findByRole('button', { name: 'Add qa-west' })); + + // Assert + await waitFor(() => expect((screen.getByLabelText('Environment name') as HTMLInputElement).value).toBe('qa-west')); + expect(mocks.addEnvironment).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index bdfdfd115c..27caa36048 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -13805,7 +13805,10 @@ "ProjectDiscardedEnvironments": { "required": [ "retention_days", - "environments" + "environments", + "other_events", + "other_names", + "other_names_capped" ], "type": "object", "properties": { @@ -13819,6 +13822,20 @@ "items": { "$ref": "#/components/schemas/DiscardedEnvironment" } + }, + "other_events": { + "type": "integer", + "description": "Events from names beyond the per-project limit, which are counted but not listed individually.", + "format": "int64" + }, + "other_names": { + "type": "integer", + "description": "How many different names those events came from, up to a limit.", + "format": "int32" + }, + "other_names_capped": { + "type": "boolean", + "description": "True when there were more other names than are counted." } } }, @@ -14044,6 +14061,7 @@ "last_occurrence", "occurrences_are_critical", "is_new_notification_pending", + "is_regression_notification_pending", "references", "tags", "duplicate_signature", @@ -14155,6 +14173,10 @@ "type": "boolean", "description": "True when the stack was created by an event from an environment that does not send notifications and no notifying\nenvironment has reported it yet. The first occurrence in a notifying environment clears this and is reported as new.\nStacks created before this field existed are not pending, so they never produce a new notification for it." }, + "is_regression_notification_pending": { + "type": "boolean", + "description": "True when the stack regressed from an environment that does not send notifications and no notifying environment has\nreported the regression yet. The first notifying occurrence clears this and is reported as the regression.\nChanging the stack\u0027s status clears it." + }, "references": { "type": "array", "items": { From 03eaf9def5fa3f22aae5d6180bc8d3c88c408b9f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 16:34:35 -0600 Subject: [PATCH 04/10] Close remaining races and gaps in pending notification handling Key the regression claim guard by regression cycle (the fix date), so a stack that regresses again is never blocked by the previous cycle's claim while the new pending flag is already visible in Elasticsearch. The key is no longer removed after patching. Route every status change through Stack.SetStatus, including the duplicate stack merge in CleanupOrphanedDataJob and FixDuplicateStacks, so merged stacks drop pending notifications too. Add a test that drives AssignToStackAction with a stack another batch claimed after it was loaded, so reverting the tags update to a whole-document save fails. Update tags for several stacks in parallel and document at-most-once delivery when the guard cannot be written. Reword the overflow row of the discarded table so it says what users can actually do. Co-Authored-By: Claude Opus 5.5 --- .../Extensions/StackExtensions.cs | 12 ++-- .../Jobs/CleanupOrphanedDataJob.cs | 3 +- .../Migrations/FixDuplicateStacks.cs | 3 +- .../Pipeline/010_AssignToStackAction.cs | 3 +- .../Repositories/StackRepository.cs | 13 ++-- .../Api/Handlers/StackHandler.cs | 6 +- .../discarded-environments-table.svelte | 3 +- .../environments/environments.svelte.test.ts | 1 + .../Mcp/ExceptionlessMcpTools.cs | 6 +- .../Jobs/CleanupOrphanedDataJobTests.cs | 8 +++ .../FixDuplicateStacksMigrationTests.cs | 4 ++ .../EnvironmentManagementPipelineTests.cs | 59 +++++++++++++++++++ .../Repositories/StackRepositoryTests.cs | 26 +++++++- 13 files changed, 121 insertions(+), 26 deletions(-) diff --git a/src/Exceptionless.Core/Extensions/StackExtensions.cs b/src/Exceptionless.Core/Extensions/StackExtensions.cs index da66f20520..a6a8b31165 100644 --- a/src/Exceptionless.Core/Extensions/StackExtensions.cs +++ b/src/Exceptionless.Core/Extensions/StackExtensions.cs @@ -7,8 +7,7 @@ public static class StackExtensions { public static void MarkFixed(this Stack stack, SemanticVersion? version, TimeProvider timeProvider) { - stack.Status = StackStatus.Fixed; - stack.ClearPendingNotifications(); + stack.SetStatus(StackStatus.Fixed); stack.DateFixed = timeProvider.GetUtcNow().UtcDateTime; stack.FixedInVersion = version?.ToString(); stack.SnoozeUntilUtc = null; @@ -16,18 +15,19 @@ public static void MarkFixed(this Stack stack, SemanticVersion? version, TimePro public static void MarkOpen(this Stack stack) { - stack.Status = StackStatus.Open; - stack.ClearPendingNotifications(); + stack.SetStatus(StackStatus.Open); stack.DateFixed = null; stack.FixedInVersion = null; stack.SnoozeUntilUtc = null; } /// - /// A status change settles any notification that was waiting for a notifying environment. Call this whenever the status is set directly. + /// Changes the status. A status change settles any notification that was waiting for a notifying environment, so every place that + /// changes a stack's status must use this instead of assigning . /// - public static void ClearPendingNotifications(this Stack stack) + public static void SetStatus(this Stack stack, StackStatus status) { + stack.Status = status; stack.IsNewNotificationPending = false; stack.IsRegressionNotificationPending = false; } diff --git a/src/Exceptionless.Core/Jobs/CleanupOrphanedDataJob.cs b/src/Exceptionless.Core/Jobs/CleanupOrphanedDataJob.cs index 37a67ae24a..fd99cb5956 100644 --- a/src/Exceptionless.Core/Jobs/CleanupOrphanedDataJob.cs +++ b/src/Exceptionless.Core/Jobs/CleanupOrphanedDataJob.cs @@ -2,6 +2,7 @@ using Elastic.Clients.Elasticsearch.Aggregations; using Elastic.Clients.Elasticsearch.Core.ReindexRethrottle; using Elastic.Clients.Elasticsearch.QueryDsl; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Configuration; @@ -294,7 +295,7 @@ public async Task FixDuplicateStacks(JobContext context) } targetStack.CreatedUtc = stacks.Documents.Min(d => d.CreatedUtc); - targetStack.Status = stacks.Documents.FirstOrDefault(d => d.Status != StackStatus.Open)?.Status ?? StackStatus.Open; + targetStack.SetStatus(stacks.Documents.FirstOrDefault(d => d.Status != StackStatus.Open)?.Status ?? StackStatus.Open); targetStack.LastOccurrence = stacks.Documents.Max(d => d.LastOccurrence); targetStack.SnoozeUntilUtc = stacks.Documents.Max(d => d.SnoozeUntilUtc); targetStack.DateFixed = stacks.Documents.Max(d => d.DateFixed); ; diff --git a/src/Exceptionless.Core/Migrations/FixDuplicateStacks.cs b/src/Exceptionless.Core/Migrations/FixDuplicateStacks.cs index 101785ec16..654fc802ec 100644 --- a/src/Exceptionless.Core/Migrations/FixDuplicateStacks.cs +++ b/src/Exceptionless.Core/Migrations/FixDuplicateStacks.cs @@ -1,6 +1,7 @@ using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.Core.ReindexRethrottle; using Elastic.Clients.Elasticsearch.QueryDsl; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Configuration; @@ -100,7 +101,7 @@ public override async Task RunAsync(MigrationContext context) } targetStack.CreatedUtc = stacks.Documents.Min(d => d.CreatedUtc); - targetStack.Status = stacks.Documents.FirstOrDefault(d => d.Status != StackStatus.Open)?.Status ?? StackStatus.Open; + targetStack.SetStatus(stacks.Documents.FirstOrDefault(d => d.Status != StackStatus.Open)?.Status ?? StackStatus.Open); targetStack.LastOccurrence = stacks.Documents.Max(d => d.LastOccurrence); targetStack.SnoozeUntilUtc = stacks.Documents.Max(d => d.SnoozeUntilUtc); targetStack.DateFixed = stacks.Documents.Max(d => d.DateFixed); ; diff --git a/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs b/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs index def8756887..ea41e257fe 100644 --- a/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs +++ b/src/Exceptionless.Core/Pipeline/010_AssignToStackAction.cs @@ -170,8 +170,7 @@ await _publisher.PublishAsync(new EntityChanged } // Only the tags changed, so update just those. Saving the whole stack could overwrite a notification another batch just claimed. - foreach (var stack in stacks.Where(s => s.Value.ShouldSave).Select(kvp => kvp.Value.Stack)) - await _stackRepository.UpdateTagsAsync(stack); + await Task.WhenAll(stacks.Where(s => s.Value.ShouldSave).Select(kvp => _stackRepository.UpdateTagsAsync(kvp.Value.Stack))); // Set stack ids after they have been saved and created contexts.ForEach(ctx => diff --git a/src/Exceptionless.Core/Repositories/StackRepository.cs b/src/Exceptionless.Core/Repositories/StackRepository.cs index fd66faf51a..49da8ba705 100644 --- a/src/Exceptionless.Core/Repositories/StackRepository.cs +++ b/src/Exceptionless.Core/Repositories/StackRepository.cs @@ -174,7 +174,8 @@ public async Task TryClaimNotificationAsync(Stack stack, PendingStackNotif // A whole-document save of a stale copy can put the flag back after it was claimed. Remembering the claim lets the next // claim clear the restored flag again without reporting the same notification twice. - return await Cache.AddAsync(GetClaimCacheKey(stack.Id, kind), true, _claimGuardExpiration); + // Delivery is at most once: the flag is already cleared, so if this cache call throws the notification is lost, not repeated. + return await Cache.AddAsync(GetClaimCacheKey(stack, kind), true, _claimGuardExpiration); } catch (DocumentNotFoundException) { @@ -212,6 +213,7 @@ public Task> GetIdsByQueryAsync(RepositoryQueryDescriptor(1) { { "notificationPending", notificationPending } } }); - if (notificationPending) - await Cache.RemoveAsync(GetClaimCacheKey(stack.Id, PendingStackNotification.Regression)); - await InvalidateStackCacheAsync(stack); } catch (DocumentNotFoundException) @@ -274,7 +273,11 @@ protected override async Task InvalidateCacheAsync(IReadOnlyCollection throw new ArgumentOutOfRangeException(nameof(kind), kind, null) }; - private static string GetClaimCacheKey(string stackId, PendingStackNotification kind) => String.Concat("notification-claim:", stackId, ":", GetPendingField(kind)); + // A new notification is pending once per stack. A regression is pending once per fix (a stack regresses again only after it was fixed + // again), so the fix date identifies the cycle and each cycle gets its own key. + private static string GetClaimCacheKey(Stack stack, PendingStackNotification kind) => kind == PendingStackNotification.Regression + ? String.Concat("notification-claim:", stack.Id, ":", GetPendingField(kind), ":", stack.DateFixed?.Ticks ?? 0) + : String.Concat("notification-claim:", stack.Id, ":", GetPendingField(kind)); private static string GetStackSignatureCacheKey(Stack stack) => GetStackSignatureCacheKey(stack.ProjectId, stack.SignatureHash); private static string GetStackSignatureCacheKey(string projectId, string signatureHash) => String.Concat(projectId, ":", signatureHash, ":", STACKING_VERSION); diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs index 1da7cb11ea..aaefc772d9 100644 --- a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -111,8 +111,7 @@ public async Task Handle(SnoozeStacks message) foreach (var stack in stacks) { - stack.Status = StackStatus.Snoozed; - stack.ClearPendingNotifications(); + stack.SetStatus(StackStatus.Snoozed); stack.SnoozeUntilUtc = message.SnoozeUntilUtc; stack.FixedInVersion = null; stack.DateFixed = null; @@ -228,8 +227,7 @@ public async Task Handle(ChangeStacksStatus message) { foreach (var stack in stacks) { - stack.Status = message.Status; - stack.ClearPendingNotifications(); + stack.SetStatus(message.Status); if (message.Status == StackStatus.Fixed) { stack.DateFixed = timeProvider.GetUtcNow().UtcDateTime; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte index 766a5be230..332e912022 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte @@ -89,7 +89,8 @@ {otherEvents.toLocaleString()} events from {otherNamesCapped ? 'at least ' : ''}{otherNames.toLocaleString()} other names were discarded. - Only the first {named.length.toLocaleString()} names are listed. Fix the clients that send these names, or list the ones you want. + Only the first {named.length.toLocaleString()} names seen are listed, so the others cannot be allowed here. Check the environment your + clients send, add the names you expect to the list above, or turn off discarding of unlisted environments. {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts index 0b9430d2a1..00165f8461 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts @@ -84,6 +84,7 @@ describe('DiscardedEnvironmentsTable', () => { // Assert expect(screen.getByText(/1,200 events from at least 100 other names were discarded/)).toBeTruthy(); + expect(screen.getByText(/cannot be allowed here\. Check the environment your clients send, add the names you expect to the list above/)).toBeTruthy(); }); it('explains when nothing has been discarded', () => { diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 6a230fd7e1..01f548d5c8 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -776,8 +776,7 @@ public async Task> UpdateStackStatusAsync( } else if (changed) { - stack.Status = stackStatus; - stack.ClearPendingNotifications(); + stack.SetStatus(stackStatus); stack.DateFixed = null; stack.FixedInVersion = null; stack.SnoozeUntilUtc = null; @@ -832,8 +831,7 @@ public async Task> SnoozeStackAsync( || stack.DateFixed is not null; if (changed) { - stack.Status = StackStatus.Snoozed; - stack.ClearPendingNotifications(); + stack.SetStatus(StackStatus.Snoozed); stack.SnoozeUntilUtc = untilUtc; stack.FixedInVersion = null; stack.DateFixed = null; diff --git a/tests/Exceptionless.Tests/Jobs/CleanupOrphanedDataJobTests.cs b/tests/Exceptionless.Tests/Jobs/CleanupOrphanedDataJobTests.cs index c277b091e8..9a84506ec2 100644 --- a/tests/Exceptionless.Tests/Jobs/CleanupOrphanedDataJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/CleanupOrphanedDataJobTests.cs @@ -408,6 +408,9 @@ public async Task FixDuplicateStacks_WithDuplicatesAcrossTenants_MergesCorrectly var stack2 = _stackData.GenerateStack(generateId: true, organizationId: organization.Id, projectId: project.Id, signatureHash: signatureHash); stack2.CreatedUtc = DateTime.UtcNow.AddDays(-5); stack2.TotalOccurrences = 10; + stack1.Status = StackStatus.Fixed; + stack2.IsNewNotificationPending = true; + stack2.IsRegressionNotificationPending = true; await _stackRepository.AddAsync([stack1, stack2], o => o.ImmediateConsistency()); // Events on both stacks @@ -426,6 +429,11 @@ public async Task FixDuplicateStacks_WithDuplicatesAcrossTenants_MergesCorrectly Assert.Single(activeStacks); Assert.Single(deletedStacks); + // Merging changes the status, which drops notifications that were waiting for a notifying environment + Assert.Equal(StackStatus.Fixed, activeStacks[0].Status); + Assert.False(activeStacks[0].IsNewNotificationPending); + Assert.False(activeStacks[0].IsRegressionNotificationPending); + // All events should now reference the surviving stack var allEvents = await _eventRepository.GetAllAsync(); Assert.Equal(10, allEvents.Total); diff --git a/tests/Exceptionless.Tests/Migrations/FixDuplicateStacksMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/FixDuplicateStacksMigrationTests.cs index 4479cec683..8049e465c5 100644 --- a/tests/Exceptionless.Tests/Migrations/FixDuplicateStacksMigrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/FixDuplicateStacksMigrationTests.cs @@ -40,6 +40,8 @@ public async Task WillMergeDuplicatedStacks() var originalStack = _stackData.GenerateStack(); originalStack.Id = ObjectId.GenerateNewId().ToString(); originalStack.TotalOccurrences = 100; + originalStack.IsNewNotificationPending = true; + originalStack.IsRegressionNotificationPending = true; var duplicateStack = originalStack.DeepClone(); duplicateStack.Id = ObjectId.GenerateNewId().ToString(); duplicateStack.Status = StackStatus.Fixed; @@ -79,6 +81,8 @@ public async Task WillMergeDuplicatedStacks() Assert.Equal(originalStack.CreatedUtc, updatedOriginalStack.CreatedUtc); Assert.Equal(110, updatedOriginalStack.TotalOccurrences); Assert.Equal(StackStatus.Fixed, updatedOriginalStack.Status); + Assert.False(updatedOriginalStack.IsNewNotificationPending); + Assert.False(updatedOriginalStack.IsRegressionNotificationPending); Assert.Equal(duplicateStack.LastOccurrence, updatedOriginalStack.LastOccurrence); Assert.Null(updatedOriginalStack.SnoozeUntilUtc); Assert.Equal(duplicateStack.DateFixed, updatedOriginalStack.DateFixed); diff --git a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs index 678b6dabf3..afa9270545 100644 --- a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs @@ -11,6 +11,8 @@ using Foundatio.Queues; using Foundatio.Repositories; using Foundatio.Repositories.Models; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; using Xunit; using DataDictionary = Exceptionless.Core.Models.DataDictionary; @@ -482,6 +484,63 @@ public async Task RunAsync_WhenTwoBatchesTouchAPendingStack_TagsUpdateDoesNotRes Assert.Contains("late", stack.Tags); } + [Fact] + public async Task ProcessBatchAsync_WhenAnotherBatchClaimsAfterTheStackWasLoaded_AddingTagsDoesNotRestoreTheClaimedFlag() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var dev = await RunAsync(project, "dev"); + Assert.True(dev.Stack!.IsNewNotificationPending); + + // The batch loads the pending stack, then another batch claims the notification before this one saves its tags. + var repository = DispatchProxy.Create(); + var proxy = (AfterStackLoadedProxy)(object)repository; + proxy.Inner = _stackRepository; + proxy.AfterLoaded = loaded => _stackRepository.TryClaimNotificationAsync(loaded, PendingStackNotification.New); + var action = ActivatorUtilities.CreateInstance(GetService(), repository); + + var ev = NewEvent("dev", "boom"); + ev.Tags = ["late"]; + var context = new EventContext(ev, await GetOrganizationAsync(), project); + foreach (var item in dev.Stack.SignatureInfo) + context.StackSignatureData[item.Key] = item.Value; + + // Act + await action.ProcessBatchAsync([context]); + await RefreshDataAsync(); + + // Assert + Assert.Equal(dev.Stack.Id, context.Stack!.Id); + var stack = (await _stackRepository.GetByIdAsync(dev.Stack.Id))!; + Assert.Contains("late", stack.Tags); + Assert.False(stack.IsNewNotificationPending); + } + + private class AfterStackLoadedProxy : DispatchProxy + { + public IStackRepository Inner { get; set; } = null!; + public Func> AfterLoaded { get; set; } = null!; + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + object? result = targetMethod!.Invoke(Inner, args); + return targetMethod.Name == nameof(IStackRepository.GetStackBySignatureHashAsync) ? LoadAsync((Task)result!) : result; + } + + private async Task LoadAsync(Task load) + { + var stack = await load; + if (stack is not null) + await AfterLoaded(stack); + + return stack; + } + } + private async Task MarkFixedAsync(string stackId) { var stack = (await _stackRepository.GetByIdAsync(stackId))!; diff --git a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs index 0101f78614..cc954e00d4 100644 --- a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs @@ -141,14 +141,17 @@ public async Task TryClaimNotificationAsync_WhenAStaleSaveRestoresTheFlag_Clears } [Fact] - public async Task MarkAsRegressedAsync_WithPendingNotification_AllowsANewClaimEvenAfterAnEarlierOne() + public async Task TryClaimNotificationAsync_ForALaterRegressionCycle_ClaimsEvenThoughAnEarlierCycleWasClaimed() { // Arrange var stack = await AddPendingStackAsync(); + stack.DateFixed = DateTime.UtcNow.AddDays(-2); await _repository.MarkAsRegressedAsync(stack, notificationPending: true); bool first = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.Regression); - // Act + // Act: the stack is fixed again and regresses again. The new cycle is pending in Elasticsearch before anything else happens, + // so a claim at that moment must not be blocked by the previous cycle's guard. + stack.DateFixed = DateTime.UtcNow.AddDays(-1); await _repository.MarkAsRegressedAsync(stack, notificationPending: true); bool second = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.Regression); @@ -157,6 +160,25 @@ public async Task MarkAsRegressedAsync_WithPendingNotification_AllowsANewClaimEv Assert.True(second); } + [Fact] + public async Task TryClaimNotificationAsync_WhenAStaleSaveRestoresARegressionFlag_DoesNotClaimTheSameCycleTwice() + { + // Arrange + var stack = await AddPendingStackAsync(); + stack.DateFixed = DateTime.UtcNow.AddDays(-1); + await _repository.MarkAsRegressedAsync(stack, notificationPending: true); + var stale = (await _repository.GetByIdAsync(stack.Id))!; + bool claimed = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.Regression); + + // Act + await _repository.SaveAsync(stale, o => o.ImmediateConsistency()); + bool claimedAgain = await _repository.TryClaimNotificationAsync(stack, PendingStackNotification.Regression); + + // Assert + Assert.True(claimed); + Assert.False(claimedAgain); + } + [Fact] public async Task MarkAsRegressedAsync_WithStaleStack_ChangesOnlyTheStatusAndNotificationFlags() { From d2fece8372fa9a573244d1970bb2dcf4b62e3ced Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 16:47:04 -0600 Subject: [PATCH 05/10] Mark every copy of a regressed stack in a batch Events with an explicit stack id each load their own copy of the stack, so a regression first seen in a non-notifying environment left later production events in the same batch without the pending regression state. Co-Authored-By: Claude Opus 5.5 --- .../Pipeline/030_CheckForRegressionAction.cs | 9 ++++-- .../EnvironmentManagementPipelineTests.cs | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs b/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs index e97e11f1e7..bb7d4ecc2d 100644 --- a/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs +++ b/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs @@ -76,13 +76,16 @@ public override async Task ProcessBatchAsync(ICollection contexts) _logger.LogDebug("Marking stack and events as regressed in version: {Version}", regressedVersion); // A regression seen only in an environment that does not notify is reported when a notifying environment first sees it. bool notificationPending = !regressedContext.Project.ShouldNotifyForEnvironment(regressedContext.Event.Environment); - stack.Status = StackStatus.Regressed; - stack.IsNewNotificationPending = false; - stack.IsRegressionNotificationPending = notificationPending; await _stackRepository.MarkAsRegressedAsync(stack, notificationPending); + // Events with an explicit stack id can each carry their own copy of the stack, so update every copy in the group. foreach (var ctx in stackGroup) + { + ctx.Stack!.Status = StackStatus.Regressed; + ctx.Stack.IsNewNotificationPending = false; + ctx.Stack.IsRegressionNotificationPending = notificationPending; ctx.IsRegression = ctx == regressedContext; + } } catch (Exception ex) { diff --git a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs index afa9270545..84945ce40a 100644 --- a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs @@ -409,6 +409,37 @@ public async Task RunAsync_WhenAStackRegressesInANonNotifyingEnvironment_Notifie Assert.False((await _stackRepository.GetByIdAsync(first.Event.StackId))!.IsRegressionNotificationPending); } + [Fact] + public async Task RunAsync_WhenABatchWithAnExplicitStackIdRegressesInDevThenProduction_NotifiesOnceForProduction() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var first = await RunAsync(project, "production"); + await DequeueNotificationsAsync(); + await MarkFixedAsync(first.Event.StackId); + var dev = NewEvent("dev", "test"); + dev.StackId = first.Event.StackId; + var production = NewEvent("production", "test"); + production.StackId = first.Event.StackId; + production.Date = dev.Date.AddSeconds(1); + + // Act + var contexts = await _pipeline.RunAsync([dev, production], await GetOrganizationAsync(), project); + await RefreshDataAsync(); + var notifications = await DequeueNotificationsAsync(); + + // Assert + Assert.NotSame(contexts.First().Stack, contexts.Last().Stack); + var notification = Assert.Single(notifications); + Assert.Equal(production.Id, notification.EventId); + Assert.True(notification.IsRegression); + Assert.False((await _stackRepository.GetByIdAsync(first.Event.StackId))!.IsRegressionNotificationPending); + } + [Fact] public async Task RunAsync_WhenAStackRegressesInANotifyingEnvironment_NotifiesImmediatelyAndNeverMarksItPending() { From 90edbc04aaaac06d2d7e84be1b33c28973f26a59 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 16:52:45 -0600 Subject: [PATCH 06/10] Share the regression claim between immediate and deferred notifications An immediate regression now reserves the same per-cycle key a pending claim takes, so a concurrent batch that marks the regression pending from a non-notifying environment can't send it again. Co-Authored-By: Claude Opus 5.5 --- .../Pipeline/070_QueueNotificationAction.cs | 6 +++- .../Interfaces/IStackRepository.cs | 6 ++++ .../Repositories/StackRepository.cs | 6 ++++ .../EnvironmentManagementPipelineTests.cs | 30 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs index 4c8e2c3095..fd335416a4 100644 --- a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs +++ b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs @@ -42,7 +42,11 @@ public override async Task ProcessAsync(EventContext ctx) return; bool isNew = await IsNewForNotificationAsync(ctx); - bool isRegression = ctx.IsRegression || await ClaimPendingAsync(ctx, PendingStackNotification.Regression); + // An immediate regression reserves the same per-cycle key a pending claim uses, so a concurrent batch that marks the regression + // pending from a non-notifying environment can't report it a second time. + bool isRegression = ctx.IsRegression + ? await _stackRepository.TryReserveNotificationAsync(ctx.Stack, PendingStackNotification.Regression) + : await ClaimPendingAsync(ctx, PendingStackNotification.Regression); if (ShouldQueueNotification(ctx, isNew, isRegression)) await _notificationQueue.EnqueueAsync(new EventNotification { diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs index 08d8b5b246..6eb17c19f0 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs @@ -20,6 +20,12 @@ public interface IStackRepository : IRepositoryOwnedByOrganizationAndProject Task TryClaimNotificationAsync(Stack stack, PendingStackNotification kind); + /// + /// Records that a notification is being sent right away instead of through a pending claim. Returns false if the notification + /// was already sent for the stack's current cycle. + /// + Task TryReserveNotificationAsync(Stack stack, PendingStackNotification kind); + /// /// Updates only the tags of the stack. /// diff --git a/src/Exceptionless.Core/Repositories/StackRepository.cs b/src/Exceptionless.Core/Repositories/StackRepository.cs index 49da8ba705..bfbca94e68 100644 --- a/src/Exceptionless.Core/Repositories/StackRepository.cs +++ b/src/Exceptionless.Core/Repositories/StackRepository.cs @@ -183,6 +183,12 @@ public async Task TryClaimNotificationAsync(Stack stack, PendingStackNotif } } + public Task TryReserveNotificationAsync(Stack stack, PendingStackNotification kind) + { + // Shares the claim key, so a pending flag restored by a concurrent batch can't send the same notification again. + return Cache.AddAsync(GetClaimCacheKey(stack, kind), true, _claimGuardExpiration); + } + public async Task UpdateTagsAsync(Stack stack) { // Only the tags change, so patch them instead of saving the whole document. A stale copy must not overwrite fields that other diff --git a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs index 84945ce40a..cb213c2f28 100644 --- a/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs @@ -440,6 +440,36 @@ public async Task RunAsync_WhenABatchWithAnExplicitStackIdRegressesInDevThenProd Assert.False((await _stackRepository.GetByIdAsync(first.Event.StackId))!.IsRegressionNotificationPending); } + [Fact] + public async Task RunAsync_WhenAConcurrentDevBatchMarksAnImmediateRegressionPending_DoesNotNotifyTwice() + { + // Arrange + var project = await ConfigureProjectAsync(p => p.Environments = + [ + new ProjectEnvironment { Name = "dev", Notify = false }, + new ProjectEnvironment { Name = "production" } + ]); + var first = await RunAsync(project, "production"); + await DequeueNotificationsAsync(); + await MarkFixedAsync(first.Event.StackId); + var staleFixedStack = (await _stackRepository.GetByIdAsync(first.Event.StackId))!; + + // Act + var regression = await RunAsync(project, "production"); + var regressionNotifications = await DequeueNotificationsAsync(); + // A dev batch that loaded the fixed stack before the production regression finishes after it. + await _stackRepository.MarkAsRegressedAsync(staleFixedStack, notificationPending: true); + await RefreshDataAsync(); + await RunAsync(project, "production"); + var laterNotifications = await DequeueNotificationsAsync(); + + // Assert + Assert.True(regression.IsRegression); + Assert.True(Assert.Single(regressionNotifications).IsRegression); + Assert.Empty(laterNotifications); + Assert.False((await _stackRepository.GetByIdAsync(first.Event.StackId))!.IsRegressionNotificationPending); + } + [Fact] public async Task RunAsync_WhenAStackRegressesInANotifyingEnvironment_NotifiesImmediatelyAndNeverMarksItPending() { From db5e71b3387f72998e70db0acdd2a7368ecd6478 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 17:25:10 -0600 Subject: [PATCH 07/10] Simplify the environments page Put the environment table first, add environments through a dialog like the other project settings pages, move the other-environments notification toggle into the table, replace the discard switches with an "Accept events from" choice, and only show discarded names when they matter. Co-Authored-By: Claude Opus 5.5 --- docs/docs/environments.md | 14 +- .../e2e/tests/project-environments.e2e.ts | 49 +++--- .../add-environment-dialog.svelte | 137 ++++++++++++++++ .../environments/add-environment-form.svelte | 148 ------------------ .../discarded-environments-table.svelte | 18 +-- .../environments/environment-settings.svelte | 93 +++++------ .../environments/environments-table.svelte | 93 +++++++---- .../environments/environments.svelte.test.ts | 137 ++++++++++------ .../[projectId]/environments/+page.svelte | 140 +++++++++-------- .../environments-page.svelte.test.ts | 35 ++++- 10 files changed, 475 insertions(+), 389 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte diff --git a/docs/docs/environments.md b/docs/docs/environments.md index 13c71ea72d..3607405a87 100644 --- a/docs/docs/environments.md +++ b/docs/docs/environments.md @@ -64,21 +64,21 @@ The **New** view is not environment-aware. A stack is dated from its first event ## Manage environments -Open **Project Settings → Environments** to list the environments a project uses. Listed names are shown in the casing you type (for example `Production`) in the Environment filter, event details, and notification emails, while matching stays case-insensitive. Adding or removing a name never changes or deletes events, and the server never adds names to the list on its own: environments that appear in your data show up in the table with an **Add** action. A project can list up to 100 environments, and a name follows the same rules as an event's environment: 1 to 64 characters after trimming, with no control characters. +Open **Project Settings → Environments** to list the environments a project uses. Listed names are shown in the casing you type (for example `Production`) in the Environment filter, event details, and notification emails, while matching stays case-insensitive. Adding or removing a name never changes or deletes events, and the server never adds names to the list on its own: environments that appear in your data show up in the table marked **Not added**, with an **Add** action. A project can list up to 100 environments, and a name follows the same rules as an event's environment: 1 to 64 characters after trimming, with no control characters. -Three project settings control what happens to events from environments that are not listed. Projects with no list and default settings behave exactly as before. +These settings control what happens to events from environments that are not listed. Projects with no list and default settings behave exactly as before. | Setting | Default | Meaning | | --- | --- | --- | -| Discard events from environments that are not listed | Off | Events whose environment is not in the list are discarded before they are stored or stacked. They count as discarded events in your usage and do not count toward your plan limit, like events for a discarded stack. | -| Also discard events that have no environment | Off | Only applies when the setting above is on. Events without an environment are discarded too. Session heartbeats and session ends, which never carry an environment, are never discarded for this reason. | -| Send notifications for other environments | On | Applies to environments that are not listed and to events without an environment. Each listed environment has its own **Notifications** toggle. | +| **Accept events from:** Only environments in this list | Off (Any environment) | Events whose environment is not in the list are discarded before they are stored or stacked. They count as discarded events in your usage and do not count toward your plan limit, like events for a discarded stack. | +| **Also accept events that don't send an environment** | On | Only shown when the setting above is on. When unchecked, events without an environment are discarded too. Session heartbeats and session ends, which never carry an environment, are never discarded for this reason. | +| **Other environments** notifications | On | The last row of the table. Applies to environments that are not listed and to events without an environment. Each listed environment has its own **Notifications** toggle. | -Ingestion acknowledges events before they are processed, so a client cannot see that an event was discarded. The **Discarded Events** table shows the names that were rejected in the last 7 days (up to 50 names per project), how many events each had, and an **Allow** action that adds the name to the list. This is the quickest way to find a misspelled environment such as `prodution`. Events without an environment are shown as their own row. +Ingestion acknowledges events before they are processed, so a client cannot see that an event was discarded. The **Discarded** section, shown while only listed environments are accepted or when anything was discarded, shows the names that were rejected in the last 7 days (up to 50 names per project), how many events each had, and an **Allow** action that adds the name to the list. This is the quickest way to find a misspelled environment such as `prodution`. Events without an environment are shown as their own row. ### Notifications by environment -An event can send notifications (email, Slack, and web hooks) only when its environment notifies: a listed environment with notifications on, or an unlisted environment or no environment while **Send notifications for other environments** is on. +An event can send notifications (email, Slack, and web hooks) only when its environment notifies: a listed environment with notifications on, or an unlisted environment or no environment while **Other environments** notifications are on. - Critical error and critical event notifications are sent only for events from a notifying environment. - A new error or new event notification is sent the first time a stack occurs in a notifying environment. If an error is first seen in `dev` and `dev` does not notify, nothing is sent; the first time the same error occurs in `Production`, you get the new error notification, and it is not reported as new again in other environments. diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts index 050e94ac6c..96e5be9d95 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts @@ -20,13 +20,13 @@ test('operator can list, configure, and filter environments and allow discarded await test.step('observed environments appear as unlisted rows', async () => { await page.goto(`/project/${projectId}/environments`); - await expect(page.getByRole('heading', { name: 'Environments in Use' })).toBeVisible(); - await expect(page.getByRole('row', { name: /^production Not listed/ })).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole('row', { name: /^staging Not listed/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Add Environment' })).toBeVisible(); + await expect(page.getByRole('row', { name: /^production Not added/ })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('row', { name: /^staging Not added/ })).toBeVisible(); }); await test.step('discarding unlisted environments asks for confirmation while nothing is listed', async () => { - const discard = page.getByRole('switch', { name: 'Discard events from environments that are not listed' }); + const discard = page.getByRole('radio', { name: 'Only environments in this list' }); await discard.click(); const dialog = page.getByRole('alertdialog'); @@ -34,29 +34,36 @@ test('operator can list, configure, and filter environments and allow discarded await dialog.getByRole('button', { name: 'Cancel' }).click(); await expect(dialog).toBeHidden(); await expect(discard).not.toBeChecked(); + await expect(page.getByRole('radio', { name: 'Any environment' })).toBeChecked(); }); await test.step('adding a name lists it in the casing that was typed and merges the observed row', async () => { - await page.getByLabel('Environment name').fill(' Production '); - await page.getByRole('button', { name: 'Add environment' }).click(); + const dialog = page.getByRole('alertdialog'); + await page.getByRole('button', { name: 'Add Environment' }).click(); + await dialog.getByLabel('Name').fill(' Production '); + await dialog.getByRole('button', { name: 'Add Environment' }).click(); await expect(page.getByText('Added Production.')).toBeVisible(); - await expect(page.getByRole('row', { name: /^Production Listed/ })).toBeVisible(); - await expect(page.getByRole('row', { name: /^production Not listed/ })).toBeHidden(); + await expect(dialog).toBeHidden(); + await expect(page.getByRole('switch', { name: 'Send notifications for Production' })).toBeVisible(); + await expect(page.getByRole('row', { name: /^production Not added/ })).toBeHidden(); - await page.getByLabel('Environment name').fill('PRODUCTION'); - await page.getByRole('button', { name: 'Add environment' }).click(); - await expect(page.getByText('PRODUCTION is already listed. Names are not case-sensitive.')).toBeVisible(); + await page.getByRole('button', { name: 'Add Environment' }).click(); + await dialog.getByLabel('Name').fill('PRODUCTION'); + await dialog.getByRole('button', { name: 'Add Environment' }).click(); + await expect(dialog.getByText('PRODUCTION is already in the list.')).toBeVisible(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); }); - await test.step('adding an observed name pre-fills the form so the casing can be corrected', async () => { + await test.step('adding an observed name pre-fills the dialog so the casing can be corrected', async () => { + const dialog = page.getByRole('alertdialog'); await page.getByRole('button', { name: 'Add staging' }).click(); - await expect(page.getByLabel('Environment name')).toHaveValue('staging'); - await expect(page.getByRole('row', { name: /^staging Not listed/ })).toBeVisible(); + await expect(dialog.getByLabel('Name')).toHaveValue('staging'); - await page.getByLabel('Environment name').fill('Staging'); - await page.getByRole('button', { name: 'Add environment' }).click(); - await expect(page.getByRole('row', { name: /^Staging Listed/ })).toBeVisible(); + await dialog.getByLabel('Name').fill('Staging'); + await dialog.getByRole('button', { name: 'Add Environment' }).click(); + await expect(page.getByRole('switch', { name: 'Send notifications for Staging' })).toBeVisible(); + await expect(page.getByRole('row', { name: /^staging Not added/ })).toBeHidden(); }); await test.step('notifications can be turned off for a listed environment and persist', async () => { @@ -85,11 +92,11 @@ test('operator can list, configure, and filter environments and allow discarded await test.step('discarding unlisted environments reports the names that were rejected', async () => { await page.goto(`/project/${projectId}/environments`); - const discard = page.getByRole('switch', { name: 'Discard events from environments that are not listed' }); + const discard = page.getByRole('radio', { name: 'Only environments in this list' }); const settingsSaved = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().endsWith('/environments/settings')); await discard.click(); expect((await settingsSaved).ok()).toBe(true); - await expect(page.getByRole('switch', { name: 'Also discard events that have no environment' })).toBeEnabled(); + await expect(page.getByRole('checkbox', { name: "Also accept events that don't send an environment" })).toBeChecked(); const referenceId = createReferenceId(run, '-managed-env-typo'); await e2eApi.submitEvent(projectId, projectToken, { @@ -110,7 +117,7 @@ test('operator can list, configure, and filter environments and allow discarded await page.getByRole('button', { name: 'Allow Prodution' }).click(); await expect(page.getByText('Added Prodution.')).toBeVisible(); - await expect(page.getByRole('row', { name: /^Prodution Listed/ })).toBeVisible(); + await expect(page.getByRole('switch', { name: 'Send notifications for Prodution' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Allow Prodution' })).toBeHidden(); }); @@ -119,6 +126,6 @@ test('operator can list, configure, and filter environments and allow discarded await page.getByRole('button', { name: 'Remove Environment' }).click(); await expect(page.getByText('Removed Prodution. Its events were not changed.')).toBeVisible(); - await expect(page.getByRole('row', { name: /^Prodution Listed/ })).toBeHidden(); + await expect(page.getByRole('switch', { name: 'Send notifications for Prodution' })).toBeHidden(); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte new file mode 100644 index 0000000000..b466e6cdab --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte @@ -0,0 +1,137 @@ + + + + + { + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + Add Environment + Shown exactly as you type it. Events match it regardless of case. + + + state.errors}> + {#snippet children(errors)} + + {/snippet} + + +
+ + {#snippet children(field)} + + Name + field.handleChange(event.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + +
+ + + Cancel + Add Environment + + +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte deleted file mode 100644 index b960b26457..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-form.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - -
{ - event.preventDefault(); - event.stopPropagation(); - form.handleSubmit(); - }} -> - state.errors}> - {#snippet children(errors)} -
- {/snippet} -
- - - {#snippet children(field)} - - Environment name - field.handleChange(event.currentTarget.value)} - aria-invalid={ariaInvalid(field)} - /> - - - {/snippet} - - - - {#snippet children(field)} - - field.handleChange(checked)} /> - Send notifications - - {/snippet} - - - state.isSubmitting}> - {#snippet children(isSubmitting)} - - {/snippet} - -
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte index 332e912022..f78333b459 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte @@ -48,9 +48,9 @@ Environment - Events discarded ({retentionDays} days) - Last seen - Actions + Events + Last seen + Actions @@ -62,8 +62,7 @@ > {:else if !hasRows} No events have been discarded because of their environment in the last {retentionDays} days.Nothing discarded in the last {retentionDays} days. {:else} @@ -71,7 +70,7 @@ {environment.name ?? environment.key} {environment.count.toLocaleString()} - + {:else} @@ -93,6 +109,21 @@ {/each} + + +
Other environments
+
Not in this list, or none sent
+
+ + toggleNotifyOther(checked)} + /> + + +
{/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts index 00165f8461..61c58177f7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts @@ -2,7 +2,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import AddEnvironmentForm from './add-environment-form.svelte'; +import AddEnvironmentDialog from './add-environment-dialog.svelte'; import DiscardedEnvironmentsTable from './discarded-environments-table.svelte'; import EnvironmentSettings from './environment-settings.svelte'; import EnvironmentsTable from './environments-table.svelte'; @@ -16,11 +16,21 @@ describe('EnvironmentsTable', () => { { count: 3, isListed: false, key: 'prodution', lastSeen: '2026-11-02T09:00:00Z', name: 'prodution' } ]; + const tableProps = { + add: vi.fn(), + discardUnlisted: false, + notifyOtherEnvironments: true, + remove: vi.fn(), + rows, + toggleNotify: vi.fn(), + toggleNotifyOther: vi.fn() + }; + it('shows listed names in their stored casing and offers Add only for unlisted names', async () => { // Arrange const add = vi.fn(); const remove = vi.fn(); - render(EnvironmentsTable, { add, notifyOtherEnvironments: false, remove, rows, toggleNotify: vi.fn() }); + render(EnvironmentsTable, { ...tableProps, add, remove }); // Act await userEvent.click(screen.getByRole('button', { name: 'Add prodution' })); @@ -30,7 +40,8 @@ describe('EnvironmentsTable', () => { expect(screen.getByText('Production')).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Add Production' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Remove prodution' })).toBeNull(); - expect(screen.getByText('Off (other environments)')).toBeTruthy(); + expect(screen.getByText('Not added')).toBeTruthy(); + expect(screen.getByText('Default')).toBeTruthy(); expect(add).toHaveBeenCalledWith(rows[2]); expect(remove).toHaveBeenCalledWith(rows[0]); }); @@ -38,7 +49,7 @@ describe('EnvironmentsTable', () => { it('toggles notifications for a listed environment', async () => { // Arrange const toggleNotify = vi.fn(); - render(EnvironmentsTable, { add: vi.fn(), notifyOtherEnvironments: true, remove: vi.fn(), rows, toggleNotify }); + render(EnvironmentsTable, { ...tableProps, toggleNotify }); // Act await userEvent.click(screen.getByRole('switch', { name: 'Send notifications for dev' })); @@ -46,6 +57,27 @@ describe('EnvironmentsTable', () => { // Assert expect(toggleNotify).toHaveBeenCalledWith(rows[1], true); }); + + it('toggles notifications for environments that are not in the list', async () => { + // Arrange + const toggleNotifyOther = vi.fn(); + render(EnvironmentsTable, { ...tableProps, toggleNotifyOther }); + + // Act + await userEvent.click(screen.getByRole('switch', { name: 'Send notifications for other environments' })); + + // Assert + expect(toggleNotifyOther).toHaveBeenCalledWith(false); + }); + + it('marks unlisted environments as discarded while only listed environments are accepted', () => { + // Act + render(EnvironmentsTable, { ...tableProps, discardUnlisted: true }); + + // Assert + expect(screen.getByText('Discarded')).toBeTruthy(); + expect(screen.queryByText('Not added')).toBeNull(); + }); }); describe('DiscardedEnvironmentsTable', () => { @@ -65,7 +97,6 @@ describe('DiscardedEnvironmentsTable', () => { // Assert expect(screen.getByText((3412).toLocaleString())).toBeTruthy(); - expect(screen.getByText('Events discarded (7 days)')).toBeTruthy(); expect(allow).toHaveBeenCalledWith(named[0]); expect(stopDiscardingUnspecified).toHaveBeenCalledOnce(); }); @@ -83,8 +114,9 @@ describe('DiscardedEnvironmentsTable', () => { }); // Assert - expect(screen.getByText(/1,200 events from at least 100 other names were discarded/)).toBeTruthy(); - expect(screen.getByText(/cannot be allowed here\. Check the environment your clients send, add the names you expect to the list above/)).toBeTruthy(); + expect( + screen.getByText(/Plus 1,200 events from at least 100 more names, too many to list here\. Check the environment your clients send\./) + ).toBeTruthy(); }); it('explains when nothing has been discarded', () => { @@ -92,82 +124,94 @@ describe('DiscardedEnvironmentsTable', () => { render(DiscardedEnvironmentsTable, { allow: vi.fn(), named: [], retentionDays: 7, stopDiscardingUnspecified: vi.fn() }); // Assert - expect(screen.getByText(/No events have been discarded because of their environment in the last 7 days/)).toBeTruthy(); + expect(screen.getByText('Nothing discarded in the last 7 days.')).toBeTruthy(); }); }); describe('EnvironmentSettings', () => { afterEach(() => cleanup()); - it('only allows discarding events without an environment while unlisted environments are discarded', async () => { + it('switches between accepting any environment and only listed ones', async () => { // Arrange const update = vi.fn(); - render(EnvironmentSettings, { - discardEventsWithoutEnvironment: false, - discardUnlistedEnvironments: false, - notifyOtherEnvironments: true, - update - }); - const withoutEnvironment = screen.getByRole('switch', { name: 'Also discard events that have no environment' }); + render(EnvironmentSettings, { discardEventsWithoutEnvironment: false, discardUnlistedEnvironments: false, update }); // Act - await userEvent.click(withoutEnvironment); - await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); - await userEvent.click(screen.getByRole('switch', { name: 'Send notifications for other environments' })); + await userEvent.click(screen.getByRole('radio', { name: 'Only environments in this list' })); // Assert - expect(withoutEnvironment.hasAttribute('disabled')).toBe(true); - expect(update).toHaveBeenNthCalledWith(1, { discard_unlisted_environments: true }); - expect(update).toHaveBeenNthCalledWith(2, { notify_other_environments: false }); - expect(update).toHaveBeenCalledTimes(2); + expect(update).toHaveBeenCalledWith({ discard_unlisted_environments: true }); + expect(screen.queryByRole('checkbox', { name: "Also accept events that don't send an environment" })).toBeNull(); + }); + + it('offers to accept events without an environment only while unlisted environments are discarded', async () => { + // Arrange + const update = vi.fn(); + render(EnvironmentSettings, { discardEventsWithoutEnvironment: false, discardUnlistedEnvironments: true, update }); + const acceptWithout = screen.getByRole('checkbox', { name: "Also accept events that don't send an environment" }); + const initiallyChecked = acceptWithout.getAttribute('aria-checked'); + + // Act + await userEvent.click(acceptWithout); + + // Assert + expect(initiallyChecked).toBe('true'); + expect(update).toHaveBeenCalledWith({ discard_events_without_environment: true }); }); }); -describe('AddEnvironmentForm', () => { +describe('AddEnvironmentDialog', () => { afterEach(() => cleanup()); - it('adds the trimmed name with the chosen notification setting', async () => { + async function submit(name?: string) { + const input = screen.getByLabelText('Name') as HTMLInputElement; + if (name !== undefined) { + await userEvent.clear(input); + if (name) { + await userEvent.type(input, name); + } + } + + await userEvent.click(screen.getByRole('button', { name: 'Add Environment' })); + } + + it('adds the trimmed name', async () => { // Arrange const add = vi.fn().mockResolvedValue(undefined); - render(AddEnvironmentForm, { add, listedNames: ['Production'] }); + render(AddEnvironmentDialog, { add, listedNames: ['Production'], open: true }); // Act - await userEvent.type(screen.getByLabelText('Environment name'), ' Staging West '); - await userEvent.click(screen.getByRole('switch', { name: 'Send notifications' })); - await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + await submit(' Staging West '); // Assert - await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'Staging West', notify: false })); + await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'Staging West', notify: true })); }); it.each([ - ['production', 'production is already listed'], - [' PRODUCTION ', 'PRODUCTION is already listed'] + ['production', 'production is already in the list'], + [' PRODUCTION ', 'PRODUCTION is already in the list'] ])('rejects %j because names are not case-sensitive', async (name, message) => { // Arrange const add = vi.fn(); - render(AddEnvironmentForm, { add, listedNames: ['Production'] }); + render(AddEnvironmentDialog, { add, listedNames: ['Production'], open: true }); // Act - await userEvent.type(screen.getByLabelText('Environment name'), name); - await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + await submit(name); // Assert expect(await screen.findByText(new RegExp(message))).toBeTruthy(); expect(add).not.toHaveBeenCalled(); }); - it('fills in the name it is given so the casing can be confirmed before adding', async () => { + it('fills in the name it is given so the casing can be corrected before adding', async () => { // Arrange const add = vi.fn().mockResolvedValue(undefined); - render(AddEnvironmentForm, { add, listedNames: [], prefill: { name: 'qa-west' } }); - const input = screen.getByLabelText('Environment name') as HTMLInputElement; + render(AddEnvironmentDialog, { add, listedNames: [], name: 'qa-west', open: true }); + const input = screen.getByLabelText('Name') as HTMLInputElement; await waitFor(() => expect(input.value).toBe('qa-west')); // Act - await userEvent.clear(input); - await userEvent.type(input, 'QA-West'); - await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + await submit('QA-West'); // Assert await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'QA-West', notify: true })); @@ -176,24 +220,23 @@ describe('AddEnvironmentForm', () => { it('rejects a name that is only whitespace', async () => { // Arrange const add = vi.fn(); - render(AddEnvironmentForm, { add, listedNames: [] }); + render(AddEnvironmentDialog, { add, listedNames: [], open: true }); // Act - await userEvent.type(screen.getByLabelText('Environment name'), ' '); - await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + await submit(' '); // Assert - expect(await screen.findByText(/Name must be 1 to 64 characters/)).toBeTruthy(); + expect(await screen.findByText(/Use 1 to 64 characters/)).toBeTruthy(); expect(add).not.toHaveBeenCalled(); }); it('requires a name', async () => { // Arrange const add = vi.fn(); - render(AddEnvironmentForm, { add, listedNames: [] }); + render(AddEnvironmentDialog, { add, listedNames: [], open: true }); // Act - await userEvent.click(screen.getByRole('button', { name: 'Add environment' })); + await submit(); // Assert expect(await screen.findByText('Name is required')).toBeTruthy(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte index 678769958f..3b326b25c2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte @@ -1,12 +1,13 @@
-
-

Environments

- - Clients report the environment an event came from, such as production or staging. - List an environment to show it with the casing you choose, choose whether it sends notifications, or discard events from environments you have not listed. - Names are not case-sensitive, and adding or removing a name never changes or deletes events. +
+ + Track the deployment environments your clients report, choose which ones send + notifications, and optionally discard the rest. -
+ +
-
-

Add Environment

- addEnvironment.mutateAsync(environment)} - listedNames={project?.environments?.map((e) => e.name) ?? []} - {prefill} - /> -
+ openAddDialog(row.name)} + {busy} + discardUnlisted={project?.discard_unlisted_environments ?? false} + isError={projectQuery.isError || observedQuery.isError} + isLoading={projectQuery.isLoading || observedQuery.isLoading} + notifyOtherEnvironments={project?.notify_other_environments ?? true} + remove={confirmRemove} + {rows} + {toggleNotify} + toggleNotifyOther={(notify) => + change({ + notify_other_environments: notify + })} + /> -
-

Settings

- {#if project} + {#if project} +
{#key settingsVersion} {/key} - {/if} -
+
+ {/if} -
-

Environments in Use

- Listed environments and the environments seen in this project's events over the last 30 days. - - (prefill = { - name: row.name - })} - {busy} - isError={projectQuery.isError || observedQuery.isError} - isLoading={projectQuery.isLoading || observedQuery.isLoading} - notifyOtherEnvironments={project?.notify_other_environments ?? true} - remove={confirmRemove} - {rows} - {toggleNotify} - /> -
- -
-

Discarded Events

- - Clients are told an event was accepted before it is checked, so discarded events are only visible here. Allow an environment to start accepting its - events, for example when a name was misspelled in a client. - - allow(environment)} - {busy} - isError={discardedQuery.isError} - isLoading={discardedQuery.isLoading} - named={discarded.named} - otherEvents={discardedQuery.data?.other_events} - otherNames={discardedQuery.data?.other_names} - otherNamesCapped={discardedQuery.data?.other_names_capped} - retentionDays={discardedQuery.data?.retention_days ?? 7} - stopDiscardingUnspecified={() => - change({ - discard_events_without_environment: false - })} - unspecified={discarded.unspecified} - /> -
+ {#if showDiscarded} +
+
+

Discarded in the Last {discardedQuery.data?.retention_days ?? 7} Days

+ Clients aren't told when events are discarded. Allow a name to start accepting its events. +
+ allow(environment)} + {busy} + isError={discardedQuery.isError} + isLoading={discardedQuery.isLoading} + named={discarded.named} + otherEvents={discardedQuery.data?.other_events} + otherNames={discardedQuery.data?.other_names} + otherNamesCapped={discardedQuery.data?.other_names_capped} + retentionDays={discardedQuery.data?.retention_days ?? 7} + stopDiscardingUnspecified={() => + change({ + discard_events_without_environment: false + })} + unspecified={discarded.unspecified} + /> +
+ {/if} +{#if showAddDialog} + e.name) ?? []} name={addName} /> +{/if} + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts index 1c87b443a2..7fdb74839b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts @@ -83,7 +83,7 @@ describe('Environments page', () => { render(EnvironmentsPage); // Act - await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + await userEvent.click(screen.getByRole('radio', { name: 'Only environments in this list' })); // Assert expect(await screen.findByText(/every event that has an environment will be discarded/)).toBeTruthy(); @@ -93,7 +93,7 @@ describe('Environments page', () => { it('saves the setting once the warning is confirmed', async () => { // Arrange render(EnvironmentsPage); - await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + await userEvent.click(screen.getByRole('radio', { name: 'Only environments in this list' })); // Act await userEvent.click(await screen.findByRole('button', { name: 'Discard Unlisted Environments' })); @@ -105,7 +105,7 @@ describe('Environments page', () => { it('does not save the setting when the warning is cancelled and puts the switch back', async () => { // Arrange render(EnvironmentsPage); - const discard = () => screen.getByRole('switch', { name: 'Discard events from environments that are not listed' }); + const discard = () => screen.getByRole('radio', { name: 'Only environments in this list' }); await userEvent.click(discard()); // Act @@ -113,6 +113,7 @@ describe('Environments page', () => { // Assert await waitFor(() => expect(discard().getAttribute('aria-checked')).toBe('false')); + expect(screen.getByRole('radio', { name: 'Any environment' }).getAttribute('aria-checked')).toBe('true'); expect(mocks.updateSettings).not.toHaveBeenCalled(); }); @@ -123,7 +124,7 @@ describe('Environments page', () => { render(EnvironmentsPage); // Act - await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + await userEvent.click(screen.getByRole('radio', { name: 'Only environments in this list' })); // Assert expect(await screen.findByText(/None of the listed environments has had events in the last 30 days/)).toBeTruthy(); @@ -137,14 +138,14 @@ describe('Environments page', () => { render(EnvironmentsPage); // Act - await userEvent.click(screen.getByRole('switch', { name: 'Discard events from environments that are not listed' })); + await userEvent.click(screen.getByRole('radio', { name: 'Only environments in this list' })); // Assert await waitFor(() => expect(mocks.updateSettings).toHaveBeenCalledWith({ discard_unlisted_environments: true })); expect(screen.queryByRole('button', { name: 'Discard Unlisted Environments' })).toBeNull(); }); - it('fills the add form with the observed name so the casing can be confirmed instead of adding it', async () => { + it('fills the add dialog with the observed name so the casing can be confirmed instead of adding it', async () => { // Arrange mocks.countData = observed({ count: 4, key: 'qa-west' }); render(EnvironmentsPage); @@ -153,7 +154,27 @@ describe('Environments page', () => { await userEvent.click(await screen.findByRole('button', { name: 'Add qa-west' })); // Assert - await waitFor(() => expect((screen.getByLabelText('Environment name') as HTMLInputElement).value).toBe('qa-west')); + await waitFor(() => expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('qa-west')); expect(mocks.addEnvironment).not.toHaveBeenCalled(); }); + + it('hides the discarded section while every environment is accepted and nothing was discarded', () => { + // Act + render(EnvironmentsPage); + + // Assert + expect(screen.queryByText(/Discarded in the Last/)).toBeNull(); + }); + + it('shows the discarded section once only listed environments are accepted', () => { + // Arrange + mocks.project = { ...project([{ created_utc: '2026-11-01T00:00:00Z', name: 'Production', notify: true }]), discard_unlisted_environments: true }; + + // Act + render(EnvironmentsPage); + + // Assert + expect(screen.getByText('Discarded in the Last 7 Days')).toBeTruthy(); + expect(screen.getByText('Nothing discarded in the last 7 days.')).toBeTruthy(); + }); }); From 89117585564fd55dfc33e3bb5639b93e0287d81d Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 17:25:58 -0600 Subject: [PATCH 08/10] Keep Cancel from adding an environment The Cancel button inside the add-environment form defaulted to a submit button, so cancelling added the typed name. Co-Authored-By: Claude Opus 5.5 --- .../environments/add-environment-dialog.svelte | 2 +- .../environments/environments.svelte.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte index b466e6cdab..8119bc6509 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte @@ -129,7 +129,7 @@ - Cancel + Cancel Add Environment diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts index 61c58177f7..3d703fca95 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts @@ -230,6 +230,20 @@ describe('AddEnvironmentDialog', () => { expect(add).not.toHaveBeenCalled(); }); + it('does not add anything when cancelled', async () => { + // Arrange + const add = vi.fn(); + render(AddEnvironmentDialog, { add, listedNames: [], open: true }); + await userEvent.type(screen.getByLabelText('Name'), 'Staging'); + + // Act + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(add).not.toHaveBeenCalled(); + }); + it('requires a name', async () => { // Arrange const add = vi.fn(); From 6264e61a1e00458727cde1dcbd348e9357933b48 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 18:16:55 -0600 Subject: [PATCH 09/10] Treat environment names as identifiers Names are now 1 to 64 ASCII letters, digits, periods, underscores, or hyphens that start with a letter or digit, so they can be used in filters and scripts. Invalid names, including ones with spaces, stay unspecified instead of being rewritten. Co-Authored-By: Claude Opus 5.5 --- docs/docs/environments.md | 4 ++-- src/Exceptionless.Core/Models/Event.cs | 13 +++++++---- src/Exceptionless.Core/Models/Project.cs | 2 +- .../Api/Endpoints/EventEndpoints.cs | 2 +- .../Api/Handlers/ProjectHandler.cs | 2 +- .../filters/environment-faceted-filter.svelte | 6 ++--- .../add-environment-dialog.svelte | 2 +- .../environments/environments.svelte.test.ts | 19 ++++++++++++--- .../features/projects/environments.test.ts | 9 +++++++- .../src/lib/features/projects/environments.ts | 12 ++++------ .../ClientApp/src/lib/generated/api.ts | 3 ++- .../ClientApp/src/lib/generated/schemas.ts | 4 ++++ .../Project/ProjectEnvironmentModels.cs | 2 +- .../EventEnvironmentSchemaTransformer.cs | 1 + .../Exceptionless.Tests/Api/Data/openapi.json | 21 +++++++++-------- .../ProjectEndpointTests.Environments.cs | 2 ++ .../NotificationEnvironmentFormattingTests.cs | 5 ++-- .../Models/EventEnvironmentTests.cs | 23 +++++++++++++++++++ .../Validation/ProjectValidatorTests.cs | 5 +++- 19 files changed, 96 insertions(+), 41 deletions(-) diff --git a/docs/docs/environments.md b/docs/docs/environments.md index 3607405a87..a311e53f91 100644 --- a/docs/docs/environments.md +++ b/docs/docs/environments.md @@ -40,7 +40,7 @@ For direct API submissions: } ``` -Names are trimmed, with a maximum of 64 characters. Events preserve the supplied casing: `" Production "` is stored and returned as `"Production"`. Filtering is case-insensitive, and aggregation keys are normalized to lowercase, so `Production` and `production` share one environment bucket. Custom names such as `qa-west` are supported. Empty, oversized, control-character, and non-string values are treated as unspecified. Historical events and older clients without this field remain unspecified; they are never assumed to be production. +Names are identifiers, so they can be used in filters and scripts: 1 to 64 ASCII letters, digits, periods (`.`), underscores (`_`), or hyphens (`-`), starting with a letter or digit. Surrounding whitespace is trimmed. Events preserve the supplied casing: `" Production "` is stored and returned as `"Production"`. Filtering is case-insensitive, and aggregation keys are normalized to lowercase, so `Production` and `production` share one environment bucket. Custom names such as `qa-west`, `staging.eu`, or `dev_local` are supported. Names with spaces or other characters (for example `Staging West` or `prod/eu`), empty or oversized names, and non-string values are treated as unspecified; they are never rewritten. Historical events and older clients without this field remain unspecified; they are never assumed to be production. ## Filter your data @@ -64,7 +64,7 @@ The **New** view is not environment-aware. A stack is dated from its first event ## Manage environments -Open **Project Settings → Environments** to list the environments a project uses. Listed names are shown in the casing you type (for example `Production`) in the Environment filter, event details, and notification emails, while matching stays case-insensitive. Adding or removing a name never changes or deletes events, and the server never adds names to the list on its own: environments that appear in your data show up in the table marked **Not added**, with an **Add** action. A project can list up to 100 environments, and a name follows the same rules as an event's environment: 1 to 64 characters after trimming, with no control characters. +Open **Project Settings → Environments** to list the environments a project uses. Listed names are shown in the casing you type (for example `Production`) in the Environment filter, event details, and notification emails, while matching stays case-insensitive. Adding or removing a name never changes or deletes events, and the server never adds names to the list on its own: environments that appear in your data show up in the table marked **Not added**, with an **Add** action. A project can list up to 100 environments, and a name follows the same rules as an event's environment. These settings control what happens to events from environments that are not listed. Projects with no list and default settings behave exactly as before. diff --git a/src/Exceptionless.Core/Models/Event.cs b/src/Exceptionless.Core/Models/Event.cs index a0bc454ee5..21eb62b668 100644 --- a/src/Exceptionless.Core/Models/Event.cs +++ b/src/Exceptionless.Core/Models/Event.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Exceptionless.Core.Extensions; using Exceptionless.Core.Serialization; using MiniValidation; @@ -9,7 +10,7 @@ namespace Exceptionless.Core.Models; [DebuggerDisplay("Type: {Type}, Date: {Date}, Message: {Message}, Value: {Value}, Count: {Count}")] -public class Event : IData, IJsonOnDeserialized +public partial class Event : IData, IJsonOnDeserialized { private string? _environment; @@ -25,16 +26,18 @@ public string? Environment } /// - /// Returns the trimmed deployment name, or null when the value cannot be a deployment name. + /// Returns the trimmed deployment name, or null when it is not a valid name. Names are identifiers so they can be used in filters and + /// scripts: 1 to 64 ASCII letters, digits, periods, underscores, or hyphens, starting with a letter or digit. /// public static string? NormalizeEnvironment(string? value) { string? name = value?.Trim(); - return String.IsNullOrEmpty(name) || name.Length > 64 || name.Any(Char.IsControl) - ? null - : name; + return name is not null && EnvironmentNameRegex().IsMatch(name) ? name : null; } + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z", RegexOptions.CultureInvariant)] + private static partial Regex EnvironmentNameRegex(); + /// /// The event type (ie. error, log message, feature usage). Check Event.KnownTypes for standard event types. /// Nullable in transit; the pipeline infers a default before save. Validated as required on repository save. diff --git a/src/Exceptionless.Core/Models/Project.cs b/src/Exceptionless.Core/Models/Project.cs index dd3e877709..7a8f5df101 100644 --- a/src/Exceptionless.Core/Models/Project.cs +++ b/src/Exceptionless.Core/Models/Project.cs @@ -130,7 +130,7 @@ public static IEnumerable ValidateEnvironments(IReadOnlyCollec string? name = environment?.Name; if (name is null || !String.Equals(ProjectEnvironment.NormalizeName(name), name, StringComparison.Ordinal)) { - yield return new ValidationResult("Environment names must be 1 to 64 characters, without leading or trailing whitespace or control characters.", [nameof(Environments)]); + yield return new ValidationResult("Environment names must be 1 to 64 letters, digits, periods, underscores, or hyphens, and start with a letter or digit.", [nameof(Environments)]); continue; } diff --git a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs index 26ab7a1726..a9c2833db8 100644 --- a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -910,7 +910,7 @@ internal static class EventEndpointHelpers /// public static readonly List SubmitGetAdditionalParameters = [ - new("environment", "query", Description: "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified."), + new("environment", "query", Description: "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified."), new("source", "query", Description: "The event source (ie. machine name, log name, feature name)."), new("message", "query", Description: "The event message."), new("reference", "query", Description: "An optional identifier to be used for referencing this event instance at a later time."), diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 44c800fbad..7d1f1d6d7d 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -471,7 +471,7 @@ public async Task> Handle(AddProjectEnvironme string? name = ProjectEnvironment.NormalizeName(message.Environment.Name); if (name is null) - return Result.Invalid(ValidationError.Create("name", "Environment names must be 1 to 64 characters and cannot contain control characters.")); + return Result.Invalid(ValidationError.Create("name", "Environment names must be 1 to 64 letters, digits, periods, underscores, or hyphens, and start with a letter or digit.")); if (project.FindEnvironment(name) is not null) return Result.Conflict($"The environment '{name}' is already listed. Names are case-insensitive."); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte index 5453628ced..9a9344a9d2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte @@ -8,7 +8,7 @@ import { getOrganizationCountQuery } from '#features/events/api.svelte.ts'; import { organization } from '#features/organizations/context.svelte.ts'; import { getOrganizationProjectsQuery } from '#features/projects/api.svelte.ts'; - import { getEnvironmentKey, getListedEnvironmentNames, hasControlCharacters } from '#features/projects/environments.ts'; + import { getEnvironmentKey, getListedEnvironmentNames, normalizeEnvironmentName } from '#features/projects/environments.ts'; import { terms } from '#features/shared/api/aggregations.ts'; import { getProjectScopeFilter } from './helpers.svelte'; @@ -64,8 +64,8 @@ ]); function createOption(search: string) { - const value = search.trim().toLowerCase(); - return value && value.length <= 64 && !hasControlCharacters(value) + const value = normalizeEnvironmentName(search)?.toLowerCase(); + return value ? { label: listedNames.get(value) ?? value, value diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte index 8119bc6509..4ccd8ac01f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte @@ -38,7 +38,7 @@ if (!normalized) { return { fields: { - name: 'Use 1 to 64 characters, without control characters.' + name: 'Use 1 to 64 letters, digits, periods, underscores, or hyphens, starting with a letter or digit.' } }; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts index 3d703fca95..6ecbc9df55 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts @@ -181,10 +181,10 @@ describe('AddEnvironmentDialog', () => { render(AddEnvironmentDialog, { add, listedNames: ['Production'], open: true }); // Act - await submit(' Staging West '); + await submit(' Staging-West '); // Assert - await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'Staging West', notify: true })); + await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'Staging-West', notify: true })); }); it.each([ @@ -217,6 +217,19 @@ describe('AddEnvironmentDialog', () => { await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'QA-West', notify: true })); }); + it.each(['Staging West', 'prod/eu', '-prod'])('rejects %j because names are identifiers', async (name) => { + // Arrange + const add = vi.fn(); + render(AddEnvironmentDialog, { add, listedNames: [], open: true }); + + // Act + await submit(name); + + // Assert + expect(await screen.findByText(/Use 1 to 64 letters, digits, periods, underscores, or hyphens/)).toBeTruthy(); + expect(add).not.toHaveBeenCalled(); + }); + it('rejects a name that is only whitespace', async () => { // Arrange const add = vi.fn(); @@ -226,7 +239,7 @@ describe('AddEnvironmentDialog', () => { await submit(' '); // Assert - expect(await screen.findByText(/Use 1 to 64 characters/)).toBeTruthy(); + expect(await screen.findByText(/Use 1 to 64 letters, digits, periods, underscores, or hyphens/)).toBeTruthy(); expect(add).not.toHaveBeenCalled(); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts index 5d9e23ef44..031e005cb6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts @@ -75,8 +75,15 @@ describe('getListedEnvironmentNames', () => { describe('normalizeEnvironmentName', () => { it.each([ [' Production ', 'Production'], - ['qa west', 'qa west'], + ['Staging-EU.2', 'Staging-EU.2'], + ['dev_local', 'dev_local'], ['a'.repeat(64), 'a'.repeat(64)], + ['qa west', undefined], + ['prod/eu', undefined], + ['prod:eu', undefined], + ['-prod', undefined], + ['.prod', undefined], + ['produção', undefined], ['', undefined], [' ', undefined], [undefined, undefined], diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts index 2667ae55e4..b986d874f4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts @@ -1,6 +1,7 @@ import type { DiscardedEnvironment, ProjectEnvironment } from '#generated/api.ts'; -export const MAX_ENVIRONMENT_NAME_LENGTH = 64; +/** Environment names are identifiers: 1 to 64 ASCII letters, digits, periods, underscores, or hyphens, starting with a letter or digit. */ +export const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export const MAX_LISTED_ENVIRONMENTS = 100; export type DiscardUnlistedWarning = 'empty-list' | 'no-recent-events'; @@ -112,15 +113,10 @@ export function getListedEnvironmentNames(projects: ProjectWithEnvironments[], p return names; } -export function hasControlCharacters(value: string): boolean { - // eslint-disable-next-line no-control-regex -- Environment names cannot contain control characters. - return /[\u0000-\u001f\u007f-\u009f]/u.test(value); -} - -/** Applies the same rules as the server: trimmed, 1 to 64 characters, no control characters. */ +/** Applies the same rules as the server: the trimmed name, or undefined when it is not a valid identifier. */ export function normalizeEnvironmentName(value: null | string | undefined): string | undefined { const name = value?.trim(); - return name && name.length <= MAX_ENVIRONMENT_NAME_LENGTH && !hasControlCharacters(name) ? name : undefined; + return name && ENVIRONMENT_NAME_PATTERN.test(name) ? name : undefined; } /** Discarded names with a name, most discarded first. The bucket without an environment has no name. */ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index bc0c1b8ead..013d564782 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -269,7 +269,7 @@ export interface NewProject { } export interface NewProjectEnvironment { - /** The environment name. It is trimmed, limited to 64 characters, and is matched case-insensitively against event environments. */ + /** The environment name: 1 to 64 letters, digits, periods, underscores, or hyphens, starting with a letter or digit. It is matched case-insensitively against event environments. */ name: string; /** If true, events from this environment can send notifications. */ notify: boolean; @@ -470,6 +470,7 @@ export interface PersistentEvent { /** * The deployment environment, such as production, staging, or development. * Missing or invalid names remain unspecified. Machine and runtime information is stored separately in data.@environment. + * @pattern ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ */ environment?: null | string; /** diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 9d9f3cd5ea..51af0c5419 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -600,6 +600,10 @@ export const PersistentEventSchema = object({ environment: string() .min(1, "Environment is required") .max(64, "Environment must be at most 64 characters") + .regex( + /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/, + "Environment has invalid format", + ) .nullable() .optional(), type: string() diff --git a/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs b/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs index 05ec6f67bf..74095ee934 100644 --- a/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs +++ b/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs @@ -6,7 +6,7 @@ namespace Exceptionless.Web.Models; public record NewProjectEnvironment { /// - /// The environment name. It is trimmed, limited to 64 characters, and is matched case-insensitively against event environments. + /// The environment name: 1 to 64 letters, digits, periods, underscores, or hyphens, starting with a letter or digit. It is matched case-insensitively against event environments. /// [Required] public string Name { get; set; } = null!; diff --git a/src/Exceptionless.Web/Utility/OpenApi/EventEnvironmentSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/EventEnvironmentSchemaTransformer.cs index 61a8f235b4..5bcd09fd9c 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/EventEnvironmentSchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/EventEnvironmentSchemaTransformer.cs @@ -20,6 +20,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext // The tolerant JSON converter accepts malformed input, but responses contain only a string or null. environment.Type = JsonSchemaType.String | JsonSchemaType.Null; environment.MaxLength = 64; + environment.Pattern = "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"; schema.Required?.Remove(name); } diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 27caa36048..961e1692d2 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -652,7 +652,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -823,7 +823,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -994,7 +994,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -1174,7 +1174,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -11100,7 +11100,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -11273,7 +11273,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -11453,7 +11453,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -11636,7 +11636,7 @@ { "name": "environment", "in": "query", - "description": "The deployment environment (for example, production or staging). Names are trimmed and limited to 64 characters, preserving the supplied casing; missing or invalid names remain unspecified.", + "description": "The deployment environment (for example, production or staging). Names are identifiers of 1 to 64 letters, digits, periods, underscores, or hyphens that start with a letter or digit; casing is preserved and missing or invalid names remain unspecified.", "schema": { "type": "string" } @@ -12869,7 +12869,7 @@ "properties": { "name": { "type": "string", - "description": "The environment name. It is trimmed, limited to 64 characters, and is matched case-insensitively against event environments." + "description": "The environment name: 1 to 64 letters, digits, periods, underscores, or hyphens, starting with a letter or digit. It is matched case-insensitively against event environments." }, "notify": { "type": "boolean", @@ -13605,6 +13605,7 @@ "environment": { "maxLength": 64, "minLength": 0, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", "type": [ "null", "string" @@ -15737,4 +15738,4 @@ "name": "Source Map" } ] -} +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs index e1e3fa5803..2824174e93 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs @@ -70,6 +70,8 @@ public async Task AddEnvironmentAsync_WithNameDifferingOnlyByCase_ReturnsConflic [InlineData("")] [InlineData(" ")] [InlineData("prod\tuction")] + [InlineData("Staging West")] + [InlineData("prod/eu")] [InlineData("0123456789012345678901234567890123456789012345678901234567890123456789")] public async Task AddEnvironmentAsync_WithInvalidName_ReturnsUnprocessableEntity(string name) { diff --git a/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs b/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs index 77e6324718..e8199b2dc6 100644 --- a/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs +++ b/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs @@ -75,13 +75,14 @@ public void GetSlackEventNotificationMessage_WithHostileEnvironmentAndProjectNam var message = GetService().GetSlackEventNotificationMessage(ev, project, false, true, false); // Assert + // Environment names are identifiers, so a hostile name never reaches the message. + Assert.Null(ev.Environment); int linkStart = message.Text.IndexOf("*<", StringComparison.Ordinal); string text = message.Text[..linkStart]; Assert.DoesNotContain("<", text); Assert.DoesNotContain(">", text); Assert.Contains("<!here> project", text); - if (type != Event.KnownTypes.FeatureUsage) - Assert.Contains("<!channel> <https://evil.example|login> & more", text); + Assert.DoesNotContain("channel", text); // The only real link is the event link. Assert.Equal(1, message.Text.Count(c => c == '<')); diff --git a/tests/Exceptionless.Tests/Serializer/Models/EventEnvironmentTests.cs b/tests/Exceptionless.Tests/Serializer/Models/EventEnvironmentTests.cs index 5a532cf7a1..aaeaef17fa 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/EventEnvironmentTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/EventEnvironmentTests.cs @@ -61,6 +61,28 @@ public void ParseEvents_InvalidEnvironment_PreservesSubmissionBatch(string envir Assert.Equal("staging", events[1].Environment); } + [Theory] + [InlineData("production", "production")] + [InlineData(" Staging-EU.2 ", "Staging-EU.2")] + [InlineData("dev_local", "dev_local")] + [InlineData("9prod", "9prod")] + [InlineData("Staging West", null)] + [InlineData("prod/eu", null)] + [InlineData("prod:eu", null)] + [InlineData("-prod", null)] + [InlineData(".prod", null)] + [InlineData("_prod", null)] + [InlineData("produção", null)] + [InlineData("prod\u0007", null)] + public void Environment_IsAnIdentifier_InvalidNamesAreUnspecified(string value, string? expected) + { + // Act + var ev = new Event { Environment = value }; + + // Assert + Assert.Equal(expected, ev.Environment); + } + [Fact] public void Serialize_UnspecifiedEnvironment_OmitsProperty() { @@ -88,6 +110,7 @@ public void Serialize_UnspecifiedEnvironment_OmitsProperty() [InlineData("[\"production\",\"staging\"]")] [InlineData("\" \"")] [InlineData("\"bad\\nenvironment\"")] + [InlineData("\"Staging West\"")] [InlineData("\"abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm\"")] public void Deserialize_InvalidRootEnvironment_PreservesCustomDataRegardlessOfPropertyOrder(string environment) { diff --git a/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs b/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs index 4d06578ce3..b36d398d58 100644 --- a/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs +++ b/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs @@ -30,7 +30,7 @@ public async Task Validate_WhenEnvironmentsAreValid_ReturnsSuccess() { // Arrange var project = CreateValidProject(); - project.Environments = [new ProjectEnvironment { Name = "Production" }, new ProjectEnvironment { Name = "qa west", Notify = false }]; + project.Environments = [new ProjectEnvironment { Name = "Production" }, new ProjectEnvironment { Name = "qa-west.2", Notify = false }]; // Act var (isValid, _) = await _validator.ValidateAsync(project); @@ -46,6 +46,9 @@ public async Task Validate_WhenEnvironmentsAreValid_ReturnsSuccess() [InlineData("production ")] [InlineData("prod\tuction")] [InlineData("prod\nuction")] + [InlineData("Staging West")] + [InlineData("prod/eu")] + [InlineData("-prod")] public async Task Validate_WhenEnvironmentNameIsInvalid_ReturnsError(string name) { // Arrange From 7e11288731b044835921b7b4b66ea9111ca83039 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 21:44:08 -0600 Subject: [PATCH 10/10] Use a valid environment name in the filter retention e2e test Environment names are identifiers now, so the filter no longer offers qa,east. Co-Authored-By: Claude Opus 5.5 --- .../ClientApp/e2e/tests/event-environments.e2e.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-environments.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-environments.e2e.ts index 58c901f779..49e721cca2 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/event-environments.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-environments.e2e.ts @@ -113,13 +113,13 @@ test('stack, session, and stream filters retain names with no current events', a await page.getByRole('button', { name: 'Manage filters' }).click(); await page.getByPlaceholder('Search...').fill('Environment'); await page.getByText('Environment', { exact: true }).click(); - await page.getByPlaceholder('Environment', { exact: true }).fill('qa,east'); - await page.getByRole('option', { name: 'Use qa,east' }).click(); + await page.getByPlaceholder('Environment', { exact: true }).fill('qa-east'); + await page.getByRole('option', { name: 'Use qa-east' }).click(); await page.keyboard.press('Escape'); - await expect(page.getByRole('button', { name: /^Environment\s+qa,east/ })).toBeVisible(); - await expect.poll(() => new URL(page.url()).searchParams.get(route === 'stream' ? 'filters' : 'environment')).toContain('qa,east'); + await expect(page.getByRole('button', { name: /^Environment\s+qa-east/ })).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.get(route === 'stream' ? 'filters' : 'environment')).toContain('qa-east'); await page.reload(); - await expect(page.getByRole('button', { name: /^Environment\s+qa,east/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Environment\s+qa-east/ })).toBeVisible(); } });