diff --git a/docs/docs/clients/dotnet/configuration.md b/docs/docs/clients/dotnet/configuration.md index b807877465..7283c9194c 100644 --- a/docs/docs/clients/dotnet/configuration.md +++ b/docs/docs/clients/dotnet/configuration.md @@ -5,6 +5,8 @@ order: 1 # Configuration +Set a deployment environment once at startup, with optional per-event overrides. See [Environments](/docs/environments/) for configuration examples. + There are a few ways to configure Exceptionless in your project. We'll cover them here or you can jump to app-specific examples: [Console App Example](/docs/clients/dotnet/guides/console-apps-example), [Web Server Example](/docs/clients/dotnet/guides/web-server-example). --- diff --git a/docs/docs/clients/javascript/client-configuration.md b/docs/docs/clients/javascript/client-configuration.md index 0db6e31181..06f51e5b3d 100644 --- a/docs/docs/clients/javascript/client-configuration.md +++ b/docs/docs/clients/javascript/client-configuration.md @@ -5,6 +5,8 @@ order: 1 # Configuration +Set a deployment environment once at startup, with optional per-event overrides. See [Environments](/docs/environments/) for configuration examples. + - [Installation](#installation) - [Browser](#browser) - [Node.js](#nodejs) diff --git a/docs/docs/environments.md b/docs/docs/environments.md new file mode 100644 index 0000000000..0cc418d0d3 --- /dev/null +++ b/docs/docs/environments.md @@ -0,0 +1,69 @@ +--- +title: "Environments" +--- + +# Environments + +Use an environment to distinguish production, staging, development, or a custom deployment within one project. Each event can carry one optional top-level `environment` string. The same error still belongs to one stack, with one shared status and fixed version. + +## Configure your client + +Set a default during application startup. In the .NET client: + +```csharp +client.Configuration.SetEnvironment("production"); +client.CreateLog("Deployment complete").SetEnvironment("staging").Submit(); +``` + +You can also set `Exceptionless:Environment` in .NET configuration, or the `Exceptionless__Environment` environment variable. The hosting integration uses `IHostEnvironment.EnvironmentName` when no explicit environment is configured. A per-event value overrides the default. + +In the JavaScript client: + +```javascript +await Exceptionless.startup((config) => { + config.apiKey = "API_KEY_HERE"; + config.environment = "production"; +}); + +await Exceptionless.createLog("Deployment complete") + .setEnvironment("staging") + .submit(); +``` + +For direct API submissions: + +```json +{ + "type": "log", + "message": "Deployment complete", + "environment": "production" +} +``` + +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. + +## Filter your data + +Choose **Manage filters → Environment** on Events, Stacks, Sessions, or Stream. Select one or more names, or **Unspecified** for events without an environment. Clear the selection to include all environments. The picker discovers names for the selected projects and time range; you can also enter a name that has no current events. + +Environment selections persist in URLs and saved views. Events, sessions, and stream tables have an optional Environment column. Event details show Environment in the overview; **Machine & runtime** contains the existing `data.@environment` diagnostics. + +Environment searches and aggregations are available on every plan: + +| Search | Meaning | +| --- | --- | +| `environment:production` | Production events | +| `(environment:production OR environment:staging)` | Either deployment | +| `_missing_:environment` | Historical or unspecified events | +| `_exists_:environment` | Events with an environment | +| `environment:"qa west"` | A custom name containing spaces | + +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. + +## Fixing stacks across deployments + +Use [fixed in version](/docs/versioning/) when environments run different releases. For example, if a stack is fixed in `2.4.0`, occurrences from production running `2.3.0` do not represent a regression just because staging already has the fix. Continue sending the application version with events. Plain **Fixed**, without a version, retains its existing behavior across all environments. + +## Rollout + +Deploy the server before adopting SDK versions that expose the new setting. The server adds mappings to existing event indices without rewriting historical events. Clients that do not send an environment continue to work. `data.@environment` and custom `data.environment` values retain their existing meanings. diff --git a/docs/docs/filtering-and-searching.md b/docs/docs/filtering-and-searching.md index d0ba2f1a50..c0a31d4ff4 100644 --- a/docs/docs/filtering-and-searching.md +++ b/docs/docs/filtering-and-searching.md @@ -6,6 +6,7 @@ title: "Filtering & Searching" - [Filter by Organization \& Project](#filter-by-organization--project) - [Filter by Time Frame](#filter-by-time-frame) +- [Filter by Environment](#filter-by-environment) - [Filter / Search by Specific Criteria](#filter--search-by-specific-criteria) - [Searchable Fields \& Requirements](#searchable-fields--requirements) - [Multiple Queries](#multiple-queries) @@ -30,6 +31,10 @@ Click on the calendar icon in the header to select from multiple preset time fra ![Exceptionless Filter Time Frame](img/filter-by-timeframe.png) +## Filter by Environment + +Choose **Manage filters → Environment** to select production, staging, development, or a custom name. Select **Unspecified** for events without this property, or clear the selection to include all environments. See [Environments](/docs/environments/) for client configuration and shared stack behavior. + ## Filter / Search by Specific Criteria Click the magnifying glass to search by specific criteria. @@ -53,6 +58,7 @@ View a complete list of searchable terms, examples, and FAQs below. | stack | `stack:54d8315ce6bb2d0500bcc7b4` | true | Stack id | | reference | `reference:12345678` | true | Reference id | | session | `session:12345678` | true | Session id | +| environment | `environment:production` or `_missing_:environment` | true | Deployment environment | | type | `type:error` | true | Event type | | source | `source:"my log source"` or `"my log source"` | false | Event source | | level | `level:Error` | true | Log level | diff --git a/docs/docs/versioning.md b/docs/docs/versioning.md index 033b1aaada..8cf36bd12d 100644 --- a/docs/docs/versioning.md +++ b/docs/docs/versioning.md @@ -4,6 +4,8 @@ title: "Versioning" # Versioning +Stacks and their fixed version are shared across [environments](/docs/environments/). Continue sending application versions so older deployments can report occurrences without incorrectly reopening a stack fixed in a newer version. + You can mark error stacks fixed and they won't show up or notify you until they regress! ## How does this work? diff --git a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs index 6a869d0425..98aa5b4f6b 100644 --- a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs @@ -183,6 +183,7 @@ public static PersistentEvent ToSessionStartEvent(this PersistentEvent source, I { var startEvent = new PersistentEvent { + Environment = source.Environment, Date = source.Date, Geo = source.Geo, OrganizationId = source.OrganizationId, diff --git a/src/Exceptionless.Core/Migrations/010_AddEventEnvironment.cs b/src/Exceptionless.Core/Migrations/010_AddEventEnvironment.cs new file mode 100644 index 0000000000..ca0582251b --- /dev/null +++ b/src/Exceptionless.Core/Migrations/010_AddEventEnvironment.cs @@ -0,0 +1,36 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories.Configuration; +using Foundatio.Repositories.Elasticsearch.Extensions; +using Foundatio.Repositories.Migrations; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Migrations; + +public sealed class AddEventEnvironment : MigrationBase +{ + private readonly ExceptionlessElasticConfiguration _configuration; + + public AddEventEnvironment(ExceptionlessElasticConfiguration configuration, ILoggerFactory loggerFactory) : base(loggerFactory) + { + _configuration = configuration; + MigrationType = MigrationType.VersionedAndResumable; + Version = 10; + } + + public override async Task RunAsync(MigrationContext context) + { + var response = await _configuration.Client.Indices.PutMappingAsync(mapping => mapping + .Indices($"{_configuration.Events.Name}-v*-*") + .AllowNoIndices(true) + .IgnoreUnavailable(true) + .Properties(properties => properties.Text(ev => ev.Environment, + text => text.Analyzer(EventIndex.LOWER_KEYWORD_ANALYZER) + .Fields(fields => fields.Keyword("keyword", keyword => keyword.Normalizer("lowercase"))))), context.CancellationToken); + _logger.LogRequest(response); + if (!response.IsValidResponse) + { + throw new InvalidOperationException("Unable to add the event environment mapping: " + response.DebugInformation); + } + } +} diff --git a/src/Exceptionless.Core/Models/Event.cs b/src/Exceptionless.Core/Models/Event.cs index 84a96c7acd..20e1560b4d 100644 --- a/src/Exceptionless.Core/Models/Event.cs +++ b/src/Exceptionless.Core/Models/Event.cs @@ -11,6 +11,26 @@ namespace Exceptionless.Core.Models; [DebuggerDisplay("Type: {Type}, Date: {Date}, Message: {Message}, Value: {Value}, Count: {Count}")] public class Event : IData, IJsonOnDeserialized { + private string? _environment; + + /// + /// 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. + /// + [StringLength(64)] + [JsonConverter(typeof(EventEnvironmentConverter))] + public string? Environment + { + get => _environment; + set + { + string? name = value?.Trim(); + _environment = String.IsNullOrEmpty(name) || name.Length > 64 || name.Any(Char.IsControl) + ? null + : name; + } + } + /// /// 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. @@ -112,7 +132,7 @@ void IJsonOnDeserialized.OnDeserialized() protected bool Equals(Event other) { - return String.Equals(Type, other.Type) && String.Equals(Source, other.Source) && Tags.CollectionEquals(other.Tags) && String.Equals(Message, other.Message) && String.Equals(Geo, other.Geo) && Value == other.Value && Equals(Data, other.Data); + return String.Equals(Environment, other.Environment) && String.Equals(Type, other.Type) && String.Equals(Source, other.Source) && Tags.CollectionEquals(other.Tags) && String.Equals(Message, other.Message) && String.Equals(Geo, other.Geo) && Value == other.Value && Equals(Data, other.Data); } public override bool Equals(object? obj) @@ -138,6 +158,10 @@ public override int GetHashCode() hashCode = (hashCode * 397) ^ (Geo?.GetHashCode() ?? 0); hashCode = (hashCode * 397) ^ Value.GetHashCode(); hashCode = (hashCode * 397) ^ (Data?.GetCollectionHashCode(_exclusions) ?? 0); + if (Environment is not null) + { + hashCode = (hashCode * 397) ^ Environment.GetHashCode(); + } return hashCode; } } diff --git a/src/Exceptionless.Core/Models/EventSummaryModel.cs b/src/Exceptionless.Core/Models/EventSummaryModel.cs index 6bfdbd4782..fd0226d7c5 100644 --- a/src/Exceptionless.Core/Models/EventSummaryModel.cs +++ b/src/Exceptionless.Core/Models/EventSummaryModel.cs @@ -2,6 +2,7 @@ public record EventSummaryModel : SummaryData { + public string? Environment { get; set; } public DateTimeOffset Date { get; set; } public string ProjectId { get; set; } = null!; public string? ProjectName { get; set; } diff --git a/src/Exceptionless.Core/Models/WebHookEvent.cs b/src/Exceptionless.Core/Models/WebHookEvent.cs index e14cc25a7d..dc55e5324a 100644 --- a/src/Exceptionless.Core/Models/WebHookEvent.cs +++ b/src/Exceptionless.Core/Models/WebHookEvent.cs @@ -14,6 +14,7 @@ public WebHookEvent(string baseUrl) public DateTimeOffset? OccurrenceDate { get; init; } public TagSet? Tags { get; init; } public string? Type { get; init; } + public string? Environment { get; init; } public string? Source { get; init; } public string? Message { get; init; } public string ProjectId { get; init; } = null!; diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs index 580831e116..380f6fbaef 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/70_SessionPlugin.cs @@ -125,11 +125,11 @@ private async Task ProcessAutoSessionsAsync(ICollection contexts) { var identityGroups = contexts .OrderBy(c => c.Event.Date) - .GroupBy(c => c.Event.GetUserIdentity(_serializer, _logger)?.Identity); + .GroupBy(c => (c.Event.ProjectId, Environment: c.Event.Environment?.ToLowerInvariant(), Identity: c.Event.GetUserIdentity(_serializer, _logger)?.Identity)); foreach (var identityGroup in identityGroups) { - if (String.IsNullOrEmpty(identityGroup.Key)) + if (String.IsNullOrEmpty(identityGroup.Key.Identity)) continue; string projectId = identityGroup.First().Project.Id; @@ -160,7 +160,7 @@ private async Task ProcessAutoSessionsAsync(ICollection contexts) ctx.IsCancelled = true; }); - string? sessionId = await GetIdentitySessionIdAsync(projectId, identityGroup.Key); + string? sessionId = await GetIdentitySessionIdAsync(projectId, identityGroup.Key.Identity, identityGroup.Key.Environment); // if session end, without any session events, cancel if (String.IsNullOrEmpty(sessionId) && session.Count == 1 && firstSessionEvent.Event.IsSessionEnd()) @@ -192,7 +192,7 @@ private async Task ProcessAutoSessionsAsync(ICollection contexts) } if (!lastSessionEvent.Event.IsSessionEnd()) - await SetIdentitySessionIdAsync(projectId, identityGroup.Key, sessionId); + await SetIdentitySessionIdAsync(projectId, identityGroup.Key.Identity, sessionId, identityGroup.Key.Environment); } else { @@ -217,7 +217,7 @@ public override Task EventProcessedAsync(EventContext context) return Task.CompletedTask; } - private static List> CreateSessionGroups(IGrouping identityGroup) + private static List> CreateSessionGroups(IEnumerable identityGroup) { var sessions = new List>(); var currentSession = new List(); @@ -259,14 +259,19 @@ private Task SetSessionStartEventIdAsync(string projectId, string sessionI return _cache.SetAsync(GetSessionStartEventIdCacheKey(projectId, sessionId), eventId, TimeSpan.FromDays(1)); } - private static string GetIdentitySessionIdCacheKey(string projectId, string identity) + private static string GetIdentitySessionIdCacheKey(string projectId, string identity, string? environment) { + if (environment is not null) + { + return String.Concat(projectId, ":environment:", environment.ToSHA1(), ":identity:", identity.ToSHA1()); + } + return String.Concat(projectId, ":identity:", identity.ToSHA1()); } - private async Task GetIdentitySessionIdAsync(string projectId, string identity) + private async Task GetIdentitySessionIdAsync(string projectId, string identity, string? environment) { - string cacheKey = GetIdentitySessionIdCacheKey(projectId, identity); + string cacheKey = GetIdentitySessionIdCacheKey(projectId, identity, environment); string? sessionId = await _cache.GetAsync(cacheKey, null); if (!String.IsNullOrEmpty(sessionId)) { @@ -279,9 +284,9 @@ await Task.WhenAll( return sessionId; } - private Task SetIdentitySessionIdAsync(string projectId, string identity, string sessionId) + private Task SetIdentitySessionIdAsync(string projectId, string identity, string sessionId, string? environment) { - return _cache.SetAsync(GetIdentitySessionIdCacheKey(projectId, identity), sessionId, _sessionTimeout); + return _cache.SetAsync(GetIdentitySessionIdCacheKey(projectId, identity, environment), sessionId, _sessionTimeout); } private async Task CreateSessionStartEventAsync(EventContext startContext, DateTime? lastActivityUtc, bool? isSessionEnd) diff --git a/src/Exceptionless.Core/Plugins/WebHook/Default/020_VersionTwoPlugin.cs b/src/Exceptionless.Core/Plugins/WebHook/Default/020_VersionTwoPlugin.cs index 81a859181c..ae49c64905 100644 --- a/src/Exceptionless.Core/Plugins/WebHook/Default/020_VersionTwoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/WebHook/Default/020_VersionTwoPlugin.cs @@ -22,6 +22,7 @@ public VersionTwoPlugin(AppOptions options, ILoggerFactory loggerFactory) : base Tags = ev.Tags, Message = ev.Message, Type = ev.Type, + Environment = ev.Environment, Source = ev.Source, ProjectId = ev.ProjectId, ProjectName = ctx.Project.Name, diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs index 9c7b77b4cd..b60ff5e842 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs @@ -70,6 +70,8 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor e.ReferenceId) .FieldAlias(Alias.ReferenceId, a => a.Path(f => f.ReferenceId)) .Text(e => e.Type, t => t.Analyzer(LOWER_KEYWORD_ANALYZER).AddKeywordField()) + .Text(e => e.Environment, t => t.Analyzer(LOWER_KEYWORD_ANALYZER) + .Fields(fields => fields.Keyword("keyword", keyword => keyword.Normalizer("lowercase")))) .Text(e => e.Source, t => t.Analyzer(STANDARDPLUS_ANALYZER).SearchAnalyzer(WHITESPACE_LOWERCASE_ANALYZER).AddKeywordField()) .Date(e => e.Date) .Text(e => e.Message) @@ -260,6 +262,7 @@ private void BuildAnalysis(IndexSettingsAnalysisDescriptor ad) public sealed class Alias { + public const string Environment = "environment"; public const string OrganizationId = "organization"; public const string ProjectId = "project"; public const string StackId = "stack"; diff --git a/src/Exceptionless.Core/Repositories/Queries/EventEnvironmentFilter.cs b/src/Exceptionless.Core/Repositories/Queries/EventEnvironmentFilter.cs new file mode 100644 index 0000000000..68a4179655 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Queries/EventEnvironmentFilter.cs @@ -0,0 +1,49 @@ +using Foundatio.Parsers.LuceneQueries; +using Foundatio.Parsers.LuceneQueries.Nodes; + +namespace Exceptionless.Core.Repositories.Queries; + +/// +/// Preserves deployment scope when counting all project users, including users without matching errors. +/// +public static class EventEnvironmentFilter +{ + public static async Task GetAsync(string? filter) + { + if (String.IsNullOrWhiteSpace(filter)) + return null; + + return GetConstraint(await new LuceneQueryParser().ParseAsync(filter)); + } + + private static string? GetConstraint(IQueryNode? node, bool negate = false) + { + if (node is not IFieldQueryNode field) + return null; + + if (String.Equals(field.UnescapedField, "environment", StringComparison.OrdinalIgnoreCase)) + return negate ? $"NOT ({node})" : node.ToString(); + + if (field.Field is not null || node is not GroupNode group) + return null; + + negate ^= group.IsNegated == true || group.Prefix == "-"; + if (group.Left is null) + return GetConstraint(group.Right, negate); + if (group.Right is null) + return GetConstraint(group.Left, negate); + + string? left = GetConstraint(group.Left, negate); + string? right = GetConstraint(group.Right, negate); + bool isOr = (group.Operator == GroupOperator.Or) ^ negate; + // An unrelated OR branch permits every environment. Unrelated AND clauses do not restrict it. + if (isOr && (left is null || right is null)) + return null; + if (left is null) + return right; + if (right is null) + return left; + + return $"({left} {(isOr ? "OR" : "AND")} {right})"; + } +} diff --git a/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs b/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs index 5cd11109f5..23f74d0fd9 100644 --- a/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs +++ b/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs @@ -8,6 +8,7 @@ namespace Exceptionless.Core.Queries.Validation; public sealed class PersistentEventQueryValidator : AppQueryValidator { private static readonly HashSet _freeQueryFields = new(StringComparer.OrdinalIgnoreCase) { + EventIndex.Alias.Environment, "date", "type", EventIndex.Alias.ReferenceId, @@ -22,6 +23,7 @@ public sealed class PersistentEventQueryValidator : AppQueryValidator }; private static readonly HashSet _freeAggregationFields = new(StringComparer.OrdinalIgnoreCase) { + EventIndex.Alias.Environment, "date", "type", "value", @@ -36,6 +38,7 @@ public sealed class PersistentEventQueryValidator : AppQueryValidator }; private static readonly HashSet _allowedAggregationFields = new(StringComparer.OrdinalIgnoreCase) { + EventIndex.Alias.Environment, "date", "source", "tags", diff --git a/src/Exceptionless.Core/Serialization/EventEnvironmentConverter.cs b/src/Exceptionless.Core/Serialization/EventEnvironmentConverter.cs new file mode 100644 index 0000000000..9a6ff454d6 --- /dev/null +++ b/src/Exceptionless.Core/Serialization/EventEnvironmentConverter.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Serialization; + +/// +/// Ignores malformed optional deployment metadata without rejecting an event or its submission batch. +/// +public sealed class EventEnvironmentConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return reader.GetString(); + } + + reader.Skip(); + return null; + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } +} diff --git a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs index 05ad674605..26ab7a1726 100644 --- a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -910,6 +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("source", "query", Description: "The event source (ie. machine name, log name, feature name)."), new("message", "query", Description: "The event message."), new("reference", "query", Description: "An optional identifier to be used for referencing this event instance at a later time."), diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index a034c63691..fc0b87a0d2 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -472,6 +472,9 @@ public async Task Handle(SubmitEventByGet message) case "source": ev.Source = kvp.Value.FirstOrDefault(); break; + case "environment": + ev.Environment = kvp.Value.FirstOrDefault(); + break; case "message": ev.Message = kvp.Value.FirstOrDefault(); break; @@ -785,6 +788,7 @@ private async Task>> GetInternalAsync(AppFilter sf, T ProjectName = projectNames.GetValueOrDefault(e.ProjectId), Tags = e.Tags?.OfType().Order(StringComparer.OrdinalIgnoreCase).ToArray() ?? [], Type = e.Type, + Environment = e.Environment, Version = e.GetVersion(), Data = summaryData.Data }; @@ -833,7 +837,7 @@ private async Task>> GetInternalAsync(AppFilter sf, T string[] stackIds = stackTerms.Buckets.Skip(skip).Take(limit + 1).Select(t => t.Key).ToArray(); var stacks = (await stackRepository.GetByIdsAsync(stackIds)).Select(s => s.ApplyOffset(ti.Offset)).ToList(); - var stackSummaries = await GetStackSummariesAsync(stacks, stackTerms.Buckets, sf, ti); + var stackSummaries = await GetStackSummariesAsync(stacks, stackTerms.Buckets, sf, ti, filter); double? totalStackCount = countResponse.Aggregations.Cardinality("cardinality_stack_id")?.Value; long? total = includeTotal && totalStackCount.HasValue ? Convert.ToInt64(totalStackCount.Value) : null; @@ -915,14 +919,14 @@ private Task> GetEventsInternalAsync(AppFilter? sys : o.SearchBeforeToken(before, serializer).SearchAfterToken(after, serializer).PageLimit(limit).TrackTotalHits(includeTotal)); } - private async Task> GetStackSummariesAsync(List stacks, IReadOnlyCollection> stackTerms, AppFilter sf, TimeInfo ti) + private async Task> GetStackSummariesAsync(List stacks, IReadOnlyCollection> stackTerms, AppFilter sf, TimeInfo ti, string? filter) { if (stacks.Count == 0) return new List(0); var projects = await projectRepository.GetByIdsAsync(stacks.Select(s => s.ProjectId).Distinct().ToArray(), o => o.Cache()); var projectNames = projects.ToDictionary(p => p.Id, p => p.Name); - var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd); + var totalUsers = await GetUserCountByProjectIdsAsync(stacks, sf, ti.Range.UtcStart, ti.Range.UtcEnd, filter); return stacks.Join(stackTerms, s => s.Id, tk => tk.Key, (stack, term) => { var data = formattingPluginManager.GetStackSummaryData(stack); @@ -948,9 +952,10 @@ private async Task> GetStackSummariesAsync(List> GetUserCountByProjectIdsAsync(ICollection stacks, AppFilter sf, DateTime utcStart, DateTime utcEnd) + private async Task> GetUserCountByProjectIdsAsync(ICollection stacks, AppFilter sf, DateTime utcStart, DateTime utcEnd, string? filter) { - using var scopedCacheClient = new ScopedCacheClient(cacheClient, $"Project:user-count:{utcStart.Floor(TimeSpan.FromMinutes(15)).Ticks}-{utcEnd.Floor(TimeSpan.FromMinutes(15)).Ticks}"); + filter = await EventEnvironmentFilter.GetAsync(filter); + using var scopedCacheClient = new ScopedCacheClient(cacheClient, $"Project:user-count:{utcStart.Floor(TimeSpan.FromMinutes(15)).Ticks}-{utcEnd.Floor(TimeSpan.FromMinutes(15)).Ticks}:{filter?.ToSHA1()}"); var projectIds = stacks.Select(s => s.ProjectId).Distinct().ToList(); var cachedTotals = await scopedCacheClient.GetAllAsync(projectIds); @@ -958,7 +963,7 @@ private async Task> GetUserCountByProjectIdsAsync(ICo if (totals.Count == projectIds.Count) return totals; - var systemFilter = new RepositoryQuery().AppFilter(sf).DateRange(utcStart, utcEnd, (PersistentEvent e) => e.Date).Index(utcStart, utcEnd); + var systemFilter = new RepositoryQuery().AppFilter(sf).FilterExpression(filter).EnforceEventStackFilter().DateRange(utcStart, utcEnd, (PersistentEvent e) => e.Date).Index(utcStart, utcEnd); var projects = cachedTotals .Where(kvp => !kvp.Value.HasValue && stacks.Contains(s => s.ProjectId == kvp.Key)) .Select(kvp => new Project { Id = kvp.Key, OrganizationId = stacks.First(s => s.ProjectId == kvp.Key).OrganizationId }) diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 5844adad6f..1a829fd7b5 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -135,7 +135,7 @@ export class ExceptionlessE2EJourney { await expect(this.page.getByRole('tab', { name: 'Overview' })).toBeVisible(); await expect(this.page.getByRole('tab', { name: 'Exception' })).toBeVisible(); await expect(this.page.getByRole('tab', { name: 'Request' })).toBeVisible(); - await expect(this.page.getByRole('tab', { name: 'Environment' })).toBeVisible(); + await expect(this.page.getByRole('tab', { name: 'Machine & runtime' })).toBeVisible(); await expect(this.page.getByRole('tab', { name: 'Extended Data' })).toBeVisible(); await expect(getVisibleRow(this.page, new RegExp(`^Reference\\s+${escapeRegExp(this.referenceId)}$`))).toBeVisible(); @@ -152,7 +152,7 @@ export class ExceptionlessE2EJourney { await expect(getVisibleRow(this.page, 'URL', '/e2e/onboarding')).toBeVisible(); await expect(getVisibleRow(this.page, 'User Agent', 'Exceptionless Playwright E2E')).toBeVisible(); - await this.page.getByRole('tab', { name: 'Environment' }).click(); + await this.page.getByRole('tab', { name: 'Machine & runtime' }).click(); await expect(getVisibleRow(this.page, 'Machine Name', 'playwright-runner')).toBeVisible(); await expect(getVisibleRow(this.page, 'Process Name', 'e2e-tests')).toBeVisible(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-environments.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-environments.e2e.ts new file mode 100644 index 0000000000..b8dcac268b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-environments.e2e.ts @@ -0,0 +1,120 @@ +import { createReferenceId, expect, test } from '../fixtures/e2e-test'; +import { getVisibleText } from '../support/page-helpers'; + +test('environment filters include unspecified events and persist in saved views', async ({ e2eApi, e2eScenario, page }) => { + const environments = ['production', 'staging', undefined]; + const messages = environments.map((environment) => `Environment ${environment ?? 'unspecified'} ${e2eScenario.run}`); + for (const [index, environment] of environments.entries()) { + const referenceId = createReferenceId(e2eScenario.run, `-env-${index}`); + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, { + environment, + message: messages[index], + reference_id: referenceId, + source: 'environment-filter-test', + type: 'log' + }); + await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, referenceId); + } + + await page.goto(`/next/event?project=${e2eScenario.projectId}&time=all`); + for (const message of messages) await expect(getVisibleText(page, message)).toBeVisible({ timeout: 30_000 }); + + await page.getByRole('button', { name: 'Manage filters' }).click(); + await page.getByPlaceholder('Search...').fill('Environment'); + await page.getByText('Environment', { exact: true }).click(); + await page.getByRole('option', { exact: true, name: 'production' }).click(); + await page.keyboard.press('Escape'); + await expect(getVisibleText(page, messages[0]!)).toBeVisible(); + await expect(getVisibleText(page, messages[1]!)).toBeHidden(); + await expect(getVisibleText(page, messages[2]!)).toBeHidden(); + + await page.getByRole('button', { name: /^Environment\s+production/ }).click(); + await page.getByRole('option', { exact: true, name: 'Unspecified' }).click(); + await page.keyboard.press('Escape'); + await expect(getVisibleText(page, messages[2]!)).toBeVisible(); + await expect(getVisibleText(page, messages[1]!)).toBeHidden(); + + const viewName = `Environments ${e2eScenario.run.slice(-24)}`; + await page.getByRole('button', { name: /^View/ }).filter({ visible: true }).first().click(); + await page.getByRole('menuitem', { name: 'Save As...' }).click(); + const dialog = page.getByRole('dialog', { name: 'Save View' }); + await dialog.getByLabel('Name', { exact: true }).fill(viewName); + await expect(dialog.getByLabel('Name', { exact: true })).toHaveValue(viewName); + const savedResponse = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/saved-views')); + await dialog.getByRole('button', { name: 'Save' }).click(); + const response = await savedResponse; + expect(response.ok(), await response.text()).toBe(true); + await expect(dialog).toBeHidden(); + await expect(page.getByRole('heading', { name: viewName })).toBeVisible(); + await page.reload(); + await expect(getVisibleText(page, messages[0]!)).toBeVisible(); + await expect(getVisibleText(page, messages[2]!)).toBeVisible(); + await expect(getVisibleText(page, messages[1]!)).toBeHidden(); + + await page.getByRole('button', { name: /^Environment\s/ }).click(); + await page.getByPlaceholder('Environment', { exact: true }).fill(' Preview-West '); + await page.getByRole('option', { name: 'Use preview-west' }).click(); + await page.keyboard.press('Escape'); + await page.reload(); + await page.getByRole('button', { name: /^Environment\s/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'preview-west' })).toBeVisible(); + await page.getByRole('button', { name: 'Remove filter' }).click(); + for (const message of messages) await expect(getVisibleText(page, message)).toBeVisible(); + await page.reload(); + for (const message of messages) await expect(getVisibleText(page, message)).toBeVisible(); +}); + +test('stack, session, and stream filters retain names with no current events', async ({ e2eScenario, page }) => { + for (const route of ['stack', 'sessions', 'stream']) { + await page.goto(`/next/${route}?project=${e2eScenario.projectId}`); + 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.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 page.reload(); + await expect(page.getByRole('button', { name: /^Environment\s+qa,east/ })).toBeVisible(); + } +}); + +test('environment choices use the saved view time range', async ({ e2eScenario, page, request }) => { + const time = '[now-7d TO now]'; + for (const [route, viewType] of [ + ['event', 'events'], + ['stack', 'stacks'], + ['sessions', 'sessions'] + ]) { + const slug = `environment-time-${route}-${e2eScenario.run.slice(-20)}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const name = `Environment time ${route}`; + const response = await request.post(`/api/v2/organizations/${e2eScenario.organizationId}/saved-views`, { + data: { + filter: `project:${e2eScenario.projectId}`, + name, + organization_id: e2eScenario.organizationId, + slug, + time, + view_type: viewType + }, + headers: { Authorization: `Bearer ${e2eScenario.userToken}` } + }); + expect(response.status(), await response.text()).toBe(201); + await page.goto(`/next/${route}/${slug}`); + await expect(page.getByRole('heading', { exact: true, name })).toBeVisible(); + expect(new URL(page.url()).searchParams.get('time')).toBeNull(); + await page.getByRole('button', { name: 'Manage filters' }).click(); + await page.getByPlaceholder('Search...').fill('Environment'); + const facetRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return url.pathname.endsWith('/events/count') && url.searchParams.get('aggregations') === 'terms:(environment~100)'; + }); + await page.getByText('Environment', { exact: true }).click(); + expect(new URL((await facetRequest).url()).searchParams.get('time')).toBe(time); + await page.keyboard.press('Escape'); + } +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts index 09f93c49b7..d25b850e51 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts @@ -115,7 +115,7 @@ test('operator can find and inspect a user session', async ({ e2eApi, e2eScenari await page.getByRole('tab', { name: 'Exception' }).click(); await expectWrappedMessageWithoutMeaningfulOverflow(); - await page.getByRole('tab', { name: 'Environment' }).click(); + await page.getByRole('tab', { name: 'Machine & runtime' }).click(); const machineNameRow = activePanel().getByRole('row').filter({ hasText: 'Machine Name' }); await expect(machineNameRow).toBeVisible(); await expect diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 8bd8a218c6..eac53884de 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -429,7 +429,7 @@ ondrop={(event) => handlePromotedTabDrop(event, tab)} ondragend={handlePromotedTabDragEnd} title={isPromotedTab(tab) ? 'Drag to reorder custom tab' : undefined} - value={tab}>{tab}{tab === 'Environment' ? 'Machine & runtime' : tab} {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-builder.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-builder.svelte new file mode 100644 index 0000000000..6eeaa924c3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-builder.svelte @@ -0,0 +1,14 @@ + 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 new file mode 100644 index 0000000000..0e42cc6183 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter-trigger.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte new file mode 100644 index 0000000000..dc94151cbe --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-faceted-filter.svelte @@ -0,0 +1,79 @@ + + + { + filter.value = values; + filterChanged(filter); + }} + {createOption} + emptyText="All environments" + hidden={filter.hidden} + loading={countQuery.isLoading} + {options} + remove={() => filterRemoved(filter)} + {title} + toggleHidden={() => { + filter.hidden = !filter.hidden; + filterChanged(filter); + }} + values={filter.value} +/> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-filter.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-filter.svelte.test.ts new file mode 100644 index 0000000000..47ec84589c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/environment-filter.svelte.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { filterUsesPremiumFeatures } from '../../premium-filter'; +import { deserializeFilters, serializeFilters, toFilter } from './helpers.svelte'; +import { EnvironmentFilter, ProjectFilter } from './models.svelte'; + +describe('EnvironmentFilter', () => { + it('normalizes names and supports all, multiple, and unspecified environments', () => { + expect(new EnvironmentFilter().toFilter()).toBe(''); + expect(new EnvironmentFilter([' Production ', 'production']).toFilter()).toBe('environment:production'); + expect(new EnvironmentFilter(['production', 'staging']).toFilter()).toBe('(environment:production OR environment:staging)'); + expect(new EnvironmentFilter(['']).toFilter()).toBe('_missing_:environment'); + expect(new EnvironmentFilter(['production', '']).toFilter()).toBe('(environment:production OR _missing_:environment)'); + }); + + it('quotes custom values and combines the environment choice with other filters', () => { + expect(toFilter([new ProjectFilter(['project-1']), new EnvironmentFilter(['qa west'])])).toContain('environment:"qa west"'); + expect(new EnvironmentFilter(['qa:west']).toFilter()).toBe('environment:"qa:west"'); + const name = '"qa" OR _exists_:message'; + expect(new EnvironmentFilter([name]).toFilter()).toBe(`environment:${JSON.stringify(name.toLowerCase())}`); + }); + + it('preserves selections and hidden state through saved-view and URL serialization', () => { + const filter = new EnvironmentFilter(['production', '']); + filter.hidden = true; + const restored = deserializeFilters(serializeFilters([filter])); + expect(restored[0]).toBeInstanceOf(EnvironmentFilter); + expect(restored[0]?.hidden).toBe(true); + expect(toFilter(restored)).toBe(filter.toFilter()); + const clone = filter.clone(); + clone.value.push('staging'); + expect(filter.value).toEqual(['production', '']); + }); + + it('allows environment searches on free plans and keeps custom data premium', () => { + for (const resource of ['event', 'event-stack'] as const) { + expect(filterUsesPremiumFeatures('environment:production', resource)).toBe(false); + expect(filterUsesPremiumFeatures('_missing_:environment', resource)).toBe(false); + expect(filterUsesPremiumFeatures('environment:production data.customer:123', resource)).toBe(true); + } + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts index f23e3a37c9..0443ac2a12 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts @@ -9,6 +9,7 @@ import { SvelteMap } from 'svelte/reactivity'; import { BooleanFilter, DateFilter, + EnvironmentFilter, KeywordFilter, LevelFilter, NumberFilter, @@ -296,6 +297,9 @@ function reconstructFilter(data: SerializedFilter): IFilter | null { case 'date': filter = new DateFilter(data.term, data.value as Date | string | undefined); break; + case 'environment': + filter = new EnvironmentFilter(Array.isArray(data.value) ? data.value.filter((value): value is string => typeof value === 'string') : []); + break; case 'keyword': filter = new KeywordFilter(data.value as string | undefined); break; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/index.ts index 8812a07dbe..01dbde7f0a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/index.ts @@ -4,6 +4,8 @@ import BooleanFacetedFilter from './boolean-faceted-filter.svelte'; import DateFacetedFilterBuilder from './date-faceted-filter-builder.svelte'; import DateFacetedFilterTrigger from './date-faceted-filter-trigger.svelte'; import DateFacetedFilter from './date-faceted-filter.svelte'; +import EnvironmentFacetedFilterBuilder from './environment-faceted-filter-builder.svelte'; +import EnvironmentFacetedFilterTrigger from './environment-faceted-filter-trigger.svelte'; import KeywordFacetedFilterBuilder from './keyword-faceted-filter-builder.svelte'; import KeywordFacetedFilter from './keyword-faceted-filter.svelte'; import LevelFacetedFilterBuilder from './level-faceted-filter-builder.svelte'; @@ -50,6 +52,8 @@ export { DateFacetedFilterBuilder, DateFacetedFilterTrigger, DateFacetedFilterTrigger as DateTrigger, + EnvironmentFacetedFilterBuilder as EnvironmentBuilder, + EnvironmentFacetedFilterTrigger as EnvironmentTrigger, KeywordFacetedFilter as Keyword, KeywordFacetedFilterBuilder as KeywordBuilder, KeywordFacetedFilter, @@ -119,6 +123,7 @@ export { export { BooleanFilter, DateFilter, + EnvironmentFilter, KeywordFilter, LevelFilter, NumberFilter, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts index ce9d78ac2b..879e465750 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts @@ -3,6 +3,8 @@ import type { PersistentEventKnownTypes } from '$features/events/models'; import type { LogLevel } from '$features/events/models/event-data'; import type { StackStatus } from '$features/stacks/models'; +import { SvelteSet } from 'svelte/reactivity'; + import { quoteIfSpecialCharacters } from './helpers.svelte'; export class BooleanFilter implements IFilter { @@ -80,6 +82,32 @@ export class DateFilter implements IFilter { } } +export class EnvironmentFilter implements IFilter { + public hidden = $state(false); + public id: string = crypto.randomUUID(); + public readonly key = 'environment'; + public readonly type = 'environment'; + public value = $state([]); + + constructor(value: string[] = []) { + this.value = [...new SvelteSet(value.map((name) => name.trim().toLowerCase()))]; + } + + public clone(): EnvironmentFilter { + const filter = new EnvironmentFilter([...this.value]); + filter.hidden = this.hidden; + filter.id = this.id; + return filter; + } + + public toFilter(): string { + const clauses = this.value.map((value) => + value === '' ? '_missing_:environment' : `environment:${/^[a-z0-9][a-z0-9_.-]*$/.test(value) ? value : JSON.stringify(value)}` + ); + return clauses.length > 1 ? `(${clauses.join(' OR ')})` : (clauses[0] ?? ''); + } +} + export class KeywordFilter implements IFilter { public hidden = $state(false); public id: string = crypto.randomUUID(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/organization-defaults-faceted-filter-builder.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/organization-defaults-faceted-filter-builder.svelte index 844b446f73..4d909536f8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/organization-defaults-faceted-filter-builder.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/organization-defaults-faceted-filter-builder.svelte @@ -230,6 +230,7 @@ {/each} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts index 257536b972..3fb60851a2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts @@ -61,6 +61,7 @@ export interface EventSummaryData { export interface EventSummaryModel extends SummaryModel { /** @format date-time */ date: string; + environment?: string; project_id: string; project_name?: string; tags: string[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte new file mode 100644 index 0000000000..e257dd9c02 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/event-environment-cell.svelte @@ -0,0 +1,17 @@ + + +{#if changed} + +{:else} + {environment ?? 'Unspecified'} +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index 1775dde949..bd6a99bb5c 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 @@ -11,12 +11,14 @@ import type { EventSummaryModel, StackSummaryModel, SummaryModel, SummaryTemplat import LogLevel from '../log-level.svelte'; import Summary from '../summary/summary.svelte'; +import EventEnvironmentCell from './event-environment-cell.svelte'; import EventTagsSummaryCell from './event-tags-summary-cell.svelte'; import EventsUserIdentitySummaryCell from './events-user-identity-summary-cell.svelte'; import StackStatusCell from './stack-status-cell.svelte'; import StackUsersSummaryCell from './stack-users-summary-cell.svelte'; export const defaultEventColumnVisibility: ColumnVisibilityState = { + environment: false, exception_type: false, level: false, message: false, @@ -35,7 +37,7 @@ export const defaultStackColumnVisibility: ColumnVisibilityState = { export function getColumns>( mode: GetEventsMode = 'summary', - options?: { onTagClick?: (tag: string) => Promise | void; showType?: boolean } + options?: { onEnvironmentClick?: (environment: string) => Promise | void; onTagClick?: (tag: string) => Promise | void; showType?: boolean } ): ColumnDef[] { const showType = options?.showType ?? true; const columns: ColumnDef[] = [ @@ -182,6 +184,19 @@ export function getColumns>('environment'), + cell: (prop) => + renderComponent(EventEnvironmentCell, { + changed: options?.onEnvironmentClick, + environment: prop.getValue() + }), + header: 'Environment', + id: 'environment', + maxSize: 640, + minSize: 96, + size: 144 + }, { accessorKey: nameof>('version'), cell: (prop) => formatTextColumn(prop.getValue()), 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 b51cc07c65..11bdd2486c 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 @@ -73,6 +73,11 @@ + + Environment + + {event.environment ?? 'Unspecified'} + {#if isSessionStart} Duration diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts index c29cce9862..febd37d6ba 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts @@ -3,6 +3,7 @@ export type SearchResource = 'event' | 'event-stack' | 'stack'; // Alias and indexed-field variants intentionally mirror the backend validators. const EVENT_FREE_QUERY_FIELDS = new Set([ 'date', + 'environment', 'organization', 'organization_id', 'project', 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 8bc9cdbfae..4d7a84edb6 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 @@ -8,7 +8,7 @@ import { type ColumnDef, type ColumnVisibilityState, renderComponent, type Stock import SessionDurationCell from './session-duration-cell.svelte'; -export const defaultSessionColumnVisibility: ColumnVisibilityState = {}; +export const defaultSessionColumnVisibility: ColumnVisibilityState = { environment: false }; export function getSessionColumns(): ColumnDef, unknown>[] { return [ @@ -74,6 +74,15 @@ export function getSessionColumns(): ColumnDef prop.getValue() ?? 'Unspecified', + header: 'Environment', + id: 'environment', + maxSize: 640, + minSize: 96, + size: 144 + }, { accessorFn: (row) => row.date, cell: (prop) => renderComponent(TimeAgo, { value: prop.getValue() }), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte index be99e2e38a..a12adb830e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte @@ -12,19 +12,31 @@ import Eye from '@lucide/svelte/icons/eye'; import EyeOff from '@lucide/svelte/icons/eye-off'; import { computeCommandScore } from 'bits-ui'; + import { setContext } from 'svelte'; import type { FacetedFilter, IFilter } from './models'; import { builderContext, type FacetFilterBuilder } from './faceted-filter-builder-context.svelte'; + import { type FilterScope, filterScopeKey } from './filter-scope'; interface Props { changed: (filter: IFilter) => void; children?: Snippet; filters: IFilter[]; remove: (filter?: IFilter) => void; + time?: null | string; } - let { changed, children, filters, remove }: Props = $props(); + let { changed, children, filters, remove, time }: Props = $props(); + + setContext(filterScopeKey, { + get filters() { + return filters; + }, + get time() { + return time; + } + }); const CREATE_KEYWORD_FILTER_COMMAND_ITEM = 'CREATE_KEYWORD_FILTER_COMMAND_ITEM'; let open = $state(false); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte index 83dacc93e3..7c6ae28999 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte @@ -15,6 +15,8 @@ interface Props { changed: (values: string[]) => void; + createOption?: (search: string) => Option | undefined; + emptyText?: string; hidden?: boolean; loading?: boolean; noOptionsText?: string; @@ -28,6 +30,8 @@ let { changed, + createOption, + emptyText = 'No Value', hidden = false, loading = false, noOptionsText = 'No results found.', @@ -39,6 +43,10 @@ values }: Props = $props(); + let search = $state(''); + const customOption = $derived(createOption?.(search)); + const showCustomOption = $derived(customOption && !options.some((option) => option.value === customOption.value)); + // eslint-disable-next-line svelte/prefer-writable-derived let updatedValues = $state([]); let displayValues = $derived.by(() => { @@ -79,6 +87,8 @@ } function filter(value: string, search: string) { + search = search.trim().toLowerCase(); + value = value.toLowerCase(); if (value.includes(search)) { return 1; } @@ -107,23 +117,30 @@ {/snippet} {:else} - No Value + {emptyText} {/if} {/snippet} e.preventDefault()}> - + {noOptionsText} + {#if showCustomOption && customOption} + + customOption && onValueSelected(customOption.value)}> + Use {customOption.label} + + + {/if} {#if loading}
Loading...
{/if} {#if options.length > 0} {#each options as option (option.value)} - onValueSelected(option.value)} value={option.value}> + onValueSelected(option.value)} value={option.value || option.label}>
; + /** + * 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. + */ + environment?: null | string; /** * The event type (ie. error, log message, feature usage). Check 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.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index ec58c99678..055d423467 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -568,6 +568,11 @@ export const PersistentEventSchema = object({ is_first_occurrence: boolean(), created_utc: iso.datetime(), idx: record(string(), unknown()).nullable().optional(), + environment: string() + .min(1, "Environment is required") + .max(64, "Environment must be at most 64 characters") + .nullable() + .optional(), type: string() .min(1, "Type is required") .max(100, "Type must be at most 100 characters") diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index f74ef8aec2..0b0f9b75ea 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -18,6 +18,7 @@ import { BooleanFilter, DateFilter, + EnvironmentFilter, LevelFilter, ProjectFilter, ReferenceFilter, @@ -69,6 +70,7 @@ import { ALL_TIME_QUERY_VALUE, + deserializeEnvironmentQueryParam, deserializeTimeQueryParam, getEventsNavigationOptionsForFilter, getListFilterQueryParams, @@ -102,6 +104,7 @@ after: undefined as string | undefined, before: undefined as string | undefined, bot: undefined as string | undefined, + environment: undefined as string | undefined, filter: undefined as string | undefined, first: undefined as string | undefined, level: undefined as string | undefined, @@ -161,6 +164,10 @@ filters.push(new BooleanFilter('first', first)); } + if (params.environment) { + filters.push(new EnvironmentFilter(deserializeEnvironmentQueryParam(params.environment))); + } + if (params.level) { filters.push(new LevelFilter(splitQueryParam(params.level) as never[])); } @@ -227,6 +234,7 @@ after: 'string', before: 'string', bot: 'string', + environment: 'string', filter: 'string', first: 'string', level: 'string', @@ -375,6 +383,10 @@ removedKeys.push(...savedViewFilters.filter((filter) => filter.type !== 'date' && !isQueryParamFilter(filter)).map((filter) => filter.key)); } + if (params.environment === '') { + removedKeys.push('environment'); + } + if (params.level === '') { removedKeys.push('level'); } @@ -503,6 +515,7 @@ newTimeParam !== queryParams.time || queryFilterParams.bot !== queryParams.bot || queryFilterParams.first !== queryParams.first || + queryFilterParams.environment !== queryParams.environment || queryFilterParams.level !== queryParams.level || queryFilterParams.project !== queryParams.project || queryFilterParams.reference !== queryParams.reference || @@ -533,6 +546,7 @@ after: shouldClearPaginationForFilter ? null : queryParams.after, before: shouldClearPaginationForFilter ? null : queryParams.before, bot: queryFilterParams.bot, + environment: queryFilterParams.environment, filter: newFilterParam, first: queryFilterParams.first, level: queryFilterParams.level, @@ -572,6 +586,7 @@ function getQueryFilterParams(filters: FacetedFilter.IFilter[]) { const botFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'bot'); const firstFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'first'); + const environmentFilter = filters.find((filter): filter is EnvironmentFilter => filter.type === 'environment'); const levelFilter = filters.find((f): f is LevelFilter => f.type === 'level'); const projectFilter = filters.find((f): f is ProjectFilter => f.type === 'project'); const referenceFilter = filters.find((f): f is ReferenceFilter => f.type === 'reference'); @@ -584,6 +599,7 @@ return { bot: botFilter?.value === undefined ? null : String(botFilter.value), + environment: environmentFilter?.value.length ? JSON.stringify(environmentFilter.value) : null, first: firstFilter?.value === undefined ? null : String(firstFilter.value), level: levelFilter?.value.length ? levelFilter.value.join(',') : null, project: projectFilter?.value.length ? projectFilter.value.join(',') : null, @@ -608,6 +624,7 @@ return { bot: getDelta(currentParams.bot, baseParams.bot), + environment: getDelta(currentParams.environment, baseParams.environment), first: getDelta(currentParams.first, baseParams.first), level: getDelta(currentParams.level, baseParams.level), project: getDelta(currentParams.project, baseParams.project), @@ -634,7 +651,7 @@ return false; } - return ['level', 'project', 'reference', 'session', 'status', 'tag', 'type', 'version'].includes(filter.type); + return ['environment', 'level', 'project', 'reference', 'session', 'status', 'tag', 'type', 'version'].includes(filter.type); } function getPageSize(): number { @@ -728,6 +745,7 @@ columnPersistenceKey: 'events-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onEnvironmentClick: (environment) => onFilterChanged(new EnvironmentFilter([environment])), onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }); @@ -961,7 +979,7 @@

{pageTitle}

- +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts index 7d4148e1c9..ff6504537a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts @@ -1,4 +1,4 @@ -import { DateFilter, ProjectFilter, StatusFilter, StringFilter } from '$features/events/components/filters/models.svelte'; +import { DateFilter, EnvironmentFilter, ProjectFilter, StatusFilter, StringFilter } from '$features/events/components/filters/models.svelte'; import { describe, expect, it, vi } from 'vitest'; vi.mock('$app/navigation', () => ({ @@ -10,6 +10,14 @@ vi.mock('$app/paths', () => ({ })); describe('redirect-to-events', () => { + it('preserves environment values including commas and unspecified through navigation', async () => { + const { buildListPageHref, deserializeEnvironmentQueryParam } = await import('./redirect-to-events.svelte'); + const names = ['production', '', 'qa,east']; + const url = new URL(buildListPageHref('events', 'organization-1', [new EnvironmentFilter(names)]), 'http://localhost'); + expect(deserializeEnvironmentQueryParam(url.searchParams.get('environment')!)).toEqual(names); + expect(url.searchParams.has('filter')).toBe(false); + expect(deserializeEnvironmentQueryParam('staging')).toEqual(['staging']); + }); it('snapshots list filter query parameters from shared reactive state', async () => { // Arrange const { getListFilterQueryParams } = await import('./redirect-to-events.svelte'); @@ -55,6 +63,7 @@ describe('redirect-to-events', () => { // Assert expect(queryParams).toEqual({ bot: null, + environment: null, filter: null, first: null, level: null, diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts index 3cf98ec3ce..bc5ac481df 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts @@ -15,6 +15,7 @@ export const ALL_TIME_QUERY_VALUE = 'all'; const LIST_FILTER_QUERY_PARAM_NAMES = [ 'bot', + 'environment', 'filter', 'first', 'level', @@ -66,6 +67,18 @@ export const LIST_FILTER_QUERY_PARAM_RESET = Object.fromEntries(LIST_FILTER_QUER null >; +export function deserializeEnvironmentQueryParam(value: string): string[] { + try { + const values: unknown = JSON.parse(value); + if (Array.isArray(values)) { + return values.filter((name): name is string => typeof name === 'string'); + } + } catch { + // Single names are also accepted in hand-written URLs. + } + return [value]; +} + export function deserializeTimeQueryParam(time: string): string { const trimmed = time.trim(); const shortcutMatch = TIME_SHORTCUT_PATTERN.exec(trimmed); @@ -101,6 +114,7 @@ export function getEventsNavigationOptionsForFilter(filter: IFilter): ListNaviga export function getListFilterQueryParams(source: ListFilterQueryParams): ListFilterQueryParamSnapshot { return { bot: source.bot ?? null, + environment: source.environment ?? null, filter: source.filter ?? null, first: source.first ?? null, level: source.level ?? null, @@ -157,6 +171,11 @@ function trySetRegisteredFilterQueryParam(queryParams: SvelteURLSearchParams, fi return true; } + if (filter.type === 'environment' && 'value' in filter && Array.isArray(filter.value) && filter.value.length > 0) { + queryParams.set('environment', JSON.stringify(filter.value)); + return true; + } + if (filter.type === 'project' && 'value' in filter && Array.isArray(filter.value) && filter.value.length > 0) { queryParams.set('project', filter.value.join(',')); return true; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte index a8b9180868..8e333099c1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte @@ -15,6 +15,7 @@ import { BooleanFilter, DateFilter, + EnvironmentFilter, LevelFilter, ProjectFilter, ReferenceFilter, @@ -62,6 +63,7 @@ import { ALL_TIME_QUERY_VALUE, + deserializeEnvironmentQueryParam, deserializeTimeQueryParam, getListFilterQueryParams, LIST_FILTER_QUERY_PARAM_RESET, @@ -82,6 +84,7 @@ after: undefined as string | undefined, before: undefined as string | undefined, bot: undefined as string | undefined, + environment: undefined as string | undefined, filter: undefined as string | undefined, filters: undefined as string | undefined, first: undefined as string | undefined, @@ -163,6 +166,10 @@ queryFilters.push(new BooleanFilter('first', first)); } + if (params.environment) { + queryFilters.push(new EnvironmentFilter(deserializeEnvironmentQueryParam(params.environment))); + } + if (params.level) { queryFilters.push(new LevelFilter(splitQueryParam(params.level) as never[])); } @@ -223,6 +230,7 @@ after: 'string', before: 'string', bot: 'string', + environment: 'string', filter: 'string', filters: 'string', first: 'string', @@ -383,6 +391,10 @@ removedKeys.push('boolean-first'); } + if (params.environment === '') { + removedKeys.push('environment'); + } + if (params.level === '') { removedKeys.push('level'); } @@ -518,6 +530,7 @@ newTimeParam !== queryParams.time || queryFilterParams.bot !== queryParams.bot || queryFilterParams.first !== queryParams.first || + queryFilterParams.environment !== queryParams.environment || queryFilterParams.level !== queryParams.level || queryFilterParams.project !== queryParams.project || queryFilterParams.reference !== queryParams.reference || @@ -544,6 +557,7 @@ after: shouldClearPaginationForFilter ? null : queryParams.after, before: shouldClearPaginationForFilter ? null : queryParams.before, bot: queryFilterParams.bot, + environment: queryFilterParams.environment, filter: null, filters: newFiltersParam, first: queryFilterParams.first, @@ -584,6 +598,7 @@ function getQueryFilterParams(currentFilters: FacetedFilter.IFilter[]) { const botFilter = currentFilters.find((filter): filter is BooleanFilter => filter instanceof BooleanFilter && filter.term === 'bot'); const firstFilter = currentFilters.find((filter): filter is BooleanFilter => filter instanceof BooleanFilter && filter.term === 'first'); + const environmentFilter = currentFilters.find((filter): filter is EnvironmentFilter => filter.type === 'environment'); const levelFilter = currentFilters.find((filter): filter is LevelFilter => filter.type === 'level'); const projectFilter = currentFilters.find((filter): filter is ProjectFilter => filter.type === 'project'); const referenceFilter = currentFilters.find((filter): filter is ReferenceFilter => filter.type === 'reference'); @@ -595,6 +610,7 @@ return { bot: botFilter?.value === undefined ? null : String(botFilter.value), + environment: environmentFilter?.value.length ? JSON.stringify(environmentFilter.value) : null, first: firstFilter?.value === undefined ? null : String(firstFilter.value), level: levelFilter?.value.length ? levelFilter.value.join(',') : null, project: projectFilter?.value.length ? projectFilter.value.join(',') : null, @@ -617,6 +633,7 @@ return { bot: getDelta(currentParams.bot, baseParams.bot), + environment: getDelta(currentParams.environment, baseParams.environment), first: getDelta(currentParams.first, baseParams.first), level: getDelta(currentParams.level, baseParams.level), project: getDelta(currentParams.project, baseParams.project), @@ -641,7 +658,7 @@ if (filter.type === 'version' && filter instanceof VersionFilter && filter.term !== 'version') { return false; } - return ['level', 'project', 'reference', 'session', 'status', 'tag', 'version'].includes(filter.type); + return ['environment', 'level', 'project', 'reference', 'session', 'status', 'tag', 'version'].includes(filter.type); } const viewActive = $derived( @@ -896,7 +913,7 @@

{pageTitle}

- +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index b14ea3a0e2..90269d5ac3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -20,6 +20,7 @@ import { BooleanFilter, DateFilter, + EnvironmentFilter, LevelFilter, ProjectFilter, ReferenceFilter, @@ -68,6 +69,7 @@ import { ALL_TIME_QUERY_VALUE, + deserializeEnvironmentQueryParam, deserializeTimeQueryParam, getEventsNavigationOptionsForFilter, getListFilterQueryParams, @@ -107,6 +109,7 @@ const pageSizePreference = createPageSizePreference(PAGE_SIZE_PREFERENCE_KEY); const DEFAULT_PARAMS = { bot: undefined as string | undefined, + environment: undefined as string | undefined, filter: undefined as string | undefined, first: undefined as string | undefined, level: undefined as string | undefined, @@ -165,6 +168,10 @@ filters.push(new BooleanFilter('first', first)); } + if (params.environment) { + filters.push(new EnvironmentFilter(deserializeEnvironmentQueryParam(params.environment))); + } + if (params.level) { filters.push(new LevelFilter(splitQueryParam(params.level) as never[])); } @@ -221,6 +228,7 @@ history: 'push', schema: { bot: 'string', + environment: 'string', filter: 'string', first: 'string', level: 'string', @@ -366,6 +374,10 @@ removedKeys.push(...savedViewFilters.filter((filter) => filter.type !== 'date' && !isQueryParamFilter(filter)).map((filter) => filter.key)); } + if (params.environment === '') { + removedKeys.push('environment'); + } + if (params.level === '') { removedKeys.push('level'); } @@ -499,6 +511,7 @@ newTimeParam !== queryParams.time || queryFilterParams.bot !== queryParams.bot || queryFilterParams.first !== queryParams.first || + queryFilterParams.environment !== queryParams.environment || queryFilterParams.level !== queryParams.level || queryFilterParams.project !== queryParams.project || queryFilterParams.reference !== queryParams.reference || @@ -527,6 +540,7 @@ queryParams.update( { bot: queryFilterParams.bot, + environment: queryFilterParams.environment, filter: newFilterParam, first: queryFilterParams.first, level: queryFilterParams.level, @@ -566,6 +580,7 @@ function getQueryFilterParams(filters: FacetedFilter.IFilter[]) { const botFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'bot'); const firstFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'first'); + const environmentFilter = filters.find((filter): filter is EnvironmentFilter => filter.type === 'environment'); const levelFilter = filters.find((f): f is LevelFilter => f.type === 'level'); const projectFilter = filters.find((f): f is ProjectFilter => f.type === 'project'); const referenceFilter = filters.find((f): f is ReferenceFilter => f.type === 'reference'); @@ -578,6 +593,7 @@ return { bot: botFilter?.value === undefined ? null : String(botFilter.value), + environment: environmentFilter?.value.length ? JSON.stringify(environmentFilter.value) : null, first: firstFilter?.value === undefined ? null : String(firstFilter.value), level: levelFilter?.value.length ? levelFilter.value.join(',') : null, project: projectFilter?.value.length ? projectFilter.value.join(',') : null, @@ -602,6 +618,7 @@ return { bot: getDelta(currentParams.bot, baseParams.bot), + environment: getDelta(currentParams.environment, baseParams.environment), first: getDelta(currentParams.first, baseParams.first), level: getDelta(currentParams.level, baseParams.level), project: getDelta(currentParams.project, baseParams.project), @@ -628,7 +645,7 @@ return false; } - return ['level', 'project', 'reference', 'session', 'status', 'tag', 'type', 'version'].includes(filter.type); + return ['environment', 'level', 'project', 'reference', 'session', 'status', 'tag', 'type', 'version'].includes(filter.type); } function getPageSize(): number { @@ -864,7 +881,7 @@

{pageTitle}

- +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index bc30777ca3..8e3be234eb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -11,9 +11,10 @@ import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import { PERSISTENT_EVENT_DELETE_RECONCILE_EVENT } from '$features/events/api.svelte'; import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; - import { ProjectFilter, StatusFilter, TagFilter } from '$features/events/components/filters'; + import { EnvironmentFilter, ProjectFilter, StatusFilter, TagFilter } from '$features/events/components/filters'; import { buildFilterCacheKey, + deserializeFilters, filterChanged, filterRemoved, getFiltersFromCache, @@ -60,6 +61,7 @@ const DEFAULT_FILTERS = [new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { filter: '(status:open OR status:regressed)', + filters: undefined as string | undefined, limit: DEFAULT_LIMIT, saved: undefined as string | undefined }; @@ -74,6 +76,7 @@ history: 'push', schema: { filter: 'string', + filters: 'string', limit: 'number', saved: 'string' } @@ -121,7 +124,10 @@ watch( () => organization.current, - () => { + (_currentOrganizationId, previousOrganizationId) => { + if (previousOrganizationId === undefined) { + return; + } updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS); queryParams.update(DEFAULT_PARAMS); paused = false; @@ -131,11 +137,22 @@ } ); - let filters = $state(getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter)); + function getQueryFilters() { + const cached = getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter); + if (queryParams.filters && serializeFilters(cached) !== queryParams.filters) { + const restored = deserializeFilters(queryParams.filters); + if (toFilter(restored) === queryParams.filter) { + return restored; + } + } + return cached; + } + + let filters = $state(getQueryFilters()); watch( - [() => queryParams.filter], - ([filter]) => { - filters = getFiltersFromCache(filterCacheKey(filter), filter); + [() => queryParams.filter, () => queryParams.filters], + () => { + filters = getQueryFilters(); }, { lazy: true @@ -173,7 +190,8 @@ updateFilterCache(filterCacheKey(filter), updatedFilters); queryParams.update( { - filter + filter, + filters: updatedFilters.some((filter) => filter.type === 'environment') ? serializeFilters(updatedFilters) : null }, { history: options.history @@ -209,6 +227,7 @@ columnPersistenceKey: 'stream-column-visibility', get columns() { return getColumns>(eventsQueryParameters.mode, { + onEnvironmentClick: (environment) => onFilterChanged(new EnvironmentFilter([environment])), onTagClick: (tag) => onFilterChanged(new TagFilter([tag])), showType: !hasSingleTypeFilter(eventsQueryParameters.filter) }) diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index 48a3128499..0be1686490 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -40,11 +40,11 @@ public sealed class ExceptionlessMcpTools private const string LastDescription = "Optional relative time range such as 24h, 7d, or 30m. Do not combine with startUtc or endUtc."; private const string StartUtcDescription = "Optional inclusive UTC start time, for example 2026-06-25T00:00:00Z. Do not combine with last."; private const string EndUtcDescription = "Optional exclusive UTC end time, for example 2026-06-25T01:00:00Z. Do not combine with last."; - private const string EventGroupByDescription = "Optional dimension to group counts by. Supported values: version, type, source, status, tag, stack, user, level, error.type, error.code, os, os.version, browser. Multi-value fields such as tag, error.type, and error.code can place one event into multiple groups, so group totals may sum higher than the overall event total."; + private const string EventGroupByDescription = "Optional dimension to group counts by. Supported values: environment, version, type, source, status, tag, stack, user, level, error.type, error.code, os, os.version, browser. Multi-value fields such as tag, error.type, and error.code can place one event into multiple groups, so group totals may sum higher than the overall event total."; private const string SnoozeDurationDescription = "Optional relative snooze duration such as 2h, 3d, or 1w. Do not combine with snoozeUntilUtc."; private const string ProjectFilterDescription = "Optional Exceptionless filter expression applied to projects. Supported fields: id, name, organization_id, created_utc, updated_utc, last_event_date_utc."; private const string StackFilterDescription = "Optional Exceptionless filter expression. Supported fields include: stack, project, project_id, organization, organization_id, type, status, title, description, tag, tags, references, fixed, hidden, regressed, error, first, first_occurrence, last, last_occurrence, occurrences, total_occurrences."; - private const string EventFilterDescription = "Optional Exceptionless filter expression applied to events. Supported fields include: id, project, project_id, stack, stack_id, organization, organization_id, type, source, message, date, tag, tags, user, user.name, user.email, path, error, error.type, error.message, error.code, status, data.*. data.* works for custom data values that were indexed for search; arbitrary event detail data is returned by get_event but is not searchable unless indexed."; + private const string EventFilterDescription = "Optional Exceptionless filter expression applied to events. Supported fields include: id, project, project_id, stack, stack_id, organization, organization_id, type, environment, source, message, date, tag, tags, user, user.name, user.email, path, error, error.type, error.message, error.code, status, data.*. data.* works for custom data values that were indexed for search; arbitrary event detail data is returned by get_event but is not searchable unless indexed."; private const string IndexedDataFilterNote = "data.* filters work for custom data values that were indexed for search. Arbitrary event detail data is returned by get_event but is not searchable unless indexed."; @@ -1655,7 +1655,8 @@ private McpEventResult ToEventResult(PersistentEvent ev, bool includeDetails = f ev.Source, ev.Message, ev.ReferenceId, - includeDetails ? ToEventDetails(ev, maxDetailSize) : null); + includeDetails ? ToEventDetails(ev, maxDetailSize) : null, + ev.Environment); } private McpEventDetails ToEventDetails(PersistentEvent ev, int maxDetailSize) @@ -1832,6 +1833,7 @@ private static double GetNumericAggregationValue(object? value, double defaultVa private static readonly string[] EventGroupByAllowedFields = [ + "environment", "version", "type", "source", @@ -1849,6 +1851,7 @@ private static double GetNumericAggregationValue(object? value, double defaultVa private static readonly IReadOnlyDictionary EventGroupByFields = new Dictionary(StringComparer.OrdinalIgnoreCase) { + ["environment"] = new("environment", EventIndex.Alias.Environment), ["version"] = new("version", EventIndex.Alias.Version), ["type"] = new("type", EventIndex.Alias.Type), ["source"] = new("source", EventIndex.Alias.Source), @@ -1922,6 +1925,7 @@ private static double GetNumericAggregationValue(object? value, double defaultVa private static readonly HashSet EventSortFields = new(StringComparer.OrdinalIgnoreCase) { + EventIndex.Alias.Environment, EventIndex.Alias.Date, EventIndex.Alias.Type, EventIndex.Alias.Source, diff --git a/src/Exceptionless.Web/Mcp/McpModels.cs b/src/Exceptionless.Web/Mcp/McpModels.cs index 38ac10f42c..48050cbdad 100644 --- a/src/Exceptionless.Web/Mcp/McpModels.cs +++ b/src/Exceptionless.Web/Mcp/McpModels.cs @@ -159,7 +159,8 @@ public sealed record McpEventResult( string? Source = null, string? Message = null, string? ReferenceId = null, - McpEventDetails? Details = null); + McpEventDetails? Details = null, + string? Environment = null); public sealed record McpEventDetails( bool IsTruncated = false, diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index 97f2dee003..aae8629299 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -15,10 +15,10 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject public static readonly IReadOnlyDictionary> ValidColumnIds = new Dictionary> { - ["events"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level" }, - ["sessions"] = new HashSet { "summary", "duration", "user", "date" }, + ["events"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level", "environment" }, + ["sessions"] = new HashSet { "summary", "duration", "user", "date", "environment" }, ["stacks"] = new HashSet { "summary", "project", "tags", "status", "users", "events", "first", "last" }, - ["stream"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level" } + ["stream"] = new HashSet { "summary", "user", "date", "project", "tags", "message", "type", "version", "exception_type", "source", "name", "level", "environment" } }; /// Union of all valid column IDs across all views. diff --git a/src/Exceptionless.Web/Utility/OpenApi/EventEnvironmentSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/EventEnvironmentSchemaTransformer.cs new file mode 100644 index 0000000000..1083a4665e --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/EventEnvironmentSchemaTransformer.cs @@ -0,0 +1,26 @@ +using Exceptionless.Core.Models; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +public sealed class EventEnvironmentSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (!typeof(Event).IsAssignableFrom(context.JsonTypeInfo.Type) || schema.Properties is null) + return Task.CompletedTask; + + var property = context.JsonTypeInfo.Type.GetProperty(nameof(Event.Environment))!; + string? name = JsonPropertyNameResolver.GetJsonPropertyName(context.JsonTypeInfo, property); + if (name is not null && schema.Properties.TryGetValue(name, out var propertySchema) && propertySchema is OpenApiSchema environment) + { + // The tolerant JSON converter accepts malformed input, but responses contain only a string or null. + environment.Type = JsonSchemaType.String | JsonSchemaType.Null; + environment.MaxLength = 64; + schema.Required?.Remove(name); + } + + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs b/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs index 9fe098e181..829c3a2243 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ public static IServiceCollection AddExceptionlessOpenApi(this IServiceCollection options.AddSchemaTransformer(); options.AddSchemaTransformer(); options.AddSchemaTransformer(); + options.AddSchemaTransformer(); options.AddSchemaTransformer(); options.AddSchemaTransformer(); }); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 6e88d09679..7d1bbcd5d8 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -530,6 +530,14 @@ "Event" ], "parameters": [ + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -693,6 +701,14 @@ "type": "string" } }, + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -856,6 +872,14 @@ "type": "string" } }, + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -1028,6 +1052,14 @@ "type": "string" } }, + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -10645,6 +10677,14 @@ "type": "string" } }, + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -10810,6 +10850,14 @@ "type": "string" } }, + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -10982,6 +11030,14 @@ "type": "string" } }, + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -11157,6 +11213,14 @@ "type": "string" } }, + { + "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.", + "schema": { + "type": "string" + } + }, { "name": "source", "in": "query", @@ -13038,6 +13102,15 @@ "additionalProperties": {}, "description": "Used to store primitive data type custom data values for searching the event." }, + "environment": { + "maxLength": 64, + "minLength": 0, + "type": [ + "null", + "string" + ], + "description": "The deployment environment, such as production, staging, or development.\nMissing or invalid names remain unspecified. Machine and runtime information is stored separately in data.@environment." + }, "type": { "maxLength": 100, "minLength": 1, diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.Environment.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.Environment.cs new file mode 100644 index 0000000000..cd095be959 --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.Environment.cs @@ -0,0 +1,107 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Jobs; +using Exceptionless.Core.Models; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Foundatio.Jobs; +using Foundatio.Repositories.Models; +using Xunit; + +namespace Exceptionless.Tests.Api.Endpoints; + +public partial class EventEndpointTests +{ + [Theory] + [InlineData(" Production ", "Production")] + [InlineData("preview-42", "preview-42")] + [InlineData(" ", null)] + [InlineData("bad\nenvironment", null)] + [InlineData("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm", null)] + public async Task GetSubmitEvent_EnvironmentParameter_TrimsAndPreservesCasing(string environment, string? expected) + { + await SendRequestAsync(request => request + .AsTestOrganizationClientUser().AppendPaths("events", "submit") + .QueryString("message", "GET environment submission") + .QueryString("reference", "get-environment-reference") + .QueryString("environment", environment).StatusCodeShouldBeOk()); + + await GetService().RunAsync(TestCancellationToken); + await RefreshDataAsync(); + + var ev = Assert.Single((await _eventRepository.GetAllAsync()).Documents, + item => item.ReferenceId == "get-environment-reference"); + Assert.Equal(expected, ev.Environment); + Assert.NotNull(ev.Data); + Assert.False(ev.Data.ContainsKey("environment")); + } + + [Fact] + public async Task GetStacks_EnvironmentFilter_ScopesUserCountsAndTheirCache() + { + await CreateDataAsync(data => + { + var first = data.Event().FreeProject().Type(Event.KnownTypes.Error).Mutate(ev => { ev.Environment = "production"; ev.SetUserIdentity("production-0"); }); + for (int i = 1; i < 12; i++) + { + string identity = $"production-{i}"; + data.Event().FreeProject().Type(Event.KnownTypes.Error).Stack(first).Mutate(ev => { ev.Environment = "production"; ev.SetUserIdentity(identity); }); + } + data.Event().FreeProject().Type(Event.KnownTypes.Error).Stack(first).Mutate(ev => { ev.Environment = "staging"; ev.SetUserIdentity("staging-user"); }); + data.Event().FreeProject().Type(Event.KnownTypes.Log).Mutate(ev => { ev.Environment = "production"; ev.SetUserIdentity("unaffected-production-user"); }); + data.Event().FreeProject().Type(Event.KnownTypes.Error).Stack(first).Mutate(ev => { ev.Environment = "development"; ev.SetUserIdentity("development-user"); }); + data.Event().FreeProject().Type(Event.KnownTypes.Log).Mutate(ev => { ev.Environment = "development"; ev.SetUserIdentity("unaffected-development-user"); }); + }); + + foreach (var (filter, expected, totalUsers) in new[] { ("environment:production", 12, 13), ("environment:staging", 1, 1), ("environment:production", 12, 13), ("environment:(production OR staging)", 13, 14), ("", 14, 16) }) + { + var stacks = await SendRequestAsAsync>(request => request + .AsFreeOrganizationUser().AppendPath("events").QueryString("mode", "stack_frequent") + .QueryString("filter", $"type:error {filter}").StatusCodeShouldBeOk()); + var stack = Assert.Single(Assert.IsType>(stacks)); + Assert.Equal(expected, stack.Total); + Assert.Equal(expected, stack.Users); + Assert.Equal(totalUsers, stack.TotalUsers); + } + } + + [Fact] + public async Task GetEvents_EnvironmentOnFreePlan_FiltersEventsSummariesAndStacks() + { + await CreateDataAsync(data => + { + var production = data.Event().FreeProject().Mutate(ev => ev.Environment = " Production "); + data.Event().FreeProject().Stack(production).Mutate(ev => ev.Environment = "production"); + data.Event().FreeProject().Stack(production).Mutate(ev => ev.Environment = "staging"); + data.Event().FreeProject().Stack(production); + data.Event().TestProject().Mutate(ev => ev.Environment = "private-environment"); + }); + + var events = await SendRequestAsAsync>(request => request + .AsFreeOrganizationUser().AppendPath("events") + .QueryString("filter", "environment:PRODUCTION").StatusCodeShouldBeOk()); + Assert.Equal(new[] { "Production", "production" }, Assert.IsType>(events).Select(ev => ev.Environment).Order(StringComparer.Ordinal).ToArray()); + + var summaries = await SendRequestAsAsync>(request => request + .AsFreeOrganizationUser().AppendPath("events").QueryString("mode", "summary") + .QueryString("filter", "environment:production").StatusCodeShouldBeOk()); + Assert.Equal(new[] { "Production", "production" }, Assert.IsType>(summaries).Select(ev => ev.Environment).Order(StringComparer.Ordinal).ToArray()); + + var stacks = await SendRequestAsAsync>(request => request + .AsFreeOrganizationUser().AppendPath("events").QueryString("mode", "stack_frequent") + .QueryString("filter", "environment:production").StatusCodeShouldBeOk()); + Assert.Equal(2, Assert.Single(Assert.IsType>(stacks)).Total); + + var missing = await SendRequestAsAsync>(request => request + .AsFreeOrganizationUser().AppendPath("events") + .QueryString("filter", "_missing_:environment").StatusCodeShouldBeOk()); + Assert.Null(Assert.Single(Assert.IsType>(missing)).Environment); + + var count = await SendRequestAsAsync(request => request + .AsFreeOrganizationUser().AppendPaths("events", "count") + .QueryString("aggregations", "terms:(environment~100)").StatusCodeShouldBeOk()); + Assert.NotNull(count); + Assert.Equal(4, count.Total); + Assert.Equal(new[] { "production", "staging" }, count.Aggregations.Terms("terms_environment")!.Buckets.Select(bucket => bucket.Key).Order().ToArray()); + Assert.Equal(2, count.Aggregations.Terms("terms_environment")!.Buckets.Single(bucket => bucket.Key == "production").Total); + } +} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 6ea6bd8be8..b18138251a 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -431,7 +431,8 @@ public async Task PostAsync_StructuredColumns_PersistsAllSettings() var columnSettings = new Dictionary { ["summary"] = new() { AutoFill = true, Position = 0, Visible = true, Wrap = true }, - ["project"] = new() { Position = 1, Visible = true, Width = 240 } + ["project"] = new() { Position = 1, Visible = true, Width = 240 }, + ["environment"] = new() { Visible = false } }; // Act @@ -452,6 +453,7 @@ public async Task PostAsync_StructuredColumns_PersistsAllSettings() // Assert Assert.NotNull(result); Assert.Equal(240, result.Columns?["project"].Width); + Assert.False(result.Columns?["environment"].Visible); Assert.True(result.Columns?["project"].Visible); Assert.Equal(1, result.Columns?["project"].Position); Assert.True(result.Columns?["summary"].AutoFill); diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 9d85fa6956..7992bd67f4 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -81,6 +81,21 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp .GetProperty("application/problem+json") .GetProperty("schema"); Assert.Equal("#/components/schemas/HttpValidationProblemDetails", validationProblemSchema.GetProperty("$ref").GetString()); + + foreach (string route in new[] + { + "/api/v2/events/submit", + "/api/v2/events/submit/{type}", + "/api/v2/projects/{projectId}/events/submit", + "/api/v2/projects/{projectId}/events/submit/{type}" + }) + { + var environmentParameter = Assert.Single(paths.GetProperty(route).GetProperty("get").GetProperty("parameters").EnumerateArray(), + parameter => parameter.GetProperty("name").GetString() == "environment"); + Assert.Equal("query", environmentParameter.GetProperty("in").GetString()); + Assert.Equal("string", environmentParameter.GetProperty("schema").GetProperty("type").GetString()); + Assert.False(environmentParameter.TryGetProperty("required", out var required) && required.GetBoolean()); + } } [Fact] @@ -96,6 +111,11 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem // Assert Assert.True(schemas.TryGetProperty("Login", out _)); + var persistentEvent = schemas.GetProperty("PersistentEvent"); + var eventEnvironment = persistentEvent.GetProperty("properties").GetProperty("environment"); + Assert.Contains(eventEnvironment.GetProperty("type").EnumerateArray(), type => type.GetString() == "string"); + Assert.Equal(64, eventEnvironment.GetProperty("maxLength").GetInt32()); + Assert.DoesNotContain(persistentEvent.GetProperty("required").EnumerateArray(), name => name.GetString() == "environment"); Assert.True(schemas.TryGetProperty("Signup", out _)); Assert.True(schemas.TryGetProperty("NewProject", out _)); Assert.True(schemas.TryGetProperty("SavedViewColumnSettings", out var savedViewColumnSettings)); diff --git a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs index 41069da579..c7e0c2b28d 100644 --- a/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Mcp/ExceptionlessMcpToolsTests.cs @@ -465,6 +465,7 @@ public async Task GetEventAsync_EventsScope_ReturnsEventDetails() Path = "/broken" }; ev.Data!["custom"] = "custom-value"; + ev.Environment = "production"; await _eventRepository.SaveAsync(ev, o => o.ImmediateConsistency()); await RefreshDataAsync(); var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); @@ -481,6 +482,7 @@ public async Task GetEventAsync_EventsScope_ReturnsEventDetails() Assert.Equal("at Test.Throw() in Test.cs:line 42", error.StackTrace); Assert.Equal("/broken", item.Details.Request?.Path); Assert.Equal("custom-value", item.Details.Data?["custom"]); + Assert.Equal("production", item.Environment); } [Fact] @@ -703,6 +705,23 @@ public async Task CountEventsAsync_GroupByVersion_ReturnsVersionCounts() Assert.Contains(data.Groups, g => g.Key == "1.0.3" && g.Events >= 1); } + [Fact] + public async Task CountEventsAsync_GroupByEnvironment_ReturnsFilteredDeploymentCounts() + { + const string referenceId = "mcp-count-environments"; + await CreateDataAsync(data => + { + data.Event().TestProject().ReferenceId(referenceId).Mutate(ev => ev.Environment = "production"); + data.Event().TestProject().ReferenceId(referenceId).Mutate(ev => ev.Environment = "staging"); + }); + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.EventsRead); + var result = await tools.CountEventsAsync(TestConstants.ProjectId, filter: $"reference:{referenceId} environment:production", groupBy: "environment"); + Assert.True(result.Ok); + var group = Assert.Single(Data(result).Groups!); + Assert.Equal("production", group.Key); + Assert.Equal(1, group.Events); + } + [Fact] public async Task CountEventsAsync_GroupByVersionAndInterval_ReturnsGroupedTrend() { diff --git a/tests/Exceptionless.Tests/Migrations/AddEventEnvironmentMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/AddEventEnvironmentMigrationTests.cs new file mode 100644 index 0000000000..828b89998f --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/AddEventEnvironmentMigrationTests.cs @@ -0,0 +1,47 @@ +using Elastic.Clients.Elasticsearch.Mapping; +using Exceptionless.Core.Migrations; +using Exceptionless.Core.Repositories.Configuration; +using Foundatio.Lock; +using Foundatio.Repositories.Migrations; +using Foundatio.Utility; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class AddEventEnvironmentMigrationTests : IntegrationTestsBase +{ + public AddEventEnvironmentMigrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { } + + [Fact] + public async Task RunAsync_LegacyDailyIndex_AddsMappingAndCanRunAgain() + { + var configuration = GetService(); + string indexName = $"{configuration.Events.Name}-v0-2000.01.01"; + var client = configuration.Client; + try + { + var created = await client.Indices.CreateAsync(indexName, index => index + .Settings(settings => settings.Analysis(analysis => analysis.Analyzers(analyzers => analyzers + .Custom("lowerkeyword", analyzer => analyzer.Filter("lowercase").Tokenizer("keyword"))))) + .Mappings(mapping => mapping.Properties(properties => properties.Keyword("legacy_field"))), TestCancellationToken); + Assert.True(created.IsValidResponse, created.DebugInformation); + + var migration = new AddEventEnvironment(configuration, GetService()); + var context = new MigrationContext(EmptyLock.Empty, _logger, TestCancellationToken); + await migration.RunAsync(context); + await migration.RunAsync(context); + + var mapping = await client.Indices.GetMappingAsync(indexName, TestCancellationToken); + Assert.True(mapping.IsValidResponse, mapping.DebugInformation); + var properties = Assert.Single(mapping.Mappings).Value.Mappings.Properties!; + var environment = Assert.IsType(properties["environment"]); + Assert.Equal("lowerkeyword", environment.Analyzer); + Assert.Equal("lowercase", Assert.IsType(environment.Fields!["keyword"]).Normalizer); + Assert.IsType(properties["legacy_field"]); + } + finally + { + await client.Indices.DeleteAsync(indexName, TestCancellationToken); + } + } +} diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs index d4a00fc1aa..91be39c2dd 100644 --- a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -95,12 +95,16 @@ public async Task DataSeedStartupAction_OnlyRepeatableMigrationsPending_SeedsDat { // Arrange // Mark all current versioned migrations complete so this test isolates repeatable data seeding. + var latestMigration = GetService>() + .Where(migration => migration.MigrationType is MigrationType.Versioned or MigrationType.VersionedAndResumable) + .MaxBy(migration => migration.Version)!; + int latestVersion = latestMigration.Version!.Value; var migrationStateRepository = GetService(); await migrationStateRepository.AddAsync(new MigrationState { - Id = "9", - Version = 9, - MigrationType = MigrationType.VersionedAndResumable, + Id = latestVersion.ToString(), + Version = latestVersion, + MigrationType = latestMigration.MigrationType, StartedUtc = DateTime.UtcNow, CompletedUtc = DateTime.UtcNow }); diff --git a/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs index 622190eec1..c7239d330d 100644 --- a/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs +++ b/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs @@ -6,6 +6,14 @@ namespace Exceptionless.Tests.Migrations; public sealed class MigrationRegistrationTests : TestWithServices { + [Fact] + public void MigrationRegistration_EventEnvironment_IsVersionedAndResumable() + { + var migration = Assert.Single(GetService>().OfType().DistinctBy(migration => migration.GetType())); + Assert.Equal(MigrationType.VersionedAndResumable, migration.MigrationType); + Assert.Equal(10, migration.Version); + } + public MigrationRegistrationTests(ITestOutputHelper output) : base(output) { } diff --git a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs index 18afb37434..1fcc10552b 100644 --- a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs @@ -85,6 +85,38 @@ public Task CreateAutoSessionAsync() return CreateAutoSessionInternalAsync(DateTimeOffset.Now); } + [Fact] + public async Task RunAsync_SameUserInDifferentEnvironments_SharesStackAndSeparatesAutomaticSessions() + { + var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); + var project = _projectData.GenerateSampleProject(); + var production = GenerateEvent(DateTimeOffset.Now.AddMinutes(-3), "same-user"); + production.Environment = " Production "; + var staging = GenerateEvent(DateTimeOffset.Now.AddMinutes(-2), "same-user"); + staging.Data![Event.KnownDataKeys.Error] = production.Data![Event.KnownDataKeys.Error]; + staging.Environment = "staging"; + var laterProduction = GenerateEvent(DateTimeOffset.Now.AddMinutes(-1), "same-user"); + laterProduction.Data![Event.KnownDataKeys.Error] = production.Data[Event.KnownDataKeys.Error]; + laterProduction.Environment = "PRODUCTION"; + + foreach (var ev in new[] { production, staging, laterProduction }) + { + var context = await _pipeline.RunAsync(ev, organization, project); + Assert.False(context.HasError, context.ErrorMessage); + Assert.True(context.IsProcessed); + } + + Assert.Equal(production.StackId, staging.StackId); + Assert.Equal(production.GetSessionId(), laterProduction.GetSessionId()); + Assert.NotEqual(production.GetSessionId(), staging.GetSessionId()); + Assert.Equal("Production", production.Environment); + Assert.Equal("PRODUCTION", laterProduction.Environment); + await RefreshDataAsync(); + var sessions = await _eventRepository.FindAsync(query => query.FilterExpression("type:session")); + Assert.Equal(2, sessions.Total); + Assert.Equal(new[] { "Production", "staging" }, sessions.Documents.Select(ev => ev.Environment).Order(StringComparer.Ordinal).ToArray()); + } + private async Task CreateAutoSessionInternalAsync(DateTimeOffset date) { var ev = GenerateEvent(date, "blake@exceptionless.io"); @@ -1043,6 +1075,7 @@ public async Task CanDiscardStackEventsBasedOnEventVersion(StackStatus expectedS Assert.NotNull(project); var ev = _eventData.GenerateEvent(organizationId: organization.Id, projectId: project.Id, type: Event.KnownTypes.Log, source: "test", occurrenceDate: DateTimeOffset.Now); + ev.Environment = "staging"; var context = await _pipeline.RunAsync(ev, organization, project); var stack = context.Stack; @@ -1061,6 +1094,7 @@ public async Task CanDiscardStackEventsBasedOnEventVersion(StackStatus expectedS await RefreshDataAsync(); ev = _eventData.GenerateEvent(organizationId: organization.Id, projectId: project.Id, type: Event.KnownTypes.Log, source: "test", occurrenceDate: DateTimeOffset.Now, semver: eventSemanticVersion); + ev.Environment = "production"; context = await _pipeline.RunAsync(ev, organization, project); stack = context.Stack; diff --git a/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs b/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs index f0872829ae..6bb1bf8cd0 100644 --- a/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs +++ b/tests/Exceptionless.Tests/Plugins/WebHookDataTests.cs @@ -50,6 +50,15 @@ public async Task CreateFromEventAsync(string version, bool expectData) } } + [Fact] + public async Task CreateFromEventAsync_VersionTwo_PreservesDeploymentEnvironment() + { + var context = GetWebHookDataContext(WebHook.KnownVersions.Version2); + context.Event!.Environment = "production"; + var data = Assert.IsType(await _webHookData.CreateFromEventAsync(context)); + Assert.Equal("production", data.Environment); + } + [Theory] [MemberData(nameof(WebHookData))] public async Task CanCreateFromStackAsync(string version, bool expectData) diff --git a/tests/Exceptionless.Tests/Search/EventEnvironmentFilterTests.cs b/tests/Exceptionless.Tests/Search/EventEnvironmentFilterTests.cs new file mode 100644 index 0000000000..382e48f97b --- /dev/null +++ b/tests/Exceptionless.Tests/Search/EventEnvironmentFilterTests.cs @@ -0,0 +1,25 @@ +using Exceptionless.Core.Repositories.Queries; +using Xunit; + +namespace Exceptionless.Tests.Search; + +public sealed class EventEnvironmentFilterTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("type:error status:open", null)] + [InlineData("type:error environment:production", "environment:production")] + [InlineData("type:error environment:(production OR staging)", "environment:(production OR staging)")] + [InlineData("type:error NOT environment:(production OR staging)", "NOT environment:(production OR staging)")] + [InlineData("environment:production OR type:error", null)] + [InlineData("type:error (environment:production OR environment:staging)", "(environment:production OR environment:staging)")] + [InlineData("type:error (_missing_:environment OR environment:production)", "(_missing_:environment OR environment:production)")] + [InlineData("type:error _exists_:environment", "_exists_:environment")] + [InlineData("type:error NOT environment:staging", "NOT environment:staging")] + [InlineData("NOT (environment:staging OR type:error)", "NOT (environment:staging)")] + [InlineData("NOT (environment:staging AND type:error)", null)] + public async Task GetAsync_EnvironmentScope_PreservesUsersOutsideTheEventType(string? filter, string? expected) + { + Assert.Equal(expected, await EventEnvironmentFilter.GetAsync(filter)); + } +} diff --git a/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs b/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs index 5209f02d71..50f7b229a7 100644 --- a/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs +++ b/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs @@ -44,6 +44,9 @@ public PersistentEventQueryValidatorTests(ITestOutputHelper output) : base(outpu [InlineData("data.age:[* TO 10]", "idx.age-n:[* TO 10]", true, true)] [InlineData("type:404 AND data.age:(>30 AND <=40)", "type:404 AND idx.age-n:(>30 AND <=40)", true, true)] [InlineData("type:404", "type:404", true, false)] + [InlineData("environment:production", "environment:production", true, false)] + [InlineData("environment:(production OR staging)", "environment:(production OR staging)", true, false)] + [InlineData("_missing_:environment", "_missing_:environment", true, false)] [InlineData("reference:404", "reference:404", true, false)] [InlineData("organization:404", "organization:404", true, false)] [InlineData("project:404", "project:404", true, false)] @@ -97,6 +100,8 @@ public async Task CanProcessQueryAsync(string query, string expected, bool isVal [InlineData("cardinality:stack", true, false)] [InlineData("cardinality:user", true, false)] [InlineData("cardinality:type", true, false)] + [InlineData("terms:(environment~100)", true, false)] + [InlineData("cardinality:environment", true, false)] [InlineData("cardinality:source", true, true)] [InlineData("cardinality:tags", true, true)] [InlineData("cardinality:geo", true, true)] diff --git a/tests/Exceptionless.Tests/Serializer/Models/EventEnvironmentTests.cs b/tests/Exceptionless.Tests/Serializer/Models/EventEnvironmentTests.cs new file mode 100644 index 0000000000..2446f2e73c --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/Models/EventEnvironmentTests.cs @@ -0,0 +1,69 @@ +using Exceptionless.Core; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Plugins.EventParser; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Serializer.Models; + +public sealed class EventEnvironmentTests : TestWithServices +{ + public EventEnvironmentTests(ITestOutputHelper output) : base(output) { } + + [Theory] + [InlineData("environment")] + [InlineData("Environment")] + [InlineData("ENVIRONMENT")] + public void Deserialize_DeploymentEnvironment_TrimsNameAndPreservesCasingAndRuntimeMetadata(string property) + { + var serializer = GetService(); + var ev = serializer.Deserialize("""{"PROPERTY":" Production ","data":{"@environment":{"machine_name":"worker-1"},"environment":{"custom":true}}}""".Replace("PROPERTY", property)); + + Assert.NotNull(ev); + Assert.Equal("Production", ev.Environment); + Assert.Equal("worker-1", ev.GetEnvironmentInfo(serializer, _logger)?.MachineName); + Assert.NotNull(ev.Data?["environment"]); + string json = serializer.SerializeToString(ev)!; + Assert.Contains("\"environment\":\"Production\"", json); + Assert.Equal("Production", serializer.Deserialize(json)?.Environment); + } + + [Theory] + [InlineData("null")] + [InlineData("42")] + [InlineData("true")] + [InlineData("{\"name\":\"production\"}")] + [InlineData("[\"production\"]")] + [InlineData("\" \"")] + [InlineData("\"production\\ninvalid\"")] + public void ParseEvents_InvalidEnvironment_PreservesSubmissionBatch(string environment) + { + var parser = GetService(); + var events = parser.ParseEvents($$"""[{"type":"error","message":"first","environment":{{environment}}},{"type":"log","message":"second","environment":"staging"}]""", 2, null); + + Assert.NotNull(events); + Assert.Equal(2, events.Count); + Assert.Null(events[0].Environment); + Assert.Equal("first", events[0].Message); + Assert.Equal("staging", events[1].Environment); + } + + [Fact] + public void Serialize_UnspecifiedEnvironment_OmitsProperty() + { + var serializer = GetService(); + Assert.DoesNotContain("environment", serializer.SerializeToString(new Event())!); + Assert.Null(serializer.Deserialize("{}")?.Environment); + Assert.Null(new Event { Environment = new string('x', 65) }.Environment); + Assert.Equal(new string('X', 64), new Event { Environment = new string('X', 64) }.Environment); + } + + [Fact] + public void Equals_DifferentEnvironments_DistinguishesEvents() + { + Assert.NotEqual(new Event { Environment = "production", Data = null }, new Event { Environment = "staging", Data = null }); + Assert.Equal(new Event { Environment = " Production ", Data = null }, new Event { Environment = "Production", Data = null }); + Assert.NotEqual(new Event { Environment = "Production", Data = null }, new Event { Environment = "production", Data = null }); + } +} diff --git a/tests/http/events.http b/tests/http/events.http index dda0f774f9..1d82bd07a7 100644 --- a/tests/http/events.http +++ b/tests/http/events.http @@ -29,6 +29,30 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/events?mode=stack_frequent&limit=10 Authorization: Bearer {{token}} +### Deployment environment (available on all plans) +GET {{apiUrl}}/events?filter=environment:production&mode=summary +Authorization: Bearer {{token}} + +### Environment names for this project +GET {{apiUrl}}/projects/{{projectId}}/events/count?aggregations=terms:(environment~100) +Authorization: Bearer {{token}} + +### Historical and unspecified environments +GET {{apiUrl}}/events?filter=_missing_:environment +Authorization: Bearer {{token}} + +### Submit a staging event (preserves casing; same stack across environments) +POST {{apiUrl}}/events +Authorization: Bearer {{clientToken}} +Content-Type: application/json + +{ + "type": "log", + "source": "environment-example", + "message": "Deployment complete", + "environment": "Staging" +} + ### @eventId = {{allEvents.response.body.$[0].id}} @stackId = {{allEvents.response.body.$[0].stack_id}} @@ -128,7 +152,7 @@ Content-Type: application/json } ### GET Submit Random Parameters -GET {{apiUrl}}/events/submit?access_token={{clientToken}}&foo=bar&edit=&spam=eggs=ham&tags=blue&tags=red&message=foo +GET {{apiUrl}}/events/submit?access_token={{clientToken}}&environment=Production&foo=bar&edit=&spam=eggs=ham&tags=blue&tags=red&message=foo ### Raygun Post POST {{apiUrl}}/events?access_token={{clientToken}}