Skip to content
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);
Comment thread
niemyjski marked this conversation as resolved.
Outdated
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 thread
niemyjski marked this conversation as resolved.
Outdated

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