Skip to content
Open
29 changes: 27 additions & 2 deletions docs/docs/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
services.AddSingleton<IAssistantUsageRecorder>(provider => provider.GetRequiredService<UsageService>());
services.AddSingleton<SlackService>();
services.AddSingleton<StackService>();
services.AddSingleton<DiscardedEnvironmentService>();

services.AddTransient<IDomainLoginProvider, ActiveDirectoryLoginProvider>();
}
Expand Down
54 changes: 54 additions & 0 deletions src/Exceptionless.Core/Extensions/ProjectEnvironmentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Exceptionless.Core.Models;

namespace Exceptionless.Core.Extensions;

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// Returns the listed casing for a listed environment, otherwise the name as it was given.
/// </summary>
public static string? GetEnvironmentDisplayName(this Project project, string? environment)
{
return project.FindEnvironment(environment)?.Name ?? environment;
}

/// <summary>
/// Events without an environment are only discarded when unlisted environments are discarded as well.
/// </summary>
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;
}

/// <summary>
/// Listed environments use their own setting. Unlisted environments and events without an environment use the project setting.
/// </summary>
public static bool ShouldNotifyForEnvironment(this Project project, string? environment)
{
return project.FindEnvironment(environment)?.Notify ?? project.NotifyOtherEnvironments;
}
}
15 changes: 13 additions & 2 deletions src/Exceptionless.Core/Extensions/StackExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,31 @@ 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;
}

public static void MarkOpen(this Stack stack)
{
stack.Status = StackStatus.Open;
stack.SetStatus(StackStatus.Open);
stack.DateFixed = null;
stack.FixedInVersion = null;
stack.SnoozeUntilUtc = null;
}

/// <summary>
/// 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 <see cref="Stack.Status"/>.
/// </summary>
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)
Expand Down
3 changes: 2 additions & 1 deletion src/Exceptionless.Core/Jobs/CleanupOrphanedDataJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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); ;
Expand Down
9 changes: 8 additions & 1 deletion src/Exceptionless.Core/Jobs/EventNotificationsJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,16 @@ protected override async Task<JobResult> 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<DateTime>(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;
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Core/Mail/Mailer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public async Task<bool> SendContactRequestAsync(string name, string emailAddress
public async Task<bool> 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);
Expand Down
3 changes: 2 additions & 1 deletion src/Exceptionless.Core/Migrations/FixDuplicateStacks.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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); ;
Expand Down
47 changes: 47 additions & 0 deletions src/Exceptionless.Core/Models/DiscardedEnvironment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Exceptionless.Core.Models;

/// <summary>
/// An environment name that recently had events discarded because it is not listed on the project.
/// </summary>
public record DiscardedEnvironment
{
/// <summary>
/// The lowercased key for the environment, or null for events that had no environment.
/// </summary>
public string? Key { get; init; }

/// <summary>
/// The first-seen casing of the name, or null for events that had no environment.
/// </summary>
public string? Name { get; init; }

/// <summary>
/// The number of events discarded within the retention window.
/// </summary>
public long Count { get; init; }

public DateTime LastSeenUtc { get; init; }
}

/// <summary>
/// The names that recently had events discarded, plus how many events came from names that could not be listed individually.
/// </summary>
public record DiscardedEnvironmentSummary
{
public IReadOnlyCollection<DiscardedEnvironment> Environments { get; init; } = [];

/// <summary>
/// Events within the retention window from names beyond the per-project limit.
/// </summary>
public long OtherEvents { get; init; }

/// <summary>
/// How many different names those events came from, up to a limit.
/// </summary>
public int OtherNames { get; init; }

/// <summary>
/// True when there were more other names than are counted.
/// </summary>
public bool OtherNamesCapped { get; init; }
}
13 changes: 8 additions & 5 deletions src/Exceptionless.Core/Models/Event.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
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;

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;

Expand All @@ -25,16 +26,18 @@ public string? Environment
}

/// <summary>
/// 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.
/// </summary>
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve previously valid environments when reading stored events

BLOCKER: For existing events with previously valid names such as Staging West, prod/eu, or produção, this stricter normalization also runs during deserialization, not just when adding managed environments. EventEnvironmentConverter.Read uses this method and now moves those stored values into custom data instead of populating Environment, so fetching historical events changes their public API representation and makes their environment disappear from event details. Preserve the previous read contract for persisted events, even if newly listed names must follow stricter rules, and cover a historical-record round trip.

AGENTS.md reference: AGENTS.md:L74-L75

Useful? React with 👍 / 👎.

}

[GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z", RegexOptions.CultureInvariant)]
private static partial Regex EnvironmentNameRegex();

/// <summary>
/// The event type (ie. error, log message, feature usage). Check <see cref="KnownTypes">Event.KnownTypes</see> for standard event types.
/// Nullable in transit; the pipeline infers a default before save. Validated as required on repository save.
Expand Down
10 changes: 10 additions & 0 deletions src/Exceptionless.Core/Models/PendingStackNotification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Exceptionless.Core.Models;

/// <summary>
/// A notification that waits for the first occurrence in a notifying environment.
/// </summary>
public enum PendingStackNotification
{
New,
Regression
}
Loading
Loading