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
16 changes: 12 additions & 4 deletions src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder
.RequireAuthorization(AuthorizationRoles.EventsReadPolicy)
.Produces<CountResult>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status426UpgradeRequired)
.WithSummary("Count")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
Expand All @@ -42,6 +43,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder
},
ResponseDescriptions = new() {
["400"] = "Invalid filter.",
["426"] = "Premium search features require an upgraded plan.",
}
});

Expand All @@ -50,6 +52,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder
.RequireAuthorization(AuthorizationRoles.EventsReadPolicy)
.Produces<CountResult>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status426UpgradeRequired)
.WithSummary("Count by organization")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
Expand All @@ -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.",
}
});

Expand All @@ -70,6 +74,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder
.RequireAuthorization(AuthorizationRoles.EventsReadPolicy)
.Produces<CountResult>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status426UpgradeRequired)
.WithSummary("Count by project")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
Expand All @@ -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.",
}
});

Expand Down Expand Up @@ -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.",
}
});

Expand Down Expand Up @@ -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.",
}
});

Expand Down Expand Up @@ -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.",
}
});

Expand Down Expand Up @@ -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.",
}
});

Expand Down Expand Up @@ -344,6 +350,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder
.RequireAuthorization(AuthorizationRoles.EventsReadPolicy)
.Produces<IReadOnlyCollection<PersistentEvent>>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status426UpgradeRequired)
.WithSummary("Get a list of all sessions")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
Expand All @@ -360,6 +367,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder
},
ResponseDescriptions = new() {
["400"] = "Invalid filter.",
["426"] = "Premium session search requires an upgraded plan.",
}
});

Expand Down
6 changes: 4 additions & 2 deletions src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder
.RequireAuthorization(AuthorizationRoles.StacksReadPolicy)
.Produces<IReadOnlyCollection<Stack>>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status426UpgradeRequired)
.WithSummary("Get all")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
Expand All @@ -250,6 +251,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder
},
ResponseDescriptions = new() {
["400"] = "Invalid filter.",
["426"] = "Premium search features require an upgraded plan.",
}
});

Expand All @@ -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.",
}
});

Expand All @@ -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.",
}
});

Expand Down
18 changes: 16 additions & 2 deletions src/Exceptionless.Web/Api/Handlers/EventHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public class EventHandler(
ICacheClient cacheClient,
ITextSerializer serializer,
PersistentEventQueryValidator validator,
StackQueryValidator stackValidator,
AppOptions appOptions,
UsageService usageService,
TimeProvider timeProvider,
Expand Down Expand Up @@ -687,7 +688,9 @@ public async Task<Result<WorkInProgressResult>> Handle(DeleteEvents message)

private async Task<Result<CountResult>> CountInternalAsync(AppFilter sf, TimeInfo ti, HttpContext httpContext, string? filter = null, string? aggregations = null, string? mode = null)
{
var pr = await validator.ValidateQueryAsync(filter);
var pr = IsStackMode(mode)
? await stackValidator.ValidateQueryAsync(filter)
: await validator.ValidateQueryAsync(filter);
Comment thread
niemyjski marked this conversation as resolved.
if (!pr.IsValid)
return Result.BadRequest(pr.Message ?? "Invalid filter.");

Expand All @@ -696,6 +699,8 @@ private async Task<Result<CountResult>> CountInternalAsync(AppFilter sf, TimeInf
return Result.BadRequest(far.Message ?? "Invalid aggregations.");

sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures;
if (ApiValidation.IsPremiumFeatureQueryBlocked(sf))
return PlanLimitResult<CountResult>("Please upgrade your plan to use premium search features.");

if (mode == "stack_new")
filter = AddFirstOccurrenceFilter(ti.Range, filter);
Expand Down Expand Up @@ -749,11 +754,15 @@ private async Task<Result<PagedResult<object>>> GetInternalAsync(AppFilter sf, T
if (skip > Pagination.MaximumSkip)
return new PagedResult<object>(Array.Empty<PersistentEvent>(), false);

var pr = await validator.ValidateQueryAsync(filter);
var pr = IsStackMode(mode)
? await stackValidator.ValidateQueryAsync(filter)
: await validator.ValidateQueryAsync(filter);
if (!pr.IsValid)
return Result.BadRequest(pr.Message ?? "Invalid filter.");

sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures;
if (ApiValidation.IsPremiumFeatureQueryBlocked(sf))
return PlanLimitResult<PagedResult<object>>("Please upgrade your plan to use premium search features.");
Comment thread
niemyjski marked this conversation as resolved.

try
{
Expand Down Expand Up @@ -874,6 +883,11 @@ private static string AddFirstOccurrenceFilter(DateTimeRange timeRange, string?
return sb.ToString();
}

private static bool IsStackMode(string? mode)
{
return mode is "stack_recent" or "stack_frequent" or "stack_new" or "stack_users";
}

private Task<FindResults<PersistentEvent>> GetEventsInternalAsync(AppFilter sf, TimeInfo ti, string? filter, string? sort, int? page, int limit, string? before, string? after, bool includeTotal, HttpRequest? request = null)
{
if (String.IsNullOrEmpty(sort))
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Web/Api/Handlers/StackHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ private async Task<Result<PagedResult<object>>> GetInternalAsync(AppFilter sf, T
return Result.BadRequest(pr.Message ?? "Invalid filter.");

sf.UsesPremiumFeatures = pr.UsesPremiumFeatures;
if (ApiValidation.IsPremiumFeatureQueryBlocked(sf))
return PlanLimitResult<PagedResult<object>>("Please upgrade your plan to use premium search features.");

try
{
Expand Down
8 changes: 8 additions & 0 deletions src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
using Exceptionless.Core.Repositories.Queries;
using Microsoft.AspNetCore.Http.Features;

namespace Exceptionless.Web.Api.Infrastructure;

public static class ApiValidation
{
public static bool IsPremiumFeatureQueryBlocked(AppFilter filter)
{
return filter.UsesPremiumFeatures
&& filter.Organizations.Count > 0
&& filter.Organizations.All(organization => !organization.HasPremiumFeatures);
}

public static IResult MissingRequestBody()
{
return global::Microsoft.AspNetCore.Http.Results.ValidationProblem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export interface GetOrganizationCountRequest {
params?: {
aggregations?: string;
filter?: string;
mode?: 'stack_new';
mode?: GetEventsMode;
offset?: string;
time?: string;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@tanstack/svelte-query')>();
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<unknown> };

// 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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';

import { filterUsesPremiumFeatures, getSearchResourceForPathname } 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"', '-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',
(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);
});
});

describe('getSearchResourceForPathname', () => {
it.each(['/stack', '/next/stack/saved-view', '/project/537650f3b77efe23a47914f4/stacks'])('identifies stack search routes: %s', (pathname) => {
expect(getSearchResourceForPathname(pathname)).toBe('stack');
});

it.each(['/event', '/stream', '/sessions'])('identifies event search routes: %s', (pathname) => {
expect(getSearchResourceForPathname(pathname)).toBe('event');
});
});
Original file line number Diff line number Diff line change
@@ -1,41 +1,48 @@
/**
* 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()));
}

export function getSearchResourceForPathname(pathname: string): SearchResource {
return /(?:^|\/)stack(?:\/|$)/.test(pathname) || /\/project\/[^/]+\/stacks(?:\/|$)/.test(pathname) ? 'stack' : 'event';
}

/**
* Extracts field names from a Lucene-style filter string.
* 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;

Expand Down
Loading
Loading