From f09562ed7391b5fc92d423ad7faeb70cfbc6206c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:07:22 +0000 Subject: [PATCH 1/6] fix(events): include parent references in by-ref navigation Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../Controllers/EventController.cs | 4 +-- .../Controllers/EventControllerTests.cs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index b506b3679c..61e90da185 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -580,7 +580,7 @@ public async Task>> GetByReferenceIdAs var ti = GetTimeInfo(null, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays, _timeProvider)); var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, before, after, includeTotal: ShouldIncludeTotal(include)); + return await GetInternalAsync(sf, ti, $"(reference:{referenceId} OR ref.parent:{referenceId})", null, mode, page, limit, before, after, includeTotal: ShouldIncludeTotal(include)); } /// @@ -618,7 +618,7 @@ public async Task>> GetByReferenceIdAs var ti = GetTimeInfo(null, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays, _timeProvider)); var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, before, after, includeTotal: ShouldIncludeTotal(include)); + return await GetInternalAsync(sf, ti, $"(reference:{referenceId} OR ref.parent:{referenceId})", null, mode, page, limit, before, after, includeTotal: ShouldIncludeTotal(include)); } /// diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index dca6b4cfaa..910154054b 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -113,6 +113,33 @@ public async Task GetByReferenceIdAsync_WithExistingReference_ReturnsMatchingEve Assert.Equal(referenceId, ev.ReferenceId); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetByReferenceIdAsync_WithOnlyParentReference_ReturnsMatchingEvents(bool projectScoped) + { + // Arrange + string referenceId = Guid.NewGuid().ToString("N"); + await CreateDataAsync(d => d.Event().TestProject().Reference("parent", referenceId).Message("parent reference route")); + await RefreshDataAsync(); + + string[] paths = projectScoped + ? ["projects", SampleDataService.TEST_PROJECT_ID, "events", "by-ref", referenceId] + : ["events", "by-ref", referenceId]; + + // Act + var events = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths(paths) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(events); + var ev = Assert.Single(events); + Assert.Equal(referenceId, ev.GetEventReference("parent")); + } + [Fact] public async Task GetCountByOrganizationAsync_WithExistingEvents_ReturnsOrganizationCount() { From 8f5e64105d98bbd1c86619e62a0be0f2500cc8aa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 13:31:54 -0500 Subject: [PATCH 2/6] test(http): cover event by-reference routes --- tests/http/events.http | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/http/events.http b/tests/http/events.http index 271fdcaaa6..e52d3c35c1 100644 --- a/tests/http/events.http +++ b/tests/http/events.http @@ -71,6 +71,14 @@ Content-Type: application/json "reference_id": "{{referenceId}}" } +### By ReferenceId +GET {{apiUrl}}/events/by-ref/{{referenceId}} +Authorization: Bearer {{token}} + +### By ReferenceId And Project +GET {{apiUrl}}/projects/{{projectId}}/events/by-ref/{{referenceId}} +Authorization: Bearer {{token}} + ### Post User Description POST {{apiUrl}}/events/by-ref/{{referenceId}}/user-description?access_token={{clientToken}} Content-Type: application/json From 6f152edde5cdb59fa173a28ec88fe80cd96f8ef9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Jul 2026 21:17:47 -0500 Subject: [PATCH 3/6] fix(events): keep parent navigation available on free plans --- .../Queries/Validation/PersistentEventQueryValidator.cs | 1 + .../Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs | 6 +++--- .../Search/PersistentEventQueryValidatorTests.cs | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs b/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs index 5ba177186a..f4f2a0970c 100644 --- a/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs +++ b/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs @@ -11,6 +11,7 @@ public sealed class PersistentEventQueryValidator : AppQueryValidator "date", "type", EventIndex.Alias.ReferenceId, + "idx.parent-r", "reference_id", EventIndex.Alias.OrganizationId, "organization_id", diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 53fa1a0bf7..3e628d61cd 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -204,16 +204,16 @@ public async Task GetByReferenceIdAsync_WithOnlyParentReference_ReturnsMatchingE { // Arrange string referenceId = Guid.NewGuid().ToString("N"); - await CreateDataAsync(d => d.Event().TestProject().Reference("parent", referenceId).Message("parent reference route")); + await CreateDataAsync(d => d.Event().FreeProject().Reference("parent", referenceId).Message("parent reference route")); await RefreshDataAsync(); string[] paths = projectScoped - ? ["projects", SampleDataService.TEST_PROJECT_ID, "events", "by-ref", referenceId] + ? ["projects", SampleDataService.FREE_PROJECT_ID, "events", "by-ref", referenceId] : ["events", "by-ref", referenceId]; // Act var events = await SendRequestAsAsync>(r => r - .AsTestOrganizationUser() + .AsFreeOrganizationUser() .AppendPaths(paths) .StatusCodeShouldBeOk() ); diff --git a/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs b/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs index 5209f02d71..7dfe70c3b6 100644 --- a/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs +++ b/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs @@ -48,6 +48,7 @@ public PersistentEventQueryValidatorTests(ITestOutputHelper output) : base(outpu [InlineData("organization:404", "organization:404", true, false)] [InlineData("project:404", "project:404", true, false)] [InlineData("stack:404", "stack:404", true, false)] + [InlineData("ref.parent:12345678", "idx.parent-r:12345678", true, false)] [InlineData("ref.session:12345678", "idx.session-r:12345678", true, true)] [InlineData("status:open", "status:open", true, false)] public async Task CanProcessQueryAsync(string query, string expected, bool isValid, bool usesPremiumFeatures) From 775c14f17cdfbf3eba57500f342ba6c277302c86 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Jul 2026 21:42:23 -0500 Subject: [PATCH 4/6] fix(events): index parent references for free plans --- .../Pipeline/035_CopySimpleDataToIdxAction.cs | 5 ++++ .../Pipeline/EventPipelineTests.cs | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs b/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs index b85723efa2..d1036b763b 100644 --- a/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs +++ b/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs @@ -11,7 +11,12 @@ public CopySimpleDataToIdxAction(AppOptions options, ILoggerFactory loggerFactor public override Task ProcessAsync(EventContext ctx) { if (!ctx.Organization.HasPremiumFeatures) + { + if (ctx.Event.GetEventReference("parent") is not null) + ctx.Event.CopyDataToIndex(["@ref:parent"]); + 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([]); diff --git a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs index d7c94b962e..f24ae55914 100644 --- a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs @@ -580,6 +580,32 @@ public void CanIndexExtendedData() Assert.Equal(11, ev.Idx.Count); } + [Fact] + public async Task ProcessAsync_FreePlanParentReference_IndexesOnlyParentReference() + { + var organization = await _organizationRepository.GetByIdAsync(TestConstants.OrganizationId3, o => o.Cache()); + Assert.NotNull(organization); + Assert.False(organization.HasPremiumFeatures); + + var project = await _projectRepository.AddAsync(_projectData.GenerateProject(organizationId: organization.Id), o => o.ImmediateConsistency().Cache()); + var ev = _eventData.GenerateEvent(organizationId: organization.Id, projectId: project.Id, generateData: false, occurrenceDate: TimeProvider.GetUtcNow()); + ev.SetEventReference("parent", "parent-reference"); + ev.Data!["custom"] = "not indexed"; + + var context = await _pipeline.RunAsync(ev, organization, project); + Assert.False(context.HasError, context.ErrorMessage); + Assert.NotNull(context.Event.Idx); + Assert.Equal("parent-reference", context.Event.Idx["parent-r"]); + Assert.False(context.Event.Idx.ContainsKey("custom-s")); + + await RefreshDataAsync(); + var parentMatches = await _eventRepository.FindAsync(q => q.FieldEquals("idx.parent-r", "parent-reference")); + Assert.Equal(ev.Id, Assert.Single(parentMatches.Documents).Id); + + var customMatches = await _eventRepository.FindAsync(q => q.FieldEquals("idx.custom-s", "not indexed")); + Assert.Empty(customMatches.Documents); + } + [Fact] public async Task SyncStackTagsAsync() { From 0e947736027722b779e11162c05fad95b3a06668 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Jul 2026 22:16:42 -0500 Subject: [PATCH 5/6] fix(events): validate and align reference filters --- .../Extensions/PersistentEventExtensions.cs | 7 +++++-- .../Extensions/StringExtensions.cs | 2 +- src/Exceptionless.Core/Models/Event.cs | 6 ++++++ .../Pipeline/035_CopySimpleDataToIdxAction.cs | 7 ++++--- .../Validation/PersistentEventQueryValidator.cs | 9 ++++++--- .../Queries/Visitors/EventFieldsQueryVisitor.cs | 2 +- src/Exceptionless.Web/Api/Handlers/EventHandler.cs | 8 ++++---- .../components/filters/helpers.svelte.test.ts | 10 ++++++++++ .../events/components/filters/models.svelte.ts | 3 ++- .../events/components/views/overview.svelte | 14 +++++++++++++- .../(app)/event/by-ref/[referenceId]/+page.svelte | 3 ++- .../Pipeline/EventPipelineTests.cs | 13 +++++++++++++ .../Search/PersistentEventQueryValidatorTests.cs | 1 + .../Utility/StringExtensionsTests.cs | 10 ++++++++++ 14 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs index 6a869d0425..537108e6b5 100644 --- a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs @@ -99,6 +99,9 @@ public static void SetEventReference(this PersistentEvent ev, string name, strin { ArgumentException.ThrowIfNullOrEmpty(name); + if (!name.IsValidFieldName()) + throw new ArgumentException("Name must contain between 1 and 25 alphanumeric or '-' characters.", nameof(name)); + if (!IsValidIdentifier(id) || String.IsNullOrEmpty(id)) throw new ArgumentException("Id must contain between 8 and 100 alphanumeric or '-' characters.", nameof(id)); @@ -108,7 +111,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) @@ -119,7 +122,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) diff --git a/src/Exceptionless.Core/Extensions/StringExtensions.cs b/src/Exceptionless.Core/Extensions/StringExtensions.cs index e974c209f4..b80cbfd1f2 100644 --- a/src/Exceptionless.Core/Extensions/StringExtensions.cs +++ b/src/Exceptionless.Core/Extensions/StringExtensions.cs @@ -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); diff --git a/src/Exceptionless.Core/Models/Event.cs b/src/Exceptionless.Core/Models/Event.cs index 84a96c7acd..429aa4ff45 100644 --- a/src/Exceptionless.Core/Models/Event.cs +++ b/src/Exceptionless.Core/Models/Event.cs @@ -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"; diff --git a/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs b/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs index d1036b763b..c4588fe5e4 100644 --- a/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs +++ b/src/Exceptionless.Core/Pipeline/035_CopySimpleDataToIdxAction.cs @@ -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; @@ -12,8 +13,8 @@ public override Task ProcessAsync(EventContext ctx) { if (!ctx.Organization.HasPremiumFeatures) { - if (ctx.Event.GetEventReference("parent") is not null) - ctx.Event.CopyDataToIndex(["@ref:parent"]); + if (ctx.Event.GetEventReference(Event.KnownReferenceNames.Parent) is not null) + ctx.Event.CopyDataToIndex([$"@ref:{Event.KnownReferenceNames.Parent}"]); return Task.CompletedTask; } diff --git a/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs b/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs index f4f2a0970c..d80784baa0 100644 --- a/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs +++ b/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs @@ -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; @@ -11,7 +12,7 @@ public sealed class PersistentEventQueryValidator : AppQueryValidator "date", "type", EventIndex.Alias.ReferenceId, - "idx.parent-r", + $"idx.{Event.KnownReferenceNames.Parent}-r", "reference_id", EventIndex.Alias.OrganizationId, "organization_id", @@ -94,9 +95,11 @@ public PersistentEventQueryValidator(ExceptionlessElasticConfiguration configura 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(_freeQueryFields.Contains) }; } diff --git a/src/Exceptionless.Core/Repositories/Queries/Visitors/EventFieldsQueryVisitor.cs b/src/Exceptionless.Core/Repositories/Queries/Visitors/EventFieldsQueryVisitor.cs index 43065cc189..5bc4774572 100644 --- a/src/Exceptionless.Core/Repositories/Queries/Visitors/EventFieldsQueryVisitor.cs +++ b/src/Exceptionless.Core/Repositories/Queries/Visitors/EventFieldsQueryVisitor.cs @@ -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)) diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index cc72b79bb2..9def44626e 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -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; @@ -230,7 +230,7 @@ public async Task>> 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, $"(reference:{message.ReferenceId} OR ref.parent:{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>> Handle(GetEventsByReferenceIdAndProject message) @@ -249,7 +249,7 @@ public async Task>> 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, $"(reference:{message.ReferenceId} OR ref.parent:{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>> Handle(GetEventsBySessionId message) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts index 7d4ac9bf5a..cc5af14b00 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts @@ -85,6 +85,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 diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts index ce9d78ac2b..6c15226e9f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts @@ -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})`; } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte index a05ca3ee78..1eb313fd96 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/overview.svelte @@ -52,6 +52,10 @@ return refs; }); + function isValidReferenceName(name: string): boolean { + return /^[\p{L}\p{Nd}-]{1,25}$/u.test(name); + } + let level = $derived(event.data?.['@level']?.toLowerCase()); let location = $derived(getLocation(event)); @@ -95,9 +99,17 @@ {#if reference.name === 'session'} Session - {:else} + {:else if reference.name === 'parent'} {reference.name} + {:else if isValidReferenceName(reference.name)} + {reference.name} + + {:else} + {reference.name} + {/if} {reference.id} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/by-ref/[referenceId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/by-ref/[referenceId]/+page.svelte index abc0659be6..b6499b954a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/by-ref/[referenceId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/by-ref/[referenceId]/+page.svelte @@ -6,6 +6,7 @@ import { Button } from '$comp/ui/button'; import { Spinner } from '$comp/ui/spinner'; import { getEventsByReferenceQuery } from '$features/events/api.svelte'; + import { ReferenceFilter } from '$features/events/components/filters'; import Summary from '$features/events/components/summary/summary.svelte'; const referenceId = $derived(page.params.referenceId || ''); @@ -16,7 +17,7 @@ } } }); - const eventListHref = $derived(`${resolve('/(app)/event')}?filter=${encodeURIComponent(`reference:${referenceId}`)}&limit=20`); + const eventListHref = $derived(`${resolve('/(app)/event')}?filter=${encodeURIComponent(new ReferenceFilter(referenceId).toFilter())}&limit=20`); let redirectedEventId = $state(); $effect(() => { diff --git a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs index f24ae55914..3304ca990b 100644 --- a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs @@ -580,6 +580,19 @@ public void CanIndexExtendedData() Assert.Equal(11, ev.Idx.Count); } + [Theory] + [InlineData("")] + [InlineData("invalid_name")] + [InlineData("abcdefghijklmnopqrstuvwxyz")] + public void SetEventReference_InvalidName_Throws(string name) + { + var ev = new PersistentEvent(); + + var exception = Assert.Throws(() => ev.SetEventReference(name, "reference-id")); + + Assert.Equal("name", exception.ParamName); + } + [Fact] public async Task ProcessAsync_FreePlanParentReference_IndexesOnlyParentReference() { diff --git a/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs b/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs index 7dfe70c3b6..3997c6ee41 100644 --- a/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs +++ b/tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs @@ -50,6 +50,7 @@ public PersistentEventQueryValidatorTests(ITestOutputHelper output) : base(outpu [InlineData("stack:404", "stack:404", true, false)] [InlineData("ref.parent:12345678", "idx.parent-r:12345678", true, false)] [InlineData("ref.session:12345678", "idx.session-r:12345678", true, true)] + [InlineData("ref.invalid_name:12345678", "ref.invalid_name:12345678", false, true)] [InlineData("status:open", "status:open", true, false)] public async Task CanProcessQueryAsync(string query, string expected, bool isValid, bool usesPremiumFeatures) { diff --git a/tests/Exceptionless.Tests/Utility/StringExtensionsTests.cs b/tests/Exceptionless.Tests/Utility/StringExtensionsTests.cs index 78435125f0..b1f29a1306 100644 --- a/tests/Exceptionless.Tests/Utility/StringExtensionsTests.cs +++ b/tests/Exceptionless.Tests/Utility/StringExtensionsTests.cs @@ -309,6 +309,16 @@ public void IsValidFieldName_NullInput_ReturnsFalse() Assert.False(result); } + [Fact] + public void IsValidFieldName_EmptyInput_ReturnsFalse() + { + // Act + bool result = String.Empty.IsValidFieldName(); + + // Assert + Assert.False(result); + } + [Fact] public void IsValidFieldName_Over25Characters_ReturnsFalse() { From 5f0dd46364dc59349c0ad0ba5151ada8e815c3c6 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 20 Jul 2026 22:23:40 -0500 Subject: [PATCH 6/6] fix(events): backfill retained parent references --- .../003_BackfillParentReferences.cs | 66 +++++++++++++++++++ .../BackfillParentReferencesMigrationTests.cs | 50 ++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/Exceptionless.Core/Migrations/003_BackfillParentReferences.cs create mode 100644 tests/Exceptionless.Tests/Migrations/BackfillParentReferencesMigrationTests.cs diff --git a/src/Exceptionless.Core/Migrations/003_BackfillParentReferences.cs b/src/Exceptionless.Core/Migrations/003_BackfillParentReferences.cs new file mode 100644 index 0000000000..ff9ba76e6e --- /dev/null +++ b/src/Exceptionless.Core/Migrations/003_BackfillParentReferences.cs @@ -0,0 +1,66 @@ +using System.Diagnostics; +using Elastic.Clients.Elasticsearch; +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 = 3; + } + + public override async Task RunAsync(MigrationContext context) + { + string referenceKey = $"@ref:{Event.KnownReferenceNames.Parent}"; + string indexKey = $"{Event.KnownReferenceNames.Parent}-r"; + string script = $"if (ctx._source.data != null && ctx._source.data.containsKey('{referenceKey}') && ctx._source.data['{referenceKey}'] != null) {{ if (ctx._source.idx == null) ctx._source.idx = [:]; ctx._source.idx['{indexKey}'] = ctx._source.data['{referenceKey}']; }} else {{ ctx.op = 'noop'; }}"; + + _logger.LogInformation("Backfilling retained event parent references"); + var stopwatch = Stopwatch.StartNew(); + var response = await _client.UpdateByQueryAsync(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)) + .Conflicts(Conflicts.Proceed) + .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) + { + _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(); + } +} diff --git a/tests/Exceptionless.Tests/Migrations/BackfillParentReferencesMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/BackfillParentReferencesMigrationTests.cs new file mode 100644 index 0000000000..1803549bc5 --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/BackfillParentReferencesMigrationTests.cs @@ -0,0 +1,50 @@ +using Exceptionless.Core.Migrations; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Tests.Utility; +using Foundatio.Lock; +using Foundatio.Repositories; +using Foundatio.Repositories.Migrations; +using Foundatio.Utility; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class BackfillParentReferencesMigrationTests : IntegrationTestsBase +{ + private readonly EventData _eventData; + private readonly IEventRepository _eventRepository; + + public BackfillParentReferencesMigrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _eventData = GetService(); + _eventRepository = GetService(); + } + + protected override void RegisterServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(EmptyLock.Empty); + base.RegisterServices(services); + } + + [Fact] + public async Task WillBackfillRetainedParentReference() + { + var ev = _eventData.GenerateEvent(organizationId: TestConstants.OrganizationId, projectId: TestConstants.ProjectId, stackId: TestConstants.StackId, generateData: false, occurrenceDate: TimeProvider.GetUtcNow()); + ev.Data = new() { [$"@ref:{Event.KnownReferenceNames.Parent}"] = "parent-reference" }; + ev.Idx = null; + await _eventRepository.AddAsync(ev, options => options.ImmediateConsistency()); + + var before = await _eventRepository.FindAsync(query => query.FieldEquals("idx.parent-r", "parent-reference")); + Assert.Empty(before.Documents); + + var migration = GetService(); + var context = new MigrationContext(GetService(), _logger, TestCancellationToken); + await migration.RunAsync(context); + await RefreshDataAsync(); + + var after = await _eventRepository.FindAsync(query => query.FieldEquals("idx.parent-r", "parent-reference")); + Assert.Equal(ev.Id, Assert.Single(after.Documents).Id); + } +}