diff --git a/docs/docs/environments.md b/docs/docs/environments.md index fc1e066a9a..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 @@ -60,7 +60,32 @@ 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 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. + +| Setting | Default | Meaning | +| --- | --- | --- | +| **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** 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 **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. +- 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. ## 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/Extensions/StackExtensions.cs b/src/Exceptionless.Core/Extensions/StackExtensions.cs index 0f11c42880..a6a8b31165 100644 --- a/src/Exceptionless.Core/Extensions/StackExtensions.cs +++ b/src/Exceptionless.Core/Extensions/StackExtensions.cs @@ -7,7 +7,7 @@ public static class StackExtensions { public static void MarkFixed(this Stack stack, SemanticVersion? version, TimeProvider timeProvider) { - stack.Status = StackStatus.Fixed; + stack.SetStatus(StackStatus.Fixed); stack.DateFixed = timeProvider.GetUtcNow().UtcDateTime; stack.FixedInVersion = version?.ToString(); stack.SnoozeUntilUtc = null; @@ -15,12 +15,23 @@ public static void MarkFixed(this Stack stack, SemanticVersion? version, TimePro public static void MarkOpen(this Stack stack) { - stack.Status = StackStatus.Open; + stack.SetStatus(StackStatus.Open); stack.DateFixed = null; stack.FixedInVersion = null; stack.SnoozeUntilUtc = null; } + /// + /// 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 SetStatus(this Stack stack, StackStatus status) + { + stack.Status = status; + 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/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/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/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/Models/DiscardedEnvironment.cs b/src/Exceptionless.Core/Models/DiscardedEnvironment.cs new file mode 100644 index 0000000000..44996114a6 --- /dev/null +++ b/src/Exceptionless.Core/Models/DiscardedEnvironment.cs @@ -0,0 +1,47 @@ +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; } +} + +/// +/// 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/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/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 4ac783e893..7a8f5df101 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.Ordinal); + 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 letters, digits, periods, underscores, or hyphens, and start with a letter or digit.", [nameof(Environments)]); + continue; + } + + 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 new file mode 100644 index 0000000000..f0c1f9ea32 --- /dev/null +++ b/src/Exceptionless.Core/Models/ProjectEnvironment.cs @@ -0,0 +1,46 @@ +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(); + + /// + /// 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 4356365293..0376273bc4 100644 --- a/src/Exceptionless.Core/Models/Stack.cs +++ b/src/Exceptionless.Core/Models/Stack.cs @@ -101,6 +101,20 @@ 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; } + + /// + /// 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/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..ea41e257fe 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) @@ -167,9 +169,8 @@ 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. + 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/Pipeline/030_CheckForRegressionAction.cs b/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs index f7a8c79a62..bb7d4ecc2d 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,11 +74,18 @@ public override async Task ProcessBatchAsync(ICollection contexts) continue; _logger.LogDebug("Marking stack and events as regressed in version: {Version}", regressedVersion); - stack.Status = StackStatus.Regressed; - await _stackRepository.MarkAsRegressedAsync(stack.Id); + // 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); + 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/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs index 9c9a6b8fa4..fd335416a4 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,22 +37,32 @@ 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); + // 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 { EventId = ctx.Event.Id, - IsNew = ctx.IsNew, - IsRegression = ctx.IsRegression, + IsNew = isNew, + 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)) + if (!ShouldCallWebHook(hook, ctx, isNew, isRegression)) 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, isRegression); var notification = new WebHookNotification { OrganizationId = ctx.Event.OrganizationId, @@ -71,7 +84,38 @@ 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; + + 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; + + bool claimed = await _stackRepository.TryClaimNotificationAsync(ctx.Stack, kind); + if (kind == PendingStackNotification.New) + ctx.Stack.IsNewNotificationPending = false; + else + ctx.Stack.IsRegressionNotificationPending = false; + + return claimed; + } + + private bool ShouldCallWebHook(WebHook hook, EventContext ctx, bool isNew, bool isRegression) { if (!hook.IsEnabled) return false; @@ -79,16 +123,16 @@ 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)) return true; - if (ctx.IsRegression && hook.EventTypes.Contains(WebHook.KnownEventTypes.StackRegression)) + if (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,21 +141,21 @@ private bool ShouldCallWebHook(WebHook hook, EventContext ctx) return false; } - private bool ShouldQueueNotification(EventContext ctx) + private bool ShouldQueueNotification(EventContext ctx, bool isNew, bool isRegression) { 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)) 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 (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..f375bd66e6 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,7 +150,8 @@ private bool ShouldHandle(PersistentEvent ev) } AddDefaultSlackFields(ev, attachment.Fields); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, error.Message?.Truncate(120))}*"; + notificationType = InEnvironment(notificationType, ev, project); + 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 3084b18778..b42b6f527f 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,7 +165,8 @@ private bool ShouldHandle(PersistentEvent ev) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Method", Value = stackingTarget.Method.Name.Truncate(60) }); AddDefaultSlackFields(ev, attachment.Fields); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, stackingTarget.Error.Message?.Truncate(120))}*"; + notificationType = InEnvironment(notificationType, ev, project); + 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 1ed977c6f0..192071f7ce 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,7 +102,8 @@ private bool ShouldHandle(PersistentEvent ev) }; AddDefaultSlackFields(ev, attachment.Fields, false); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, ev.Source?.Truncate(120))}*"; + notificationType = InEnvironment(notificationType, ev, project); + 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 c74d6d9c86..44c940e403 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; @@ -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 f00ffc6278..7493b185e7 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,7 +155,8 @@ private bool ShouldHandle(PersistentEvent ev) attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Url", Value = requestInfo.GetFullPath(true, true, true) }); AddDefaultSlackFields(ev, attachment.Fields); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, source.Truncate(120))}*"; + notificationType = InEnvironment(notificationType, ev, project); + 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 db42da417b..57483b8967 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,7 +99,8 @@ public override SlackMessage GetSlackEventNotification(PersistentEvent ev, Proje attachment.Fields.Add(new SlackMessage.SlackAttachmentFields { Title = "Source", Value = ev.Source.Truncate(60) }); AddDefaultSlackFields(ev, attachment.Fields); - string subject = $"[{project.Name}] A {notificationType}: *{GetSlackEventUrl(ev.Id, messageOrSource.Truncate(120))}*"; + notificationType = InEnvironment(notificationType, ev, project); + 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 072ad5c412..aa4d4a67de 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); @@ -71,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/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..6eb17c19f0 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs @@ -9,7 +9,27 @@ 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); + + /// + /// 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. + /// + 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 29b0c3407e..bfbca94e68 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,6 +152,58 @@ Instant parseDate(def dt) { } } + 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. + 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) + return false; + + await InvalidateStackCacheAsync(stack); + + // 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. + // 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) + { + return false; + } + } + + 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 + // 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); @@ -163,17 +216,28 @@ 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. + // The claim guard key belongs to the regression cycle (see GetClaimCacheKey), so an earlier cycle's key never blocks this one. + 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()); + await InvalidateStackCacheAsync(stack); + } + catch (DocumentNotFoundException) + { + _logger.LogWarning("Stack {StackId} not found when marking as regressed", stack.Id); + } } public Task SoftDeleteByProjectIdAsync(string organizationId, string projectId) @@ -206,6 +270,21 @@ 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) + }; + + // 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.Core/Services/DiscardedEnvironmentService.cs b/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs new file mode 100644 index 0000000000..8023d382d8 --- /dev/null +++ b/src/Exceptionless.Core/Services/DiscardedEnvironmentService.cs @@ -0,0 +1,191 @@ +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; + 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); + + 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); + 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), + _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); + } + + 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, and the events from names that were not tracked. + /// + public async Task GetAsync(string projectId) + { + ArgumentException.ThrowIfNullOrEmpty(projectId); + + 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)))); + + 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 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); + 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/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/Endpoints/ProjectEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs index 199f45d795..3816697594 100644 --- a/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/ProjectEndpoints.cs @@ -480,6 +480,101 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild } }); + 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.ChangeProjectEnvironment(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.ChangeProjectEnvironmentSettings(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) diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 3705100472..7d1f1d6d7d 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,107 @@ 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 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."); + + 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(ChangeProjectEnvironment 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(ChangeProjectEnvironmentSettings 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."); + + var discarded = await discardedEnvironmentService.GetAsync(project.Id); + return new ProjectDiscardedEnvironments + { + RetentionDays = DiscardedEnvironmentService.RetentionDays, + Environments = discarded.Environments, + OtherEvents = discarded.OtherEvents, + OtherNames = discarded.OtherNames, + OtherNamesCapped = discarded.OtherNamesCapped + }; + } + + 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/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs index 5602041978..aaefc772d9 100644 --- a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -111,7 +111,7 @@ public async Task Handle(SnoozeStacks message) foreach (var stack in stacks) { - stack.Status = StackStatus.Snoozed; + stack.SetStatus(StackStatus.Snoozed); stack.SnoozeUntilUtc = message.SnoozeUntilUtc; stack.FixedInVersion = null; stack.DateFixed = null; @@ -227,7 +227,7 @@ public async Task Handle(ChangeStacksStatus message) { foreach (var stack in stacks) { - stack.Status = message.Status; + stack.SetStatus(message.Status); 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 0ae02c94ea..ba0ffee9d3 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 ChangeProjectEnvironment(string Id, string? Name, UpdateProjectEnvironment Changes, HttpContext Context); +public record RemoveProjectEnvironment(string Id, string? Name, HttpContext Context); +public record ChangeProjectEnvironmentSettings(string Id, UpdateProjectEnvironmentSettings Settings, HttpContext Context); +public record GetDiscardedProjectEnvironments(string Id, HttpContext Context); 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(); } }); 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..96e5be9d95 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-environments.e2e.ts @@ -0,0 +1,131 @@ +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('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('radio', { name: 'Only environments in this list' }); + 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 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 () => { + 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(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.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 dialog so the casing can be corrected', async () => { + const dialog = page.getByRole('alertdialog'); + await page.getByRole('button', { name: 'Add staging' }).click(); + await expect(dialog.getByLabel('Name')).toHaveValue('staging'); + + 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 () => { + 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('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('checkbox', { name: "Also accept events that don't send an environment" })).toBeChecked(); + + 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('switch', { name: 'Send notifications for Prodution' })).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('switch', { name: 'Send notifications for Prodution' })).toBeHidden(); + }); +}); 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} - {environment ?? 'Unspecified'} + {/if} 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/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..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 @@ -19,6 +19,8 @@ getStackTrace, 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'; @@ -70,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 + }); @@ -77,8 +84,10 @@ {#if event.environment} 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..6d2a7af86e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environment-name.svelte @@ -0,0 +1,20 @@ + + +{display.name ?? unspecified} 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-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/add-environment-dialog.svelte new file mode 100644 index 0000000000..4ccd8ac01f --- /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/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..f78333b459 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/discarded-environments-table.svelte @@ -0,0 +1,116 @@ + + +
+ + + + Environment + Events + Last seen + Actions + + + + {#if isLoading} + + {:else if isError} + + {:else if !hasRows} + Nothing discarded in the last {retentionDays} days. + {:else} + {#each named as environment (environment.key)} + + {environment.name ?? environment.key} + {environment.count.toLocaleString()} + + + + + + {/each} + {#if otherEvents > 0} + + + Plus {otherEvents.toLocaleString()} events from {otherNamesCapped ? 'at least ' : ''}{otherNames.toLocaleString()} more names, too many + to list here. Check the environment your clients send. + + + {/if} + {#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..7a474b59ff --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environment-settings.svelte @@ -0,0 +1,60 @@ + + +
+ + + update({ + discard_unlisted_environments: value === 'listed' + })} + value={discardUnlistedEnvironments ? 'listed' : 'any'} + > +
+ +
+ +

New environments show up in the table above as they are seen.

+
+
+
+ +
+ +

Others are discarded and don't count toward your plan limit.

+
+
+
+ + {#if discardUnlistedEnvironments} +
+ + update({ + discard_events_without_environment: !checked + })} + /> + +
+ {/if} +
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..47d0ae9f50 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments-table.svelte @@ -0,0 +1,130 @@ + + +
+ + + + Environment + Notifications + Events (30 days) + Last seen + Actions + + + + {#if isLoading} + + {:else if isError} + + {:else} + {#each rows as row (row.key)} + + + {row.name} + {#if !row.isListed} + {discardUnlisted ? 'Discarded' : 'Not added'} + {/if} + + + {#if row.isListed} + toggleNotify(row, checked)} + /> + {:else} + {#if discardUnlisted} + — + {:else} + Default + {/if} + {/if} + + {row.count.toLocaleString()} + + {#if row.lastSeen} + + {:else} + Not in 30 days + {/if} + + + {#if row.isListed} + + {:else} + + {/if} + + + {/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 new file mode 100644 index 0000000000..6ecbc9df55 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/environments/environments.svelte.test.ts @@ -0,0 +1,272 @@ +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 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'; + +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' } + ]; + + 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, { ...tableProps, add, remove }); + + // 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('Not added')).toBeTruthy(); + expect(screen.getByText('Default')).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, { ...tableProps, toggleNotify }); + + // Act + await userEvent.click(screen.getByRole('switch', { name: 'Send notifications for dev' })); + + // 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', () => { + 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(allow).toHaveBeenCalledWith(named[0]); + 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(/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', () => { + // Act + render(DiscardedEnvironmentsTable, { allow: vi.fn(), named: [], retentionDays: 7, stopDiscardingUnspecified: vi.fn() }); + + // Assert + expect(screen.getByText('Nothing discarded in the last 7 days.')).toBeTruthy(); + }); +}); + +describe('EnvironmentSettings', () => { + afterEach(() => cleanup()); + + it('switches between accepting any environment and only listed ones', async () => { + // Arrange + const update = vi.fn(); + render(EnvironmentSettings, { discardEventsWithoutEnvironment: false, discardUnlistedEnvironments: false, update }); + + // Act + await userEvent.click(screen.getByRole('radio', { name: 'Only environments in this list' })); + + // Assert + 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('AddEnvironmentDialog', () => { + afterEach(() => cleanup()); + + 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(AddEnvironmentDialog, { add, listedNames: ['Production'], open: true }); + + // Act + await submit(' Staging-West '); + + // Assert + await waitFor(() => expect(add).toHaveBeenCalledWith({ name: 'Staging-West', notify: true })); + }); + + it.each([ + ['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(AddEnvironmentDialog, { add, listedNames: ['Production'], open: true }); + + // Act + 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 corrected before adding', async () => { + // Arrange + const add = vi.fn().mockResolvedValue(undefined); + 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 submit('QA-West'); + + // Assert + 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(); + render(AddEnvironmentDialog, { add, listedNames: [], open: true }); + + // Act + await submit(' '); + + // Assert + expect(await screen.findByText(/Use 1 to 64 letters, digits, periods, underscores, or hyphens/)).toBeTruthy(); + 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(); + render(AddEnvironmentDialog, { add, listedNames: [], open: true }); + + // Act + await submit(); + + // Assert + expect(await screen.findByText('Name is required')).toBeTruthy(); + expect(add).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000000..031e005cb6 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildEnvironmentRows, + getDiscardUnlistedWarning, + 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'], + ['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], + ['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 }); + }); +}); + +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 new file mode 100644 index 0000000000..b986d874f4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/environments.ts @@ -0,0 +1,132 @@ +import type { DiscardedEnvironment, ProjectEnvironment } from '#generated/api.ts'; + +/** 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'; + +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]; +} + +/** + * 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); + 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; +} + +/** 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 && ENVIRONMENT_NAME_PATTERN.test(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..3ac27db022 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/schemas.ts @@ -2,13 +2,22 @@ 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'; -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'), 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..013d564782 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: 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; +} + export interface NewSavedView { /** @pattern ^[a-fA-F0-9]{24}$ */ organization_id: string; @@ -448,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; /** @@ -507,6 +530,44 @@ 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[]; + /** + * 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. */ +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 +682,18 @@ 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; + /** + * 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. */ @@ -681,6 +754,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 +842,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 +1012,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..51af0c5419 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") @@ -582,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() @@ -642,6 +664,36 @@ export const ProblemDetailsSchema = object({ }); 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 +>; + +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 +783,8 @@ 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(), + is_regression_notification_pending: boolean(), references: array(string()), tags: array(string()), duplicate_signature: string().min(1, "Duplicate signature is required"), @@ -812,6 +866,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 +959,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 +1137,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..3b326b25c2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/+page.svelte @@ -0,0 +1,298 @@ + + +
+
+ + Track the deployment environments your clients report, choose which ones send + notifications, and optionally discard the rest. + + +
+ + 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 + })} + /> + + {#if project} +
+ {#key settingsVersion} + + {/key} +
+ {/if} + + {#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} + + + + + 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 + + + + + + + + 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]/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..7fdb74839b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/environments/environments-page.svelte.test.ts @@ -0,0 +1,180 @@ +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('radio', { name: 'Only environments in this list' })); + + // 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('radio', { name: 'Only environments in this list' })); + + // 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('radio', { name: 'Only environments in this list' }); + 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(screen.getByRole('radio', { name: 'Any environment' }).getAttribute('aria-checked')).toBe('true'); + 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('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(); + 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('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 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); + + // Act + await userEvent.click(await screen.findByRole('button', { name: 'Add qa-west' })); + + // Assert + 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(); + }); +}); 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/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 1b7a66970b..01f548d5c8 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -776,7 +776,7 @@ public async Task> UpdateStackStatusAsync( } else if (changed) { - stack.Status = stackStatus; + stack.SetStatus(stackStatus); stack.DateFixed = null; stack.FixedInVersion = null; stack.SnoozeUntilUtc = null; @@ -831,7 +831,7 @@ public async Task> SnoozeStackAsync( || stack.DateFixed is not null; if (changed) { - stack.Status = StackStatus.Snoozed; + stack.SetStatus(StackStatus.Snoozed); 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 new file mode 100644 index 0000000000..74095ee934 --- /dev/null +++ b/src/Exceptionless.Web/Models/Project/ProjectEnvironmentModels.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Models; + +namespace Exceptionless.Web.Models; + +public record NewProjectEnvironment +{ + /// + /// 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!; + + /// + /// 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; } = []; + + /// + /// 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/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/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/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..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" } @@ -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": [ @@ -10796,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" } @@ -10969,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" } @@ -11149,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" } @@ -11332,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" } @@ -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: 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", + "description": "If true, events from this environment can send notifications." + } + } + }, "NewSavedView": { "required": [ "name", @@ -13251,6 +13605,7 @@ "environment": { "maxLength": 64, "minLength": 0, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", "type": [ "null", "string" @@ -13448,6 +13803,94 @@ } } }, + "ProjectDiscardedEnvironments": { + "required": [ + "retention_days", + "environments", + "other_events", + "other_names", + "other_names_capped" + ], + "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" + } + }, + "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." + } + } + }, + "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 +14061,8 @@ "first_occurrence", "last_occurrence", "occurrences_are_critical", + "is_new_notification_pending", + "is_regression_notification_pending", "references", "tags", "duplicate_signature", @@ -13725,6 +14170,14 @@ "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." + }, + "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": { @@ -13914,6 +14367,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 +15235,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 +15274,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", @@ -15224,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 new file mode 100644 index 0000000000..2824174e93 --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProjectEndpointTests.Environments.cs @@ -0,0 +1,347 @@ +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("Staging West")] + [InlineData("prod/eu")] + [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); + 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] + 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/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/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/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/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/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/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 new file mode 100644 index 0000000000..cb213c2f28 --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/EnvironmentManagementPipelineTests.cs @@ -0,0 +1,697 @@ +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 System.Reflection; +using Microsoft.Extensions.DependencyInjection; +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)).Environments); + 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)).Environments; + + // 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 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_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_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() + { + // 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); + } + + [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))!; + stack.MarkFixed(null, TimeProvider); + stack.DateFixed = DateTime.UtcNow.AddHours(-1); + await _stackRepository.SaveAsync(stack, o => o.ImmediateConsistency()); + } + + [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.TryClaimNotificationAsync(stack, PendingStackNotification.New))); + bool claimedAgain = await _stackRepository.TryClaimNotificationAsync(stack, PendingStackNotification.New); + + // 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.TryClaimNotificationAsync(stack, PendingStackNotification.New); + + // 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..e8199b2dc6 --- /dev/null +++ b/tests/Exceptionless.Tests/Plugins/NotificationEnvironmentFormattingTests.cs @@ -0,0 +1,107 @@ +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); + } + + [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 + // 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); + Assert.DoesNotContain("channel", text); + + // The only real link is the event link. + Assert.Equal(1, message.Text.Count(c => c == '<')); + } + + 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/Repositories/StackRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs index 54b4093187..cc954e00d4 100644 --- a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs @@ -120,6 +120,91 @@ 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 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: 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); + + // Assert + Assert.True(first); + 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() + { + // 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 +213,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/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/Services/DiscardedEnvironmentServiceTests.cs b/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs new file mode 100644 index 0000000000..e0b73c043d --- /dev/null +++ b/tests/Exceptionless.Tests/Services/DiscardedEnvironmentServiceTests.cs @@ -0,0 +1,156 @@ +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)).Environments; + + // 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)).Environments; + + // 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_TracksTheFirstNamesAndCountsTheRestAsOtherEvents() + { + // 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"]); + await _service.RecordAsync(ProjectId, [names[^1], names[^1]]); + var summary = await _service.GetAsync(ProjectId); + + // Assert + 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] + 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)).Environments; + + // 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")).Environments; + + // 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)).Environments; + + // 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..b36d398d58 100644 --- a/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs +++ b/tests/Exceptionless.Tests/Validation/ProjectValidatorTests.cs @@ -25,6 +25,90 @@ 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.2", 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")] + [InlineData("Staging West")] + [InlineData("prod/eu")] + [InlineData("-prod")] + 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() {