From 730f50533db56d1bd1aa08478fe27890905490b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 23:55:45 +0000 Subject: [PATCH 1/6] Initial plan From 6939c21ab3d3fb64c7278e2851061775e2ed23cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:09:23 +0000 Subject: [PATCH 2/6] fix: enforce premium search restrictions server-side Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../Controllers/EventController.cs | 4 +++ .../Controllers/StackController.cs | 2 ++ .../Controllers/EventControllerTests.cs | 30 +++++++++++++++++++ .../Controllers/StackControllerTests.cs | 15 ++++++++++ 4 files changed, 51 insertions(+) diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index 7470abb854..7ec97ad686 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -241,6 +241,8 @@ private async Task> CountInternalAsync(AppFilter sf, T return BadRequest(far.Message); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures; + if (sf.UsesPremiumFeatures && sf.Organizations.Any(o => !o.HasPremiumFeatures)) + return PlanLimitReached("Please upgrade your plan to use premium search features."); if (mode == "stack_new") filter = AddFirstOccurrenceFilter(ti.Range, filter); @@ -297,6 +299,8 @@ private async Task>> GetInternalAsync( return BadRequest(pr.Message); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures; + if (sf.UsesPremiumFeatures && sf.Organizations.Any(o => !o.HasPremiumFeatures)) + return PlanLimitReached("Please upgrade your plan to use premium search features."); try { diff --git a/src/Exceptionless.Web/Controllers/StackController.cs b/src/Exceptionless.Web/Controllers/StackController.cs index 9b9b5630c4..980a2c881f 100644 --- a/src/Exceptionless.Web/Controllers/StackController.cs +++ b/src/Exceptionless.Web/Controllers/StackController.cs @@ -489,6 +489,8 @@ private async Task>> GetInternalAsync(Ap return BadRequest(pr.Message); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures; + if (sf.UsesPremiumFeatures && sf.Organizations.Any(o => !o.HasPremiumFeatures)) + return PlanLimitReached("Please upgrade your plan to use premium search features."); try { diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 066c3d7d6c..4fc055b19a 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -519,6 +519,36 @@ public async Task CanGetFreeProjectLevelMostFrequentStackMode() Assert.Equal(2, results.Count); } + [Fact] + public async Task GetCountByProjectAsync_WithPremiumFilterOnFreeOrganization_ReturnsUpgradeRequired() + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject().Tag("premium-tag")); + + // Act & Assert + await SendRequestAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "events", "count") + .QueryString("filter", "tags:premium-tag") + .StatusCodeShouldBeUpgradeRequired() + ); + } + + [Fact] + public async Task GetByProjectAsync_WithPremiumFilterOnFreeOrganization_ReturnsUpgradeRequired() + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject().Tag("premium-tag")); + + // Act & Assert + await SendRequestAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "events") + .QueryString("filter", "tags:premium-tag") + .StatusCodeShouldBeUpgradeRequired() + ); + } + [Fact] public async Task CanGetNewStackMode() { diff --git a/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs b/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs index 02017715de..79b1c7ba9e 100644 --- a/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/StackControllerTests.cs @@ -61,6 +61,21 @@ public async Task CanSearchByNonPremiumFields() Assert.Single(result); } + [Fact] + public async Task GetByProjectAsync_WithPremiumFilterOnFreeOrganization_ReturnsUpgradeRequired() + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject().Message("Premium restricted stack")); + + // Act & Assert + await SendRequestAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "stacks") + .QueryString("filter", "title:\"Premium restricted stack\"") + .StatusCodeShouldBeUpgradeRequired() + ); + } + [Theory] [InlineData(null)] [InlineData("1.0.0")] From 8ddad7db466039bd3b965a6101fbc98f92662baa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:13:57 +0000 Subject: [PATCH 3/6] refactor: share premium search restriction check Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../Controllers/Base/ExceptionlessApiController.cs | 5 +++++ src/Exceptionless.Web/Controllers/EventController.cs | 4 ++-- src/Exceptionless.Web/Controllers/StackController.cs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs b/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs index 662defc7a3..813f4312d2 100644 --- a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs +++ b/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs @@ -229,6 +229,11 @@ protected ObjectResult PlanLimitReached(string message) return Problem(statusCode: StatusCodes.Status426UpgradeRequired, title: message); } + protected bool IsPremiumFeatureQueryBlocked(AppFilter filter) + { + return filter.UsesPremiumFeatures && filter.Organizations.Any(o => !o.HasPremiumFeatures); + } + protected ObjectResult TooManyRequests(string message) { return Problem(statusCode: StatusCodes.Status429TooManyRequests, title: message); diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index 7ec97ad686..3d232ecc5a 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -241,7 +241,7 @@ private async Task> CountInternalAsync(AppFilter sf, T return BadRequest(far.Message); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures; - if (sf.UsesPremiumFeatures && sf.Organizations.Any(o => !o.HasPremiumFeatures)) + if (IsPremiumFeatureQueryBlocked(sf)) return PlanLimitReached("Please upgrade your plan to use premium search features."); if (mode == "stack_new") @@ -299,7 +299,7 @@ private async Task>> GetInternalAsync( return BadRequest(pr.Message); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures; - if (sf.UsesPremiumFeatures && sf.Organizations.Any(o => !o.HasPremiumFeatures)) + if (IsPremiumFeatureQueryBlocked(sf)) return PlanLimitReached("Please upgrade your plan to use premium search features."); try diff --git a/src/Exceptionless.Web/Controllers/StackController.cs b/src/Exceptionless.Web/Controllers/StackController.cs index 980a2c881f..ae41cce6ac 100644 --- a/src/Exceptionless.Web/Controllers/StackController.cs +++ b/src/Exceptionless.Web/Controllers/StackController.cs @@ -489,7 +489,7 @@ private async Task>> GetInternalAsync(Ap return BadRequest(pr.Message); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures; - if (sf.UsesPremiumFeatures && sf.Organizations.Any(o => !o.HasPremiumFeatures)) + if (IsPremiumFeatureQueryBlocked(sf)) return PlanLimitReached("Please upgrade your plan to use premium search features."); try From b0727eace12004f7e2d0630d52c7b966d14327ba Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 14:56:46 -0500 Subject: [PATCH 4/6] fix: align Svelte premium search guidance --- .../features/events/premium-filter.test.ts | 28 ++++++++++++ .../src/lib/features/events/premium-filter.ts | 43 ++++++++++--------- .../ClientApp/src/routes/(app)/+layout.svelte | 3 +- .../src/routes/(app)/event/+page.svelte | 6 +++ .../src/routes/(app)/stack/+page.svelte | 6 +++ 5 files changed, 64 insertions(+), 22 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts new file mode 100644 index 0000000000..79d72e41d7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { filterUsesPremiumFeatures } from './premium-filter'; + +describe('filterUsesPremiumFeatures', () => { + it.each([undefined, null, '', 'status:open', '(status:open OR status:regressed)', 'reference:ABC123'])('allows free event filters: %s', (filter) => { + expect(filterUsesPremiumFeatures(filter, 'event')).toBe(false); + }); + + it.each(['tags:important', 'data.user.identity:blake', 'message:"out of memory"'])('detects premium event filters: %s', (filter) => { + expect(filterUsesPremiumFeatures(filter, 'event')).toBe(true); + }); + + it.each(['first_occurrence:[now-1d TO now]', 'last:now', 'occurrences_are_critical:true', 'critical:false', 'project:ABC123'])( + 'allows free stack filters: %s', + (filter) => { + expect(filterUsesPremiumFeatures(filter, 'stack')).toBe(false); + } + ); + + it.each(['title:"out of memory"', 'reference:ABC123', 'stack:ABC123', 'tags:important'])('detects premium stack filters: %s', (filter) => { + expect(filterUsesPremiumFeatures(filter, 'stack')).toBe(true); + }); + + it('detects a premium field after a free field', () => { + expect(filterUsesPremiumFeatures('status:open AND tags:important', 'event')).toBe(true); + }); +}); 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 ccb9fbd3a4..e260270bac 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 @@ -1,33 +1,36 @@ -/** - * Free query fields that don't require a premium plan. - * Any field referenced in a filter that is NOT in this set requires premium features. - * Must be kept in sync with PersistentEventQueryValidator._freeQueryFields on the backend. - */ -const FREE_QUERY_FIELDS = new Set([ - 'date', - 'organization', - 'organization_id', - 'project', - 'project_id', - 'reference', - 'reference_id', - 'stack', - 'stack_id', - 'status', - 'type' -]); +export type SearchResource = 'event' | 'stack'; + +// These mirror the backend query validators so the upgrade notification is shown +// before a restricted request fails. The API remains the enforcement boundary. +const FREE_QUERY_FIELDS: Record> = { + event: new Set(['date', 'organization', 'organization_id', 'project', 'project_id', 'reference', 'reference_id', 'stack', 'stack_id', 'status', 'type']), + stack: new Set([ + 'critical', + 'first', + 'first_occurrence', + 'last', + 'last_occurrence', + 'occurrences_are_critical', + 'organization', + 'organization_id', + 'project', + 'project_id', + 'status', + 'type' + ]) +}; /** * Returns true if the filter string references fields that require a premium plan. * Uses client-side field detection to avoid an extra API call. */ -export function filterUsesPremiumFeatures(filter: null | string | undefined): boolean { +export function filterUsesPremiumFeatures(filter: null | string | undefined, resource: SearchResource): boolean { if (!filter) { return false; } const fields = extractFilterFields(filter); - return fields.some((field) => !FREE_QUERY_FIELDS.has(field.toLowerCase())); + return fields.some((field) => !FREE_QUERY_FIELDS[resource].has(field.toLowerCase())); } /** diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 9deb9cb990..afb52ba4bc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -11,7 +11,6 @@ import { accessToken, gotoLogin } from '$features/auth/index.svelte'; import { UpgradeRequiredDialog } from '$features/billing'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; - import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { buildIntercomBootOptions, IntercomShell } from '$features/intercom'; import { shouldLoadIntercomOrganization } from '$features/intercom/config'; import Notifications from '$features/notifications/components/notifications.svelte'; @@ -51,7 +50,7 @@ let { children }: Props = $props(); let isAuthenticated = $derived(!!accessToken.current); - let requiresPremium = $derived(premiumPage.requiresPremium || filterUsesPremiumFeatures(page.url.searchParams.get('filter'))); + let requiresPremium = $derived(premiumPage.requiresPremium); const sidebar = useSidebar(); let isCommandOpen = $state(false); let commandResetKey = $state(0); 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 9c57ade96f..29d75b2150 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -43,7 +43,9 @@ import EventsBulkActionsDropdownMenu from '$features/events/components/table/events-bulk-actions-dropdown-menu.svelte'; import EventsDataTable from '$features/events/components/table/events-data-table.svelte'; import { defaultEventColumnVisibility, getColumns } from '$features/events/components/table/options.svelte'; + import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { organization } from '$features/organizations/context.svelte'; + import { premiumPage } from '$features/organizations/premium-page.svelte'; import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; import * as agg from '$features/shared/api/aggregations'; @@ -642,6 +644,10 @@ } }); + $effect(() => { + premiumPage.current = filterUsesPremiumFeatures(eventsQueryParameters.filter, 'event') ? 'search' : undefined; + }); + const client = useFetchClient(); const clientStatus = useFetchClientStatus(client); let clientResponse = $state[]>>(); 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 4900d209a5..b16f372e0e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -39,7 +39,9 @@ import OrganizationDefaultsFacetedFilterBuilder from '$features/events/components/filters/organization-defaults-faceted-filter-builder.svelte'; import EventsDataTable from '$features/events/components/table/events-data-table.svelte'; import { getColumns } from '$features/events/components/table/options.svelte'; + import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { organization } from '$features/organizations/context.svelte'; + import { premiumPage } from '$features/organizations/premium-page.svelte'; import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; import * as agg from '$features/shared/api/aggregations'; @@ -610,6 +612,10 @@ } }); + $effect(() => { + premiumPage.current = filterUsesPremiumFeatures(eventsQueryParameters.filter, 'stack') ? 'search' : undefined; + }); + const client = useFetchClient(); const clientStatus = useFetchClientStatus(client); let clientResponse = $state[]>>(); From 335dbadb54f90d78a24844f104b5d8a9a63663c5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 15 Jul 2026 22:15:15 -0500 Subject: [PATCH 5/6] fix: complete premium search enforcement --- .../Api/Endpoints/EventEndpoints.cs | 14 +++-- .../Api/Endpoints/StackEndpoints.cs | 6 ++- .../features/events/premium-filter.test.ts | 9 ++-- .../src/lib/features/events/premium-filter.ts | 2 +- .../Exceptionless.Tests/Api/Data/openapi.json | 52 ++++++++++++++++--- .../Api/OpenApiSnapshotTests.cs | 18 +++++++ tests/http/events.http | 8 +++ tests/http/stacks.http | 4 ++ 8 files changed, 97 insertions(+), 16 deletions(-) diff --git a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs index a45cc56a4e..aede1d8f3b 100644 --- a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -31,6 +31,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Count") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -42,6 +43,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "Premium search features require an upgraded plan.", } }); @@ -50,6 +52,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Count by organization") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -62,6 +65,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -70,6 +74,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Count by project") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -82,6 +87,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -132,7 +138,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "Premium search features require an upgraded plan.", } }); @@ -162,7 +168,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The organization could not be found.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -192,7 +198,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The project could not be found.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -222,7 +228,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The stack could not be found.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); diff --git a/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs index c97aaf48ca..3af15e02d7 100644 --- a/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs @@ -237,6 +237,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.StacksReadPolicy) .Produces>() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Get all") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -250,6 +251,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "Premium search features require an upgraded plan.", } }); @@ -276,7 +278,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The organization could not be found.", - ["426"] = "Unable to view stack occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -303,7 +305,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The organization could not be found.", - ["426"] = "Unable to view stack occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts index 79d72e41d7..310addec30 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts @@ -7,9 +7,12 @@ describe('filterUsesPremiumFeatures', () => { expect(filterUsesPremiumFeatures(filter, 'event')).toBe(false); }); - it.each(['tags:important', 'data.user.identity:blake', 'message:"out of memory"'])('detects premium event filters: %s', (filter) => { - expect(filterUsesPremiumFeatures(filter, 'event')).toBe(true); - }); + it.each(['tags:important', 'data.@user.identity:blake', 'message:"out of memory"', '-tags:important', '+tags:important'])( + 'detects premium event filters: %s', + (filter) => { + expect(filterUsesPremiumFeatures(filter, 'event')).toBe(true); + } + ); it.each(['first_occurrence:[now-1d TO now]', 'last:now', 'occurrences_are_critical:true', 'critical:false', 'project:ABC123'])( 'allows free stack filters: %s', 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 e260270bac..b361b127de 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 @@ -38,7 +38,7 @@ export function filterUsesPremiumFeatures(filter: null | string | undefined, res * Matches patterns like `field:value` or `field:(value1 OR value2)`. */ function extractFilterFields(filter: string): string[] { - const fieldPattern = /(?:^|\s|[(!])(\w[\w.]*):/g; + const fieldPattern = /(?:^|\s|[(!])[-+]?(\w[\w.@]*):/g; const fields: string[] = []; let match: null | RegExpExecArray; diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 68ec2364ce..69b73d6c95 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -7255,6 +7255,16 @@ } } } + }, + "426": { + "description": "Premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -7372,7 +7382,7 @@ } }, "426": { - "description": "Unable to view stack occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -7497,7 +7507,7 @@ } }, "426": { - "description": "Unable to view stack occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -7893,6 +7903,16 @@ } } } + }, + "426": { + "description": "Premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -7975,6 +7995,16 @@ } } } + }, + "426": { + "description": "The organization is suspended or premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -8057,6 +8087,16 @@ } } } + }, + "426": { + "description": "The organization is suspended or premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -8264,7 +8304,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "Premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -8474,7 +8514,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -8622,7 +8662,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -8842,7 +8882,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 9561adc053..948509df19 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -187,6 +187,15 @@ public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() String.Equals(parameter.GetProperty("name").GetString(), "expected_stack_id", StringComparison.Ordinal)); AssertResponseCodes(eventById, "200", "400", "404", "426"); + foreach (string path in new[] { "/api/v2/events/count", "/api/v2/organizations/{organizationId}/events/count", "/api/v2/projects/{projectId}/events/count" }) + AssertPathResponseCodes(paths, path, "get", "200", "400", "426"); + + foreach (string path in new[] { "/api/v2/events", "/api/v2/organizations/{organizationId}/events", "/api/v2/projects/{projectId}/events", "/api/v2/stacks/{stackId}/events" }) + AssertPathResponseCodes(paths, path, "get", "200", "400", "426"); + + foreach (string path in new[] { "/api/v2/stacks", "/api/v2/organizations/{organizationId}/stacks", "/api/v2/projects/{projectId}/stacks" }) + AssertPathResponseCodes(paths, path, "get", "200", "400", "426"); + foreach (string path in new[] { "/api/v1/events", "/api/v1/projects/{projectId}/events", "/api/v2/events", "/api/v2/projects/{projectId}/events" }) { var eventPost = paths.GetProperty(path).GetProperty("post"); @@ -219,6 +228,15 @@ private static void AssertResponseCodes(JsonElement operation, params string[] e Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}'."); } + private static void AssertPathResponseCodes(JsonElement paths, string path, string method, params string[] expectedStatusCodes) + { + var operation = paths.GetProperty(path).GetProperty(method); + var responses = operation.GetProperty("responses"); + string actualStatusCodes = String.Join(", ", responses.EnumerateObject().Select(response => response.Name)); + foreach (string statusCode in expectedStatusCodes) + Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}' for {method.ToUpperInvariant()} {path}. Actual: {actualStatusCodes}."); + } + private static void AssertArrayResponseSchema(JsonElement paths, string path, string expectedItemSchema) { var schema = paths.GetProperty(path) diff --git a/tests/http/events.http b/tests/http/events.http index 271fdcaaa6..6719b17f61 100644 --- a/tests/http/events.http +++ b/tests/http/events.http @@ -49,10 +49,18 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/projects/{{projectId}}/events Authorization: Bearer {{token}} +### By Project With Premium Filter (returns 426 for a free organization) +GET {{apiUrl}}/projects/{{projectId}}/events?filter=tags:important +Authorization: Bearer {{token}} + ### Count GET {{apiUrl}}/events/count?aggregations=date:(date~month+cardinality:stack+sum:count~1)+cardinality:stack+terms:(first+@include:true)+sum:count~1&filter=(status:open+OR+status:regressed) Authorization: Bearer {{token}} +### Count By Project With Premium Filter (returns 426 for a free organization) +GET {{apiUrl}}/projects/{{projectId}}/events/count?filter=tags:important +Authorization: Bearer {{token}} + ### Simple Strings POST {{apiUrl}}/events?access_token={{clientToken}} Content-Type: application/json diff --git a/tests/http/stacks.http b/tests/http/stacks.http index db3f18b9cf..d17ddbd62f 100644 --- a/tests/http/stacks.http +++ b/tests/http/stacks.http @@ -39,6 +39,10 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=tag:production Authorization: Bearer {{token}} +### Get By Project With Premium Filter (returns 426 for a free organization) +GET {{apiUrl}}/projects/{{projectId}}/stacks?filter=title:timeout +Authorization: Bearer {{token}} + ### Get By Organization Id with Filter (e.g., stacks with type and status) GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=type:error%20status:open Authorization: Bearer {{token}} From 6e5b08222d771511867eb4c0245272fd40753498 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 15 Jul 2026 22:35:04 -0500 Subject: [PATCH 6/6] fix: preserve stack mode in count requests --- .../src/lib/features/events/api.svelte.ts | 2 +- .../src/lib/features/events/api.test.ts | 45 ++++++++++++++++++- .../src/routes/(app)/stack/+page.svelte | 3 ++ .../Api/Endpoints/EventEndpointTests.cs | 21 +++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts index 9a57dd1a2a..a50f578bcc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts @@ -113,7 +113,7 @@ export interface GetOrganizationCountRequest { params?: { aggregations?: string; filter?: string; - mode?: 'stack_new'; + mode?: GetEventsMode; offset?: string; time?: string; }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts index a94b4a70aa..d66da04c85 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts @@ -2,11 +2,54 @@ import { ChangeType } from '$features/websockets/models'; import { QueryClient } from '@tanstack/svelte-query'; import { describe, expect, it, vi } from 'vitest'; +const fetchClientMocks = vi.hoisted(() => ({ + getJSON: vi.fn() +})); + vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-token' } })); -import { invalidatePersistentEventQueries, queryKeys } from './api.svelte'; +vi.mock('@exceptionless/fetchclient', () => ({ + useFetchClient: () => ({ getJSON: fetchClientMocks.getJSON }) +})); + +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createQuery: vi.fn((factory) => factory()), + useQueryClient: vi.fn(() => new actual.QueryClient()) + }; +}); + +import { getOrganizationCountQuery, invalidatePersistentEventQueries, queryKeys } from './api.svelte'; + +describe('getOrganizationCountQuery', () => { + it('forwards stack mode with a stack-only filter to the count request', async () => { + // Arrange + fetchClientMocks.getJSON.mockResolvedValue({ data: { aggregations: {}, total: 0 } }); + const query = getOrganizationCountQuery({ + params: { + filter: 'critical:false', + mode: 'stack_frequent' + }, + route: { organizationId: 'organization-id' } + }) as unknown as { queryFn: (context: { signal: AbortSignal }) => Promise }; + + // Act + await query.queryFn({ signal: new AbortController().signal }); + + // Assert + expect(fetchClientMocks.getJSON).toHaveBeenCalledWith('/organizations/organization-id/events/count', { + params: expect.objectContaining({ + filter: 'critical:false', + mode: 'stack_frequent' + }), + signal: expect.any(AbortSignal) + }); + }); +}); describe('invalidatePersistentEventQueries', () => { it('does not invalidate nested count aggregation queries for event updates', async () => { 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 863057e108..b0e977d722 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -721,6 +721,9 @@ get filter() { return eventsQueryParameters.filter; }, + get mode() { + return eventsQueryParameters.mode; + }, get time() { return eventsQueryParameters.time; } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 34d0f15352..2c8a196431 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -877,6 +877,27 @@ await SendRequestAsync(r => r ); } + [Theory] + [InlineData("critical:false")] + [InlineData("first_occurrence:[now-1d TO now]")] + public async Task Handle_GetEventCountByOrganizationInStackModeWithFreeStackFilter_ReturnsOk(string filter) + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject()); + + // Act + var result = await SendRequestAsAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "events", "count") + .QueryString("filter", filter) + .QueryString("mode", "stack_frequent") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + } + [Fact] public async Task Handle_GetEventsByProjectWithPremiumFilterOnFreeOrganization_ReturnsUpgradeRequired() {