Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f09562e
fix(events): include parent references in by-ref navigation
Copilot Jun 1, 2026
8f5e641
test(http): cover event by-reference routes
niemyjski Jul 10, 2026
ae3cc52
Merge origin/main into copilot/fix-parent-url-navigation
niemyjski Jul 16, 2026
311c5d5
Merge remote-tracking branch 'origin/main' into issue/pr-2280-parent-…
niemyjski Jul 21, 2026
6f152ed
fix(events): keep parent navigation available on free plans
niemyjski Jul 21, 2026
775c14f
fix(events): index parent references for free plans
niemyjski Jul 21, 2026
0e94773
fix(events): validate and align reference filters
niemyjski Jul 21, 2026
5f0dd46
fix(events): backfill retained parent references
niemyjski Jul 21, 2026
6942014
Merge remote-tracking branch 'origin/main' into issue/pr-2280-parent-…
niemyjski Jul 26, 2026
a507d66
fix(events): align free parent reference navigation
niemyjski Jul 26, 2026
85e61ce
fix(events): preserve legacy references and migration safety
niemyjski Jul 26, 2026
3e5f263
fix(events): backfill cased parent references
niemyjski Jul 26, 2026
d7a0b37
fix(events): report truncated reference results
niemyjski Jul 26, 2026
2991ef0
fix(events): recognize reference field names
niemyjski Jul 26, 2026
5d986d8
Merge remote-tracking branch 'origin/main' into copilot/fix-parent-ur…
niemyjski Aug 5, 2026
11f79ad
fix(events): preserve unicode reference premium checks
niemyjski Aug 5, 2026
d4b3e97
Merge remote-tracking branch 'origin/main' into copilot/fix-parent-ur…
niemyjski Aug 8, 2026
008495e
Merge remote-tracking branch 'origin/main' into copilot/fix-parent-ur…
niemyjski Aug 12, 2026
11f16c5
fix(migrations): assign parent backfill version 6
niemyjski Aug 12, 2026
bf2ac52
fix(events): match built-in references case-insensitively
niemyjski Aug 12, 2026
90b02f9
fix(events): align reference name validation
niemyjski Aug 12, 2026
2409d9c
Merge remote-tracking branch 'origin/main' into HEAD
niemyjski Aug 19, 2026
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
Expand Up @@ -108,7 +108,7 @@ public static void SetEventReference(this PersistentEvent ev, string name, strin

public static string? GetSessionId(this PersistentEvent ev)
{
return ev.IsSessionStart() ? ev.ReferenceId : ev.GetEventReference("session");
return ev.IsSessionStart() ? ev.ReferenceId : ev.GetEventReference(Event.KnownReferenceNames.Session);
}

public static void SetSessionId(this PersistentEvent ev, string sessionId)
Expand All @@ -119,7 +119,7 @@ public static void SetSessionId(this PersistentEvent ev, string sessionId)
if (ev.IsSessionStart())
ev.ReferenceId = sessionId;
else
ev.SetEventReference("session", sessionId);
ev.SetEventReference(Event.KnownReferenceNames.Session, sessionId);
}

public static bool HasSessionEndTime(this PersistentEvent ev)
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Core/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ public static bool IsNumeric(this string? value)

public static bool IsValidFieldName(this string? value)
{
if (value is null || value.Length > 25)
if (String.IsNullOrEmpty(value) || value.Length > 25)
return false;

return IsValidIdentifier(value);
Expand Down
113 changes: 113 additions & 0 deletions src/Exceptionless.Core/Migrations/006_BackfillParentReferences.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Diagnostics;
using System.Text.Json;
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.Tasks;
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 BackfillParentReferences : MigrationBase
{
private readonly ElasticsearchClient _client;
private readonly ExceptionlessElasticConfiguration _config;
private readonly TimeProvider _timeProvider;

public BackfillParentReferences(ExceptionlessElasticConfiguration configuration, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(loggerFactory)
{
_config = configuration;
_client = configuration.Client;
_timeProvider = timeProvider;

MigrationType = MigrationType.VersionedAndResumable;
Version = 6;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Assign the backfill a version greater than 7

When an environment has already completed MigrateLegacyStripeSuspensionUserId version 7, the migration manager treats all lower versioned migrations as complete—the setup in MigrateSavedViewColumnsIntegrationTests.cs:95-104 explicitly records only version 7 to mark every current versioned migration complete. Introducing this backfill afterward as version 6 therefore skips it in those environments, leaving retained events without idx.parent-r; assign it a new version above the current maximum.

AGENTS.md reference: AGENTS.md:L72-L74

Useful? React with 👍 / 👎.

}

public override async Task RunAsync(MigrationContext context)
{
string referenceKey = $"@ref:{Event.KnownReferenceNames.Parent}";
string indexKey = $"{Event.KnownReferenceNames.Parent}-r";
string script = $$"""
def parentReference = null;
if (ctx._source.data != null) {
parentReference = ctx._source.data['{{referenceKey}}'];
if (parentReference == null) {
for (def entry : ctx._source.data.entrySet()) {
if (entry.getKey().equalsIgnoreCase('{{referenceKey}}')) {
parentReference = entry.getValue();
break;
}
}
}
}
if (parentReference != null) {
if (ctx._source.idx == null) ctx._source.idx = [:];
ctx._source.idx['{{indexKey}}'] = parentReference.toString();
} else {
ctx.op = 'noop';
}
""";

_logger.LogInformation("Backfilling retained event parent references");
var stopwatch = Stopwatch.StartNew();
var response = await _client.UpdateByQueryAsync<PersistentEvent>(request => request
.Indices($"{_config.Events.VersionedName}-*")
.Query(query => query.Bool(filter => filter.MustNot(mustNot => mustNot.Exists(exists => exists.Field($"idx.{indexKey}")))))
.Script(value => value.Source(script).Lang(ScriptLanguage.Painless))
.WaitForCompletion(false));
_logger.LogRequest(response, LogLevel.Information);

if (!response.IsValidResponse || response.Task is null)
throw new ApplicationException($"Unable to start parent-reference backfill: {response.DebugInformation}");

int attempts = 0;
while (!context.CancellationToken.IsCancellationRequested)
{
var taskStatus = await _client.Tasks.GetAsync(response.Task.FullyQualifiedId, context.CancellationToken);
if (!taskStatus.IsValidResponse)
throw new ApplicationException($"Unable to monitor parent-reference backfill: {taskStatus.DebugInformation}");

if (taskStatus.Completed)
{
EnsureTaskSucceeded(taskStatus);
_logger.LogInformation("Finished parent-reference backfill: Duration={Duration}", stopwatch.Elapsed);
return;
}

attempts++;
await context.Lock.RenewAsync();
await Task.Delay(TimeSpan.FromSeconds(attempts <= 5 ? 1 : 5), _timeProvider, context.CancellationToken);
}

context.CancellationToken.ThrowIfCancellationRequested();
}

internal static void EnsureTaskSucceeded(GetTasksResponse taskStatus)
{
if (taskStatus.Error is not null)
throw new ApplicationException($"Parent-reference backfill failed: {JsonSerializer.Serialize(taskStatus.Error)}");

if (taskStatus.Response is null)
return;

JsonElement response = JsonSerializer.SerializeToElement(taskStatus.Response);
if (response.ValueKind == JsonValueKind.Object
&& response.TryGetProperty("version_conflicts", out var versionConflicts)
&& versionConflicts.TryGetInt64(out long conflictCount)
&& conflictCount > 0)
{
throw new ApplicationException($"Parent-reference backfill failed with {conflictCount} version conflicts.");
}

if (response.ValueKind == JsonValueKind.Object
&& response.TryGetProperty("failures", out var failures)
&& failures.ValueKind == JsonValueKind.Array
&& failures.GetArrayLength() > 0)
{
throw new ApplicationException($"Parent-reference backfill failed: {failures}");
}
}
}
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Models/Event.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ public static class KnownTags
public const string Internal = "Internal";
}

public static class KnownReferenceNames
{
public const string Parent = "parent";
public const string Session = "session";
}

public static class KnownDataKeys
{
public const string Error = "@error";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Exceptionless.Core.Plugins.EventProcessor;
using Exceptionless.Core.Models;
using Exceptionless.Core.Plugins.EventProcessor;
using Microsoft.Extensions.Logging;

namespace Exceptionless.Core.Pipeline;
Expand All @@ -11,7 +12,12 @@ public CopySimpleDataToIdxAction(AppOptions options, ILoggerFactory loggerFactor
public override Task ProcessAsync(EventContext ctx)
{
if (!ctx.Organization.HasPremiumFeatures)
{
if (ctx.Event.GetEventReference(Event.KnownReferenceNames.Parent) is not null)
ctx.Event.CopyDataToIndex([$"@ref:{Event.KnownReferenceNames.Parent}"]);
Comment on lines +16 to +17

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 Normalize parent-reference keys before free-plan lookup

When raw event data contains a key with surrounding whitespace such as " @ref:parent ", the existing CopyDataToIndex path accepts it by trimming the key, but this exact GetEventReference lookup returns null, so free-plan ingestion never creates idx.parent-r. The migration has the same gap at 006_BackfillParentReferences.cs:39, where it compares case-insensitively without trimming, leaving retained free events with these previously accepted keys undiscoverable through the new by-reference navigation; normalize keys consistently in both paths.

AGENTS.md reference: AGENTS.md:L72-L74

Useful? React with 👍 / 👎.


return Task.CompletedTask;
}

// TODO: Do we need a pipeline action to trim keys and remove null values that may be sent by other native clients.
ctx.Event.CopyDataToIndex([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Exceptionless.Core.Repositories.Configuration;
using Exceptionless.Core.Models;
using Exceptionless.Core.Repositories.Configuration;
using Foundatio.Parsers.LuceneQueries;
using Foundatio.Parsers.LuceneQueries.Visitors;
using Microsoft.Extensions.Logging;
Expand All @@ -11,6 +12,7 @@ public sealed class PersistentEventQueryValidator : AppQueryValidator
"date",
"type",
EventIndex.Alias.ReferenceId,
$"idx.{Event.KnownReferenceNames.Parent}-r",
"reference_id",
EventIndex.Alias.OrganizationId,
"organization_id",
Expand Down Expand Up @@ -98,9 +100,11 @@ internal static bool IsFreeQueryField(string field)

protected override QueryProcessResult ApplyQueryRules(QueryValidationResult result)
{
bool hasInvalidReferenceField = result.ReferencedFields.Any(field => field.StartsWith("ref.", StringComparison.OrdinalIgnoreCase));
return new QueryProcessResult
{
IsValid = result.IsValid,
IsValid = result.IsValid && !hasInvalidReferenceField,
Message = hasInvalidReferenceField ? "Invalid reference field name" : null,
UsesPremiumFeatures = !result.ReferencedFields.All(IsFreeQueryField)
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public override Task VisitAsync(MissingNode node, IQueryVisitorContext context)
return null;

string[] parts = field.Split('.');
if (parts.Length != 2 || (parts.Length == 2 && parts[1].StartsWith("@")))
if (parts.Length != 2 || parts[1].StartsWith("@") || !parts[1].IsValidFieldName())
return field;

if (String.Equals(parts[0], "data", StringComparison.OrdinalIgnoreCase))
Expand Down
8 changes: 4 additions & 4 deletions src/Exceptionless.Web/Api/Handlers/EventHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@
using Exceptionless.Core.Validation;
using Exceptionless.DateTimeExtensions;
using Exceptionless.Web.Api.Infrastructure;
using Exceptionless.Web.Api.Results;
using Exceptionless.Web.Api.Messages;
using Exceptionless.Web.Api.Results;
using Exceptionless.Web.Extensions;
using Exceptionless.Web.Models;
using Exceptionless.Web.Utility;
using Foundatio.Caching;
using Foundatio.Mediator;
using Foundatio.Queues;
using Foundatio.Serializer;
using Foundatio.Repositories;
using Foundatio.Repositories.Elasticsearch.Extensions;
using Foundatio.Repositories.Extensions;
using Foundatio.Repositories.Models;
using Foundatio.Serializer;
using Microsoft.Net.Http.Headers;

namespace Exceptionless.Web.Api.Handlers;
Expand Down Expand Up @@ -231,7 +231,7 @@ public async Task<Result<PagedResult<object>>> Handle(GetEventsByReferenceId mes

var ti = TimeRangeParser.GetTimeInfo(null, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organizations.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider));
var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true };
return await GetInternalAsync(sf, ti, httpContext, String.Concat("reference:", message.ReferenceId), null, message.Mode, message.Page, message.Limit, message.Before, message.After, includeTotal: ShouldIncludeTotal(message.Include));
return await GetInternalAsync(sf, ti, httpContext, $"(reference:{message.ReferenceId} OR ref.{Event.KnownReferenceNames.Parent}:{message.ReferenceId})", null, message.Mode, message.Page, message.Limit, message.Before, message.After, includeTotal: ShouldIncludeTotal(message.Include));
Comment thread
niemyjski marked this conversation as resolved.
}

public async Task<Result<PagedResult<object>>> Handle(GetEventsByReferenceIdAndProject message)
Expand All @@ -250,7 +250,7 @@ public async Task<Result<PagedResult<object>>> Handle(GetEventsByReferenceIdAndP

var ti = TimeRangeParser.GetTimeInfo(null, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(project, appOptions.MaximumRetentionDays, timeProvider));
var sf = new AppFilter(project, organization);
return await GetInternalAsync(sf, ti, httpContext, String.Concat("reference:", message.ReferenceId), null, message.Mode, message.Page, message.Limit, message.Before, message.After, includeTotal: ShouldIncludeTotal(message.Include));
return await GetInternalAsync(sf, ti, httpContext, $"(reference:{message.ReferenceId} OR ref.{Event.KnownReferenceNames.Parent}:{message.ReferenceId})", null, message.Mode, message.Page, message.Limit, message.Before, message.After, includeTotal: ShouldIncludeTotal(message.Include));
}

public async Task<Result<PagedResult<object>>> Handle(GetEventsBySessionId message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export function getEventQuery(request: GetEventRequest) {
}

export function getEventsByReferenceQuery(request: GetEventsByReferenceRequest) {
return createQuery<EventSummaryModel<SummaryTemplateKeys>[], ProblemDetails>(() => ({
return createQuery<FetchClientResponse<EventSummaryModel<SummaryTemplateKeys>[]>, ProblemDetails>(() => ({
enabled: () => !!accessToken.current && !!request.route.referenceId,
queryFn: async () => {
const client = useFetchClient();
Expand All @@ -384,11 +384,12 @@ export function getEventsByReferenceQuery(request: GetEventsByReferenceRequest)
limit: 20,
mode: 'summary',
page: 1,
...request.params
...request.params,
include: 'total'
}
});

return response.data!;
return response;
},
queryKey: queryKeys.eventsByReference(request.route.referenceId, request.route.projectId, request.params)
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ describe('TagFilter', () => {
});
});

describe('ReferenceFilter', () => {
it('matches direct and parent references', () => {
expect(new ReferenceFilter('ref-123').toFilter()).toBe('(reference:"ref-123" OR ref.parent:"ref-123")');
});

it('quotes the reference in both clauses', () => {
expect(new ReferenceFilter('ref 123').toFilter()).toBe('(reference:"ref 123" OR ref.parent:"ref 123")');
});
});

describe('applyTimeFilter', () => {
it('removes an existing date filter when time is explicitly empty', () => {
// Arrange
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ export class ReferenceFilter implements IFilter {
return '';
}

return `reference:${quoteIfSpecialCharacters(this.value)}`;
const reference = quoteIfSpecialCharacters(this.value);
return `(reference:${reference} OR ref.parent:${reference})`;
Comment thread
niemyjski marked this conversation as resolved.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
return refs;
});

function isValidReferenceName(name: string): boolean {
return !/[\uD800-\uDFFF]/.test(name) && /^[\p{L}\p{Nd}-]{1,25}$/u.test(name);
}

let level = $derived(event.data?.['@level']?.toLowerCase());
let location = $derived(getLocation(event));

Expand Down Expand Up @@ -101,12 +105,20 @@
{/if}
{#each references as reference (reference.id)}
<Table.Row class="group">
{#if reference.name === 'session'}
{#if reference.name.toLowerCase() === 'session'}
<Table.Head class="w-40 font-semibold whitespace-nowrap">Session</Table.Head>
<Table.Cell class="w-4 pr-0"><EventsFacetedFilter.SessionTrigger changed={filterChanged} value={reference.id} /></Table.Cell>
{:else}
{:else if reference.name.toLowerCase() === 'parent'}
<Table.Head class="w-40 font-semibold whitespace-nowrap">{reference.name}</Table.Head>
<Table.Cell class="w-4 pr-0"><EventsFacetedFilter.ReferenceTrigger changed={filterChanged} value={reference.id} /></Table.Cell>
{:else if isValidReferenceName(reference.name)}
<Table.Head class="w-40 font-semibold whitespace-nowrap">{reference.name}</Table.Head>
<Table.Cell class="w-4 pr-0"
><EventsFacetedFilter.StringTrigger changed={filterChanged} term={`ref.${reference.name}`} value={reference.id} /></Table.Cell
Comment thread
niemyjski marked this conversation as resolved.
>
{:else}
<Table.Head class="w-40 font-semibold whitespace-nowrap">{reference.name}</Table.Head>
<Table.Cell class="w-4 pr-0"></Table.Cell>
{/if}
<Table.Cell
><A
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';

vi.mock('$env/dynamic/public', () => ({ env: {} }));

import { ReferenceFilter, SessionFilter } from '$features/events/components/filters';

import type { PersistentEvent } from '../../models';

import Overview from './overview.svelte';

describe('Overview', () => {
it.each([
['Parent', 'reference', ReferenceFilter],
['SESSION', 'session', SessionFilter]
])('uses the built-in %s filter case-insensitively', async (referenceName, filterName, FilterType) => {
const filterChanged = vi.fn();
const event = {
data: { [`@ref:${referenceName}`]: 'reference-id' }
} as PersistentEvent;

render(Overview, { event, filterChanged });
await fireEvent.click(screen.getByTitle(`Filter ${filterName}:reference-id`));

expect(filterChanged).toHaveBeenCalledOnce();
expect(filterChanged).toHaveBeenCalledWith(expect.any(FilterType));
});

it('does not offer a filter for a supplementary-plane reference name rejected by the backend', () => {
const event = {
data: { '@ref:𐐀': 'reference-id' }
} as unknown as PersistentEvent;

render(Overview, { event, filterChanged: vi.fn() });

expect(screen.queryByTitle('Filter ref.𐐀:reference-id')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import { describe, expect, it } from 'vitest';
import { filterUsesPremiumFeatures, getSearchResourceForPathname } from './premium-filter';

describe('filterUsesPremiumFeatures', () => {
it('keeps built-in parent reference navigation available without premium features', () => {
expect(filterUsesPremiumFeatures('(reference:"parent-id" OR ref.parent:"parent-id")', 'event')).toBe(false);
});

it('still requires premium features for custom references', () => {
expect(filterUsesPremiumFeatures('ref.custom:"reference-id"', 'event')).toBe(true);
});

it.each(['ref.order-id:"reference-id"', 'ref.订单-1:"reference-id"'])('recognizes backend-valid custom reference field %s', (filter) => {
expect(filterUsesPremiumFeatures(filter, 'event')).toBe(true);
});

it.each([
undefined,
null,
Expand Down
Loading
Loading