Skip to content

feat: organization-scoped event custom fields with management UI and filter integration#1815

Draft
ejsmith wants to merge 7 commits into
mainfrom
feature/custom-fields
Draft

feat: organization-scoped event custom fields with management UI and filter integration#1815
ejsmith wants to merge 7 commits into
mainfrom
feature/custom-fields

Conversation

@ejsmith
Copy link
Copy Markdown
Member

@ejsmith ejsmith commented Feb 2, 2025

Summary

Adds opt-in, organization-scoped custom fields for event data — letting users promote arbitrary Data dictionary keys into Elasticsearch-indexed fields for use in search filters, saved views, and analytics dashboards.

This is a fully additive change with no breaking API or schema changes.


What Changed

Backend

Area Change
PersistentEvent Implements IHaveVirtualCustomFields to connect event Data to Foundatio's pooled-slot indexing infrastructure
EventIndex Registers 8 standard custom field types via AddStandardCustomFieldTypes() — removes dynamic templates (no longer needed)
EventCustomFieldService New IStartupAction that wires the document-changing pipeline hook; provisions system fields (sessionend, haserror) per org; validates field names (ASCII, no @ prefix, max length); converts and type-checks values before slot write
OrganizationController 5 new endpoints under {id}/event-custom-fields: GET list, POST create, PATCH update, DELETE soft-delete, GET candidates from event
RemoveCustomFieldWorkItemHandler Background cleanup handler (does not free slots — deferred for retention-window safety)
CustomFieldOptions MaxFieldsPerOrganization = 20, MaxSlotChurn = 100
Pipeline cleanup Removes CopySimpleDataToIdxAction and EventFieldsQueryVisitor — replaced by first-class field definitions

API Endpoints (new)

GET    /api/v2/organizations/{id}/event-custom-fields
POST   /api/v2/organizations/{id}/event-custom-fields
PATCH  /api/v2/organizations/{id}/event-custom-fields/{fieldId}
DELETE /api/v2/organizations/{id}/event-custom-fields/{fieldId}
GET    /api/v2/organizations/{id}/event-custom-fields/candidates?eventId={id}

Plan gating: Free-plan organizations receive 426 Upgrade Required on POST.

Reserved names: Any field starting with @ is rejected (reserved for system use).

Active quota: Maximum 20 active fields per organization (soft-deleted fields do not count toward the quota; they do occupy their slot).

Deletion Policy

Deletion is deliberately two-phase to prevent slot reuse corruption:

  1. Soft delete (synchronous) — marks IsDeleted = true, removes the field from the UI, blocks new events from indexing into this slot. Enqueues a cleanup work item.
  2. Slot cleanup (deferred) — the background handler logs the soft-delete but does not free the slot. A future retention-aware cleanup job will free slots after all events indexed with that slot have aged out.

Saved-view protection: DELETE returns 409 Conflict with field name if any saved view filter references the field (checks both idx.{field} and data.{field} patterns).

Frontend (Svelte 5)

  • New management page: /organization/{id}/custom-fields
  • Linked from the organization settings navigation
  • Create dialog with name, display name, type, description fields and inline validation
  • Edit dialog for mutable properties (description, display order)
  • Delete confirmation dialog with exact-name-match requirement, accurate UX text explaining indexing only affects new events, not historical
  • Upgrade dialog for free-plan organizations (426 → UpgradeDialog)
  • Custom field filter builders integrated into the Svelte event filter UI (all operators per type: keyword → equals/not-equals/exists/missing; string → contains/exists/missing; numeric/date → range/gt/gte/lt/lte/exists/missing; bool → true/false/exists/missing)

Architecture Docs

See docs/custom-fields.md for the full architecture, slot system internals, lifecycle, deletion policy, operator support, FAQ, and churn analysis.


Test Evidence

Suite Tests Status
CustomFieldApiTests 36
EventCustomFieldServiceTests 25
CustomFieldIndexingTests 12
PersistentEventCustomFieldsTests 14
All other backend tests 87 additional
Frontend unit tests 243
OpenAPI baseline 1/1

Key test scenarios covered

  • CRUD lifecycle (create/read/update/delete)
  • IndexType normalization (uppercase → lowercase)
  • Reserved name rejection (@-prefix, too long, non-ASCII)
  • Quota enforcement (blocks at 21st field)
  • Duplicate name rejection (including against soft-deleted fields)
  • Soft-deleted field immutability (PATCH/DELETE on IsDeleted=true → 404)
  • Double-delete protection
  • Saved-view filter blocking deletion (idx.{field} and data.{field} patterns)
  • Free-plan 426 gate
  • System field provisioning (sessionend, haserror) on first create
  • System field deletion prevention
  • Slot lifecycle (slot recycled only after soft-delete + slot safety wait)
  • Type conversion correctness: longfloat (range guard), date → UTC, bool coercion
  • Filter builder operators per index type

Known Limitations

Limitation Rationale
TOCTOU quota race Two concurrent POSTs can transiently exceed the 20-field quota. Foundatio's distributed lock is per-scope, not per-org quota. Low risk; documented.
Slot churn accumulates Soft-deleted slots are never freed. A future retention-aware cleanup job will reclaim them.
No backfill on create Historical events are not re-indexed when a field is created. Only new events are indexed.
No backfill on delete Soft-deleted slots still appear in historical queries until events age out of the retention window.

Breaking Changes

None. All changes are additive. Existing events, queries, saved views, and API clients are unaffected.


Original Requirements

Custom Fields System for Exceptionless

Allow organizations to define indexed fields from event data properties, enabling rich search, filter, and analytics capabilities for user-defined data.

Core Requirements

  • Organization-scoped custom field definitions stored in Elasticsearch
  • Opt-in indexing: users explicitly register fields; no auto-indexing from raw data
  • Support 8 field types: keyword, string, int, long, double, float, bool, date
  • Per-organization quota: max 20 active fields (free plans get 426 upgrade required)
  • Integration with Foundatio.Repositories.Elasticsearch custom fields (slot pooling)
  • Reserved field name protection (@ prefix)
  • Slot reuse protection — no immediate hard delete, deferred cleanup

Management UI Requirements

  • Organization settings page for managing custom fields
  • Create / edit / delete dialogs
  • Delete confirmation requiring exact name match
  • Upgrade dialog for free plans
  • Filter integration: all operators per type

Filter Operator Requirements

  • keyword: equals, not-equals, exists, missing
  • string/full text: contains/search, exists, missing
  • numeric/date: equals, range, gt, gte, lt, lte, exists, missing
  • bool: true, false, exists, missing

Deletion Policy

  • Cannot delete if referenced by a saved view filter (409 Conflict)
  • Soft delete first, slot freed only after retention window
  • UI messaging must accurately reflect indexing semantics (new events only)

System Fields

  • sessionend (date) and haserror (bool) provisioned per org automatically
  • Protected from user deletion

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds custom field support for events by updating the event model and repository logic, removing legacy query visitor code, and updating test infrastructure to support the new behavior.

  • Removed obsolete EventFieldsQueryVisitor usage and file.
  • Introduced automatic custom field creation in the event repository.
  • Updated the PersistentEvent model to implement virtual custom fields and modified test configurations accordingly.

Reviewed Changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Exceptionless.Tests/Search/PersistentEventQueryValidatorTests.cs Removed legacy query visitor call in tests.
tests/Exceptionless.Tests/Search/EventIndexTests.cs Updated repository query to include organization filtering.
tests/Exceptionless.Tests/Migrations/FixDuplicateStacksMigrationTests.cs Set log level to Trace for migration tests.
tests/Exceptionless.Tests/AppWebHostFactory.cs Added Kibana container configuration.
src/Exceptionless.Core/Repositories/Queries/Visitors/EventFieldsQueryVisitor.cs Removed unused visitor implementation.
src/Exceptionless.Core/Repositories/EventRepository.cs Added auto-creation of custom fields and related tenant logic.
src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs Removed legacy dynamic mapping for event index custom fields.
src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs Added index creation for custom fields.
src/Exceptionless.Core/Models/PersistentEvent.cs Updated Idx property type and implemented IHaveVirtualCustomFields.
Files not reviewed (1)
  • src/Exceptionless.Core/Exceptionless.Core.csproj: Language not supported

public IDictionary<string, object?> Idx { get; set; } = new DataDictionary();

object? IHaveVirtualCustomFields.GetCustomField(string name) => Data?[name];
IDictionary<string, object?> IHaveVirtualCustomFields.GetCustomFields() => Data ?? [];
Copy link

Copilot AI Apr 30, 2025

Choose a reason for hiding this comment

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

Using an empty array literal '[]' as a fallback for an IDictionary<string, object?> is invalid. Replace '[]' with an appropriate empty dictionary initializer (e.g. 'new DataDictionary()').

Suggested change
IDictionary<string, object?> IHaveVirtualCustomFields.GetCustomFields() => Data ?? [];
IDictionary<string, object?> IHaveVirtualCustomFields.GetCustomFields() => Data ?? new DataDictionary();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in commit a5e340a - GetCustomFields() returns new DataDictionary() when Data is null. The [] fallback was removed entirely.

@niemyjski niemyjski changed the title Add custom fields for events feat: organization-scoped event custom fields with management UI and filter integration May 24, 2026
Comment thread src/Exceptionless.Core/Models/PersistentEvent.cs Fixed
Comment thread src/Exceptionless.Core/Services/EventCustomFieldService.cs Fixed
Comment thread src/Exceptionless.Core/Services/EventCustomFieldService.cs Fixed
Comment thread src/Exceptionless.Core/Services/EventCustomFieldService.cs Fixed
Comment thread src/Exceptionless.Core/Services/EventCustomFieldService.cs Fixed
Comment thread src/Exceptionless.Core/Services/EventCustomFieldService.cs Fixed
Comment thread src/Exceptionless.Core/Services/EventCustomFieldService.cs Fixed
@niemyjski niemyjski requested a review from Copilot May 25, 2026 13:22
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Comment thread tests/Exceptionless.Tests/CustomFields/CustomFieldApiTests.cs Fixed
@niemyjski niemyjski requested a review from Copilot May 28, 2026 11:45
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

…filter integration

Add organization-level custom field management that allows premium users to define
typed, searchable event fields (keyword, string, numeric, date, boolean) with full
lifecycle management including soft-delete, index slot reuse, and saved view protection.

Key changes:
- Custom field CRUD on organization controller with validation and plan gating
- EventCustomFieldService for document change handling and field lifecycle
- Premium feature detection in saved views (UsesPremiumFeatures flag)
- Filter integration with operator support per field type
- Angular UI: management dialog, filter picker integration
- Svelte UI: custom fields management and filter components
- Comprehensive test coverage (107+ custom field tests)
- Max 20 active fields per organization, slot-based index allocation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@niemyjski niemyjski force-pushed the feature/custom-fields branch from 0a8855a to b277880 Compare May 28, 2026 17:44
niemyjski and others added 6 commits May 28, 2026 13:02
…seline, fix Prettier

- Restore accidentally deleted ControllerManifestTests.cs (was removed during squash)
- Generate controller-manifest.json baseline with custom fields endpoints included
- Fix Prettier formatting in sidebar.svelte

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…heck

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…finedAsync

The previous baseline was missing PostPredefinedAsync because UPDATE_SNAPSHOTS
ran against a stale build. Rebuilt and regenerated correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…es, ContainsKey pattern

- PersistentEvent.GetCustomFields(): consolidate value-type guard into the .Where() predicate
- EventCustomFieldService: use .OfType<PersistentEvent>() to replace the outer .Where() null filter
  and the redundant inner .Where(d => d is not null) in the document loop
- CustomFieldIndexingTests: fix regex type names (integer→int, boolean→bool; add string and float)
- CustomFieldApiTests: fix 'integer' index type to 'int' (integer is not a supported type)
- CustomFieldApiTests: replace ContainsKey+indexer with TryGetValue for SessionHasError assertion

The 'integer' index type bug was silent: ConvertValue returns null for unknown types so the
field would be stored but never actually indexed, with no error surfaced to the user.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ring

ConvertToKeyword and ConvertToString were calling value.ToString() without
a format provider. On servers with a non-en-US locale (e.g. de-DE), float,
double, and decimal values produce '1,5' instead of '1.5', breaking
Elasticsearch keyword-field searches because the stored value and the query
value use different decimal separators.  DateTime values were also formatted
with the current thread culture instead of ISO 8601.

Fix: introduce a FormatInvariant helper that uses CultureInfo.InvariantCulture
for numeric types and the roundtrip 'O' ISO-8601 format for DateTime/DateTimeOffset.
The bool.ToString() behaviour ("True"/"False") is preserved for backwards
compatibility with already-indexed data.

Add regression tests that explicitly set CultureInfo to de-DE and assert
that the output always uses an invariant decimal separator.  Also add tests
for long coercion in ConvertToBool, and for DateTime/DateTimeOffset keyword
formatting.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Exceptionless.Insulation 25% 23% 203
Exceptionless.Web 74% 63% 4019
Exceptionless.AppHost 28% 18% 82
Exceptionless.Core 69% 62% 8046
Summary 68% (13924 / 20334) 62% (7315 / 11886) 12350

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants