Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<SearchResource, ReadonlySet<string>> = {
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()));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -642,6 +644,10 @@
}
});

$effect(() => {
premiumPage.current = filterUsesPremiumFeatures(eventsQueryParameters.filter, 'event') ? 'search' : undefined;
});

const client = useFetchClient();
const clientStatus = useFetchClientStatus(client);
let clientResponse = $state<FetchClientResponse<EventSummaryModel<SummaryTemplateKeys>[]>>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -610,6 +612,10 @@
}
});

$effect(() => {
premiumPage.current = filterUsesPremiumFeatures(eventsQueryParameters.filter, 'stack') ? 'search' : undefined;
});

const client = useFetchClient();
const clientStatus = useFetchClientStatus(client);
let clientResponse = $state<FetchClientResponse<EventSummaryModel<SummaryTemplateKeys>[]>>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/Exceptionless.Web/Controllers/EventController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ private async Task<ActionResult<CountResult>> CountInternalAsync(AppFilter sf, T
return BadRequest(far.Message);

sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures;
if (IsPremiumFeatureQueryBlocked(sf))
return PlanLimitReached("Please upgrade your plan to use premium search features.");

if (mode == "stack_new")
filter = AddFirstOccurrenceFilter(ti.Range, filter);
Expand Down Expand Up @@ -302,6 +304,8 @@ private async Task<ActionResult<ICollection<PersistentEvent>>> GetInternalAsync(
return BadRequest(pr.Message);

sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures;
if (IsPremiumFeatureQueryBlocked(sf))
return PlanLimitReached("Please upgrade your plan to use premium search features.");
Comment on lines +307 to +308

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve free stack filters in stack-mode event searches

For free organizations using the Svelte Stacks page with a stack-only free filter such as first_occurrence, last_occurrence, or critical, this new block returns 426 before the stack-mode path can apply EnforceEventStackFilter(). The page still requests organizations/{organizationId}/events with mode: 'stack_frequent' (stack/+page.svelte lines 598 and 669), so this method validates those stack filters with PersistentEventQueryValidator, whose free event fields do not include the stack-only fields that EventStackFilter later supports. That makes the new “free stack fields” UI guidance disagree with the API and breaks those stack searches for free plans.

Useful? React with 👍 / 👎.


try
{
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Web/Controllers/StackController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,8 @@ private async Task<ActionResult<IReadOnlyCollection<Stack>>> GetInternalAsync(Ap
return BadRequest(pr.Message);

sf.UsesPremiumFeatures = pr.UsesPremiumFeatures;
if (IsPremiumFeatureQueryBlocked(sf))
return PlanLimitReached("Please upgrade your plan to use premium search features.");

try
{
Expand Down
30 changes: 30 additions & 0 deletions tests/Exceptionless.Tests/Controllers/EventControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,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()
{
Expand Down
15 changes: 15 additions & 0 deletions tests/Exceptionless.Tests/Controllers/StackControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading