Skip to content

Commit 845084b

Browse files
niemyjskiCopilot
andcommitted
fix: CI failures — remove unnecessary System.Text.Json ref, fix frontend lint, address PR code quality feedback
- Remove System.Text.Json PackageReference (NU1510 warning-as-error in .NET 10 CI) - Fix ESLint errors: perfectionist import/export ordering, curly braces, escape chars, svelte/require-each-key across custom-fields and event components - Fix prettier formatting in custom-fields Svelte/TS files - PersistentEvent.GetCustomFields(): use LINQ Where, return DataDictionary (Copilot feedback) - EventCustomFieldService: filter before foreach loop (CodeQL Where suggestion), narrow catch clauses to non-cancellation exceptions and typed conversion errors - OrganizationMaintenanceWorkItemHandler: narrow catch to non-cancellation exceptions - Fix ref.session query resolution: system fields use deterministic slots without tenant context - Regenerate OpenAPI baseline for CustomFieldDefinitionResponse DTO changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a5e340a commit 845084b

28 files changed

Lines changed: 773 additions & 269 deletions

File tree

src/Exceptionless.Core/Exceptionless.Core.csproj

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,4 @@
4040
Condition="'$(ReferenceFoundatioRepositoriesSource)' == 'true'" />
4141
</ItemGroup>
4242

43-
<ItemGroup Label="Transitive dependency updates to resolve vulnerability warnings">
44-
<PackageReference Include="System.Text.Json" Version="9.0.1" />
45-
</ItemGroup>
4643
</Project>

src/Exceptionless.Core/Jobs/WorkItemHandlers/OrganizationMaintenanceWorkItemHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public override async Task HandleItemAsync(WorkItemContext context)
6464
{
6565
await _eventCustomFieldService.EnsureSystemFieldsAsync(organization.Id);
6666
}
67-
catch (Exception ex)
67+
catch (Exception ex) when (ex is not OperationCanceledException)
6868
{
6969
Log.LogError(ex, "Error ensuring system custom fields for organization {OrganizationId}", organization.Id);
7070
}

src/Exceptionless.Core/Models/PersistentEvent.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ public class PersistentEvent : Event, IOwnedByOrganizationAndProjectAndStackWith
6060

6161
public IDictionary<string, object?> GetCustomFields()
6262
{
63+
if (Data is null) return new DataDictionary();
6364
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
64-
if (Data is null) return result;
65-
foreach (var kvp in Data)
65+
foreach (var kvp in Data.Where(kvp => !String.IsNullOrEmpty(kvp.Key)
66+
&& (!kvp.Key.StartsWith('@') || kvp.Key.StartsWith("@ref:", StringComparison.OrdinalIgnoreCase))))
6667
{
67-
if (String.IsNullOrEmpty(kvp.Key) || kvp.Key.StartsWith('@')) continue;
6868
if (kvp.Value is string or bool or int or long or float or double or decimal or DateTime or DateTimeOffset)
6969
result[kvp.Key] = kvp.Value;
7070
}

src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ ILoggerFactory loggerFactory
5050
AddIndex(Tokens = new TokenIndex(this));
5151
AddIndex(Users = new UserIndex(this));
5252
AddIndex(WebHooks = new WebHookIndex(this));
53-
AddCustomFieldIndex(_appOptions.ElasticsearchOptions.ScopePrefix + "customfields", appOptions.ElasticsearchOptions.NumberOfReplicas);
5453
}
5554

5655
public Task RunAsync(CancellationToken shutdownToken = default)

src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ public EventIndex(ExceptionlessElasticConfiguration configuration, IServiceProvi
2626
_configuration = configuration;
2727
_serviceProvider = serviceProvider;
2828

29-
AddStandardCustomFieldTypes();
30-
3129
if (appOptions.MaximumRetentionDays > 0)
3230
MaxIndexAge = TimeSpan.FromDays(appOptions.MaximumRetentionDays);
3331

src/Exceptionless.Core/Repositories/EventRepository.cs

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ namespace Exceptionless.Core.Repositories;
1616

1717
public class EventRepository : RepositoryOwnedByOrganizationAndProject<PersistentEvent>, IEventRepository
1818
{
19+
private const string LegacySessionEndIdxField = "sessionend-d";
1920
private readonly TimeProvider _timeProvider;
21+
private readonly IProjectRepository _projectRepository;
22+
private readonly IStackRepository _stackRepository;
2023

21-
public EventRepository(ExceptionlessElasticConfiguration configuration, AppOptions options, MiniValidationValidator validator)
24+
public EventRepository(ExceptionlessElasticConfiguration configuration, AppOptions options, MiniValidationValidator validator, IProjectRepository projectRepository, IStackRepository stackRepository)
2225
: base(configuration.Events, validator, options)
2326
{
2427
_timeProvider = configuration.TimeProvider;
28+
_projectRepository = projectRepository;
29+
_stackRepository = stackRepository;
2530

2631
DisableCache(); // NOTE: If cache is ever enabled, then fast paths for patching/deleting with scripts will be super slow!
2732
BatchNotifications = true;
@@ -38,7 +43,9 @@ public EventRepository(ExceptionlessElasticConfiguration configuration, AppOptio
3843

3944
public Task<FindResults<PersistentEvent>> GetOpenSessionsAsync(DateTime createdBeforeUtc, CommandOptionsDescriptor<PersistentEvent>? options = null)
4045
{
41-
var filter = Query<PersistentEvent>.Term(e => e.Type, Event.KnownTypes.Session) && !Query<PersistentEvent>.Exists(f => f.Field(e => e.Idx![EventCustomFieldService.SessionEndIdxField]));
46+
var filter = Query<PersistentEvent>.Term(e => e.Type, Event.KnownTypes.Session)
47+
&& !Query<PersistentEvent>.Exists(f => f.Field(e => e.Idx![EventCustomFieldService.SessionEndIdxField]))
48+
&& !Query<PersistentEvent>.Exists(f => f.Field($"idx.{LegacySessionEndIdxField}"));
4249
if (createdBeforeUtc.Ticks > 0)
4350
filter &= Query<PersistentEvent>.DateRange(r => r.Field(e => e.Date).LessThanOrEquals(createdBeforeUtc));
4451

@@ -67,7 +74,7 @@ public Task<long> RemoveAllAsync(string organizationId, string? clientIpAddress,
6774

6875
var query = new RepositoryQuery<PersistentEvent>().Organization(organizationId);
6976
if (utcStart.HasValue && utcEnd.HasValue)
70-
query = query.DateRange(utcStart, utcEnd, InferField(e => e.Date)).Index(utcStart, utcEnd);
77+
query = query.DateRange(utcStart, utcEnd, InferField(e => e.Date));
7178
else if (utcEnd.HasValue)
7279
query = query.ElasticFilter(Query<PersistentEvent>.DateRange(r => r.Field(e => e.Date).LessThan(utcEnd)));
7380
else if (utcStart.HasValue)
@@ -218,33 +225,83 @@ protected override Task OnCustomFieldsDocumentsChanging(object sender, Documents
218225

219226
/// <summary>
220227
/// Custom field query resolution: resolves idx.fieldName and data.fieldName to idx.{type}-{slot}.
221-
/// Returns null for unrecognized fields so the global resolver (field aliases) still works.
228+
/// Blocks raw slot access (e.g., idx.keyword-7) to prevent querying deleted or other tenants' fields.
229+
/// Returns null for non-idx/data fields so the global resolver (field aliases) still works.
222230
/// </summary>
231+
// Well-known system field slot mappings (deterministic — always slot 1 for each type).
232+
private static readonly Dictionary<string, string> _systemFieldSlots = new(StringComparer.OrdinalIgnoreCase)
233+
{
234+
["@ref:session"] = EventCustomFieldService.SessionReferenceIdxField,
235+
[Event.KnownDataKeys.SessionEnd] = EventCustomFieldService.SessionEndIdxField,
236+
[Event.KnownDataKeys.SessionHasError] = EventCustomFieldService.SessionHasErrorIdxField,
237+
};
238+
223239
protected override async Task OnCustomFieldsBeforeQuery(object sender, BeforeQueryEventArgs<PersistentEvent> args)
224240
{
225-
var tenantKey = GetTenantKey(args.Query);
226-
if (String.IsNullOrEmpty(tenantKey))
227-
return;
241+
var tenantKey = await ResolveTenantKeyAsync(args.Query);
228242

229243
var definitionRepo = ElasticIndex.Configuration.CustomFieldDefinitionRepository;
230-
if (definitionRepo is null)
231-
return;
232244

233-
var fieldMapping = await definitionRepo.GetFieldMappingAsync(EntityTypeName, tenantKey);
234-
var mapping = fieldMapping.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetIdxName(), StringComparer.OrdinalIgnoreCase);
245+
// Lazy-load field mapping only when a query actually references idx.* or data.* fields.
246+
// Most queries (count, date histograms, simple filters) never hit custom fields,
247+
// so deferring this avoids a cache/ES lookup on every hot-path query.
248+
Dictionary<string, string>? mapping = null;
235249

236-
args.Options.QueryFieldResolver((field, _) =>
250+
args.Options.QueryFieldResolver(async (field, _) =>
237251
{
238252
string? fieldName = null;
239253
if (field.StartsWith("idx.", StringComparison.OrdinalIgnoreCase))
240254
fieldName = field.Substring(4);
241255
else if (field.StartsWith("data.", StringComparison.OrdinalIgnoreCase))
242256
fieldName = field.Substring(5);
257+
else if (field.StartsWith("ref.", StringComparison.OrdinalIgnoreCase))
258+
fieldName = $"@ref:{field.Substring(4)}";
259+
260+
if (fieldName is null)
261+
return null;
262+
263+
// System fields have deterministic slots that don't require tenant resolution.
264+
if (_systemFieldSlots.TryGetValue(fieldName, out var systemSlot))
265+
return $"idx.{systemSlot}";
243266

244-
if (fieldName is not null && mapping.TryGetValue(fieldName, out var idxName))
245-
return Task.FromResult<string?>($"idx.{idxName}");
267+
// Non-system fields require a tenant key to look up their slot assignment.
268+
if (String.IsNullOrEmpty(tenantKey) || definitionRepo is null)
269+
{
270+
// Without tenant context, block raw idx access; data.* fields fall through.
271+
return field.StartsWith("idx.", StringComparison.OrdinalIgnoreCase) ? "idx.__blocked__" : null;
272+
}
273+
274+
mapping ??= (await definitionRepo.GetFieldMappingAsync(EntityTypeName, tenantKey))
275+
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetIdxName(), StringComparer.OrdinalIgnoreCase);
276+
277+
if (mapping.TryGetValue(fieldName, out var idxName))
278+
return $"idx.{idxName}";
279+
280+
// Block raw slot access (e.g., idx.keyword-7) and unknown idx fields.
281+
// Redirect to a non-existent field so the clause matches nothing.
282+
if (field.StartsWith("idx.", StringComparison.OrdinalIgnoreCase))
283+
return "idx.__blocked__";
246284

247-
return Task.FromResult<string?>(null);
285+
// For data.* and ref.* fields that don't map to a custom field, return null to let
286+
// other resolvers handle legitimate data paths (e.g., data.@version).
287+
return null;
248288
});
249289
}
290+
291+
private async Task<string?> ResolveTenantKeyAsync(IRepositoryQuery query)
292+
{
293+
var organizationIds = query.GetOrganizations();
294+
if (organizationIds.Count == 1)
295+
return organizationIds.Single();
296+
297+
var projectIds = query.GetProjects();
298+
if (projectIds.Count == 1)
299+
return (await _projectRepository.GetByIdAsync(projectIds.Single()))?.OrganizationId;
300+
301+
var stackIds = query.GetStacks();
302+
if (stackIds.Count == 1)
303+
return (await _stackRepository.GetByIdAsync(stackIds.Single()))?.OrganizationId;
304+
305+
return null;
306+
}
250307
}

src/Exceptionless.Core/Services/EventCustomFieldService.cs

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public class EventCustomFieldService : IStartupAction
2323
/// </summary>
2424
public static readonly IReadOnlyList<(string Name, string IndexType)> SystemFields =
2525
[
26+
("@ref:session", "keyword"),
2627
(Event.KnownDataKeys.SessionEnd, "date"),
2728
(Event.KnownDataKeys.SessionHasError, "bool")
2829
];
@@ -31,6 +32,7 @@ public class EventCustomFieldService : IStartupAction
3132
/// Well-known idx field names for system fields. These are deterministic because system fields
3233
/// are always provisioned first via EnsureSystemFieldsAsync (slot 1 for each type).
3334
/// </summary>
35+
public const string SessionReferenceIdxField = "keyword-1";
3436
public const string SessionEndIdxField = "date-1";
3537
public const string SessionHasErrorIdxField = "bool-1";
3638

@@ -97,13 +99,11 @@ private async Task OnDocumentsChangingAsync(object sender, DocumentsChangeEventA
9799
var documentsByOrganization = args.Documents
98100
.Select(d => d.Value)
99101
.Where(d => d is not null)
100-
.GroupBy(d => d.OrganizationId);
102+
.GroupBy(d => d.OrganizationId)
103+
.Where(g => !String.IsNullOrEmpty(g.Key));
101104

102105
foreach (var organizationGroup in documentsByOrganization)
103106
{
104-
if (String.IsNullOrEmpty(organizationGroup.Key))
105-
continue;
106-
107107
IDictionary<string, CustomFieldDefinition>? fieldMapping = null;
108108
try
109109
{
@@ -117,22 +117,19 @@ private async Task OnDocumentsChangingAsync(object sender, DocumentsChangeEventA
117117
fieldMapping = await _customFieldDefinitionRepository.GetFieldMappingAsync(nameof(PersistentEvent), organizationGroup.Key);
118118
}
119119
}
120-
catch (Exception ex)
120+
catch (Exception ex) when (ex is not OperationCanceledException)
121121
{
122122
_logger.LogError(ex, "Error loading custom field definitions for organization {OrganizationId}", organizationGroup.Key);
123123
continue;
124124
}
125125

126-
foreach (var document in organizationGroup)
126+
foreach (var document in organizationGroup.Where(d => d is not null))
127127
{
128-
if (document is null)
129-
continue;
130-
131128
try
132129
{
133130
ProcessEventCustomFields(document, fieldMapping);
134131
}
135-
catch (Exception ex)
132+
catch (Exception ex) when (ex is not OperationCanceledException)
136133
{
137134
_logger.LogError(ex, "Error processing custom fields for event {EventId}", document.Id);
138135
}
@@ -142,6 +139,8 @@ private async Task OnDocumentsChangingAsync(object sender, DocumentsChangeEventA
142139

143140
private void ProcessEventCustomFields(PersistentEvent ev, IDictionary<string, CustomFieldDefinition> fieldMapping)
144141
{
142+
ClearManagedCustomFieldSlots(ev);
143+
145144
if (fieldMapping.Count == 0 || ev.Data is null || ev.Data.Count == 0)
146145
return;
147146

@@ -168,11 +167,39 @@ private void ProcessEventCustomFields(PersistentEvent ev, IDictionary<string, Cu
168167
if (value is not null)
169168
idx[definition.GetIdxName()] = value;
170169
}
171-
catch (Exception ex)
170+
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
172171
{
173172
_logger.LogDebug(ex, "Skipping custom field {FieldName}: type mismatch for index type {IndexType}", fieldName, definition.IndexType);
174173
}
175174
}
175+
176+
if (ev.Idx?.Count == 0)
177+
ev.Idx = null;
178+
}
179+
180+
private static void ClearManagedCustomFieldSlots(PersistentEvent ev)
181+
{
182+
if (ev.Idx is null || ev.Idx.Count == 0)
183+
return;
184+
185+
foreach (var idxKey in ev.Idx.Keys.Where(IsManagedCustomFieldSlotKey).ToArray())
186+
ev.Idx.Remove(idxKey);
187+
188+
if (ev.Idx.Count == 0)
189+
ev.Idx = null;
190+
}
191+
192+
private static bool IsManagedCustomFieldSlotKey(string idxKey)
193+
{
194+
if (String.IsNullOrWhiteSpace(idxKey))
195+
return false;
196+
197+
int separatorIndex = idxKey.LastIndexOf('-');
198+
if (separatorIndex <= 0 || separatorIndex == idxKey.Length - 1)
199+
return false;
200+
201+
return SupportedIndexTypes.Contains(idxKey[..separatorIndex])
202+
&& Int32.TryParse(idxKey.AsSpan(separatorIndex + 1), out _);
176203
}
177204

178205
/// <summary>
@@ -324,13 +351,7 @@ public static bool IsValidFieldName(string name)
324351
return false;
325352

326353
// Only ASCII alphanumeric, underscore, dot, and dash — no Unicode identifiers
327-
foreach (var c in name)
328-
{
329-
if (!Char.IsAsciiLetterOrDigit(c) && c != '_' && c != '.' && c != '-')
330-
return false;
331-
}
332-
333-
return true;
354+
return name.All(c => Char.IsAsciiLetterOrDigit(c) || c == '_' || c == '.' || c == '-');
334355
}
335356

336357
}

0 commit comments

Comments
 (0)