From bda94999d0cf8d023fea77259028d83771524aa1 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 1 Oct 2026 21:31:45 -0500 Subject: [PATCH 1/6] Add native event measurements and configurable saved-view charts --- docs/event-telemetry.md | 72 +++ .../010_AddEventTelemetryMappings.cs | 25 + src/Exceptionless.Core/Models/Event.cs | 31 +- src/Exceptionless.Core/Models/EventChart.cs | 33 ++ .../Models/EventMeasurement.cs | 25 + .../Models/PersistentEvent.cs | 4 + src/Exceptionless.Core/Models/SavedView.cs | 2 + .../007_ValidateEventTelemetryAction.cs | 24 + .../Default/JsonEventParserPlugin.cs | 4 + .../Configuration/Indexes/EventIndex.cs | 9 + .../Indexes/EventTelemetryMapping.cs | 17 + .../Repositories/Queries/EventChartQuery.cs | 120 +++++ .../Repositories/Queries/MeasurementField.cs | 31 ++ .../PersistentEventQueryValidator.cs | 8 +- .../Seed/PredefinedSavedViewConfiguration.cs | 1 + .../Seed/PredefinedSavedViewContentHasher.cs | 15 +- .../Seed/PredefinedSavedViewsDataSeed.cs | 10 + .../Serialization/EventTelemetryReader.cs | 69 +++ .../Validation/EventTelemetryValidation.cs | 39 ++ .../Api/Endpoints/EventEndpoints.cs | 19 + .../Api/Handlers/EventHandler.cs | 121 +++++ .../Api/Handlers/SavedViewHandler.cs | 42 +- .../Api/Messages/EventMessages.cs | 4 + .../src/lib/features/events/api.svelte.ts | 44 +- .../components/event-chart-settings.svelte | 266 ++++++++++ .../event-chart-settings.svelte.test.ts | 44 ++ .../event-measurement-chart.stories.ts | 47 ++ .../components/event-measurement-chart.svelte | 101 ++++ .../event-measurement-chart.svelte.test.ts | 56 +++ .../events/components/views/overview.svelte | 46 ++ .../lib/features/events/event-chart.test.ts | 57 +++ .../src/lib/features/events/event-chart.ts | 56 +++ .../components/saved-view-picker.svelte | 5 + .../saved-views/use-saved-views.svelte.ts | 15 +- .../ClientApp/src/lib/generated/api.ts | 81 +++ .../ClientApp/src/lib/generated/schemas.ts | 124 +++++ .../src/routes/(app)/event/+page.svelte | 100 +++- .../event/by-ref/[referenceId]/+page.svelte | 13 +- .../Models/EventChartRequest.cs | 29 ++ .../Models/SavedView/NewSavedView.cs | 2 + .../Models/SavedView/UpdateSavedView.cs | 2 + .../Models/SavedView/ViewSavedView.cs | 2 + .../Utility/OpenApi/DeltaSchemaTransformer.cs | 19 +- .../Exceptionless.Tests/Api/Data/openapi.json | 467 +++++++++++++++++- .../Endpoints/EventTelemetryEndpointTests.cs | 197 ++++++++ .../Api/Endpoints/SavedViewEndpointTests.cs | 24 + .../Api/OpenApiSnapshotTests.cs | 14 + .../AddEventTelemetryMappingsTests.cs | 59 +++ ...MigrateSavedViewColumnsIntegrationTests.cs | 12 +- .../Plugins/EventTelemetryTests.cs | 121 +++++ .../PredefinedSavedViewContentHasherTests.cs | 13 + 51 files changed, 2700 insertions(+), 41 deletions(-) create mode 100644 docs/event-telemetry.md create mode 100644 src/Exceptionless.Core/Migrations/010_AddEventTelemetryMappings.cs create mode 100644 src/Exceptionless.Core/Models/EventChart.cs create mode 100644 src/Exceptionless.Core/Models/EventMeasurement.cs create mode 100644 src/Exceptionless.Core/Pipeline/007_ValidateEventTelemetryAction.cs create mode 100644 src/Exceptionless.Core/Repositories/Configuration/Indexes/EventTelemetryMapping.cs create mode 100644 src/Exceptionless.Core/Repositories/Queries/EventChartQuery.cs create mode 100644 src/Exceptionless.Core/Repositories/Queries/MeasurementField.cs create mode 100644 src/Exceptionless.Core/Serialization/EventTelemetryReader.cs create mode 100644 src/Exceptionless.Core/Validation/EventTelemetryValidation.cs create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.stories.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.ts create mode 100644 src/Exceptionless.Web/Models/EventChartRequest.cs create mode 100644 tests/Exceptionless.Tests/Api/Endpoints/EventTelemetryEndpointTests.cs create mode 100644 tests/Exceptionless.Tests/Migrations/AddEventTelemetryMappingsTests.cs create mode 100644 tests/Exceptionless.Tests/Plugins/EventTelemetryTests.cs diff --git a/docs/event-telemetry.md b/docs/event-telemetry.md new file mode 100644 index 0000000000..c157bdcc11 --- /dev/null +++ b/docs/event-telemetry.md @@ -0,0 +1,72 @@ +# Event measurements and relationships + +Any event type can carry numeric observations, categorical dimensions, an outcome, and relationships to other events. These are native event fields, independent of `data` and custom-field configuration. A producer can use `type:operation` for tests, benchmarks, jobs, imports, or other repeatable work; no particular type is required. + +```json +{ + "type": "operation", + "source": "Import.Accounts", + "date": "2026-10-01T12:00:00Z", + "reference_id": "invocation-1234", + "parent_reference_id": "suite-run-1234", + "root_reference_id": "build-run-1234", + "outcome": "success", + "dimensions": { + "branch": "main", + "runtime": "net10", + "scenario": "10000-records" + }, + "measurements": [ + { "name": "duration", "value": 12.4, "unit": "s" }, + { "name": "allocated", "value": 48192, "unit": "By" }, + { "name": "sql.calls", "value": 4, "unit": "1" }, + { "name": "redis.calls", "value": 0, "unit": "1" } + ] +} +``` + +## Field contract + +- **Measurements:** at most 32 uniquely named observations per event. Names start with a letter and contain letters, digits, dots, underscores, or hyphens (100 characters maximum). Values are finite doubles, including zero and fractional values. A missing value is invalid; a missing measurement is absent, not zero. Integers above 2^53 cannot be represented exactly. Units are required, case-sensitive strings of at most 32 characters. Recommended units include `s`, `ms`, `By`, `%`, and `1` (dimensionless). Units do not choose the numeric storage type. No automatic unit conversion occurs: select the exact unit when querying or charting. +- **Dimensions:** at most 32 name/string pairs, using the same name rules and values up to 256 characters. `"4.90"` and `"00123"` remain exact strings. Dimensions are categorical; numeric comparisons belong in measurements. There is no inference of numbers, dates, or booleans. +- **Outcome:** an optional string up to 100 characters. Suggested values are `success`, `failure`, `skipped`, `cancelled`, and `unknown`; applications can supply other outcomes. +- **Relationships:** `parent_reference_id` identifies the immediate parent, and `root_reference_id` identifies the enclosing execution. References use the existing 8–100 character alphanumeric/hyphen contract and are scoped to the same project. The producer supplies these values. Parent events can arrive later or be outside retention; ingestion does not require them to exist. A direct self-parent is invalid. Navigation follows one link at a time and does not recursively expand a graph, so indirect cycles cannot trigger unbounded traversal. Existing `data["@ref:name"]` relationships keep their current meaning. + +Keep `source` and the stacking signature stable across executions to obtain the history of a repeatable operation. Give each execution a distinct reference; do not put a run ID in its source or stack signature. Reference IDs retain their existing deduplication semantics; they are not permanent uniqueness guarantees or an exactly-once ingestion API. + +Native fields do not change `value`, infer a new event type, or create stack signatures. Older unstructured root properties whose shapes differ from the new fields are retained in `data` by the inbound parser. Existing stored `data` entries remain there. + +## Queries and charts + +Use existing event search and sort APIs: + +```text +type:operation outcome:failure +dimensions.branch:main +measurement.duration@ms:>500 +project:PROJECT_ID parent_reference_id:suite-run-1234 +project:PROJECT_ID (reference:build-run-1234 OR root_reference_id:build-run-1234) +``` + +`sort=-measurement.duration@ms` orders individual events by a specific measurement and unit. The `measurement.NAME@UNIT` search alias binds the name, unit, and numeric value to the same nested observation. Normal numeric aggregation expressions can use this alias too. Use simple unit symbols in search aliases; the structured chart API accepts the full unit string. + +The events dashboard's **Configure chart** control selects a measurement/unit, aggregation (average, minimum, maximum, sum, count, p50, p95, p99), line/bar display, and split by source, stack, outcome, or a named dimension. With no measurement, bucketed counts can be split by outcome. Counts represent stored event observations, not measurement sums or the legacy duplicate `count` field. + +Charts inherit the view's filter and time range. They can show UTC time buckets or individual event observations. Bucketed measurements omit missing values and display gaps. Individual observations retain event IDs for drilldown. Bucket drilldown selects the series, measurement, unit, and interval. The expandable observations list provides keyboard access and separates events with identical timestamps. + +Bucket charts return at most 20 series and approximately 200 time buckets per series; terms ranking is by observation frequency, not metric value. Terms aggregation counts can be approximate across shards. Missing split fields are excluded from bucketed series. Individual-event mode returns the latest 1,000 observations and at most 20 series. The response and UI flag incomplete results; narrow the query for a complete view. Percentiles describe submitted observations and are approximate, not pooled percentiles of raw samples hidden inside pre-aggregated benchmark means. + +Chart configuration is a typed `chart` property on saved views. It is included in create, update, copy, predefined synchronization, and import/export. Omitting it preserves existing behavior; explicitly patching it to `null` restores the default event/stack count chart. Views without a chart retain their legacy predefined content hashes. Unsaved chart changes are carried in the `chart` URL parameter, including an explicit `null` override; URLs support refresh, sharing, and Back/Forward navigation. Other saved-view display drafts keep their existing storage behavior. + +The authenticated API provides: + +- `GET /api/v2/organizations/{organizationId}/events/measurements` with optional `filter`, `time`, and `offset`: bounded measurement-name/unit suggestions. +- `POST /api/v2/organizations/{organizationId}/events/chart` with `{ "chart": { "measurement": "duration", "unit": "ms", "aggregation": "avg", "group_by": "source", "mode": "buckets", "display": "line" }, "filter": "type:operation", "time": "[now-7d TO now]" }`: observations or aggregated series. + +These APIs enforce organization access, retention, suspension, and premium-search policies. They use repository queries and the existing event store. + +## Storage and rollout + +Elasticsearch uses a fixed `nested` mapping for `measurements` (`name` and `unit` keywords, `value` double), a `flattened` mapping for dimensions, and keyword mappings for outcome and relationships. New names do not create new mappings. Each measurement does create an additional hidden nested document, so the per-event limit bounds storage and indexing overhead. Daily index creation includes these mappings; migration 10 adds them to retained partitions. It does not backfill historical custom data or move it into native fields. Deploy the mapping migration before enabling producers. + +CI/benchmark reporters, SDK convenience methods, multiple dashboard widgets, baseline comparisons, automatic flaky-test classification, and regression alerts are follow-up work. For tests, report passing and failing attempts, retries, and the logical execution identity; failure-only events cannot establish a flakiness rate. For benchmarks, use stable scenario/runtime/machine dimensions and compare equivalent environments. Keep suite totals separate from child observations in queries to avoid double-counting. Production enablement should measure actual ingest volume, nested-document/storage cost, event quotas, and retention before broad reporting. diff --git a/src/Exceptionless.Core/Migrations/010_AddEventTelemetryMappings.cs b/src/Exceptionless.Core/Migrations/010_AddEventTelemetryMappings.cs new file mode 100644 index 0000000000..60c1d3df3a --- /dev/null +++ b/src/Exceptionless.Core/Migrations/010_AddEventTelemetryMappings.cs @@ -0,0 +1,25 @@ +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; + +/// Adds native telemetry mappings to retained event partitions without reindexing. +public sealed class AddEventTelemetryMappings(ExceptionlessElasticConfiguration configuration, ILoggerFactory loggerFactory) : MigrationBase(loggerFactory) +{ + public override int? Version => 10; + + public override async Task RunAsync(MigrationContext context) + { + var response = await configuration.Client.Indices.PutMappingAsync(d => d + .Indices($"{configuration.Events.Name}-v*-*") + .AllowNoIndices(true) + .IgnoreUnavailable(true) + .Properties(p => p.AddTelemetry()), context.CancellationToken); + _logger.LogRequest(response); + if (!response.IsValidResponse) + throw new InvalidOperationException("Unable to add event telemetry mappings: " + response.DebugInformation); + } +} diff --git a/src/Exceptionless.Core/Models/Event.cs b/src/Exceptionless.Core/Models/Event.cs index 84a96c7acd..f6bb77a384 100644 --- a/src/Exceptionless.Core/Models/Event.cs +++ b/src/Exceptionless.Core/Models/Event.cs @@ -56,6 +56,24 @@ public class Event : IData, IJsonOnDeserialized /// public int? Count { get; set; } + /// The result of this operation, independent of the event type. + [StringLength(100, MinimumLength = 1)] + public string? Outcome { get; set; } + + /// Reference of the immediate parent event in the same project. + public string? ParentReferenceId { get; set; } + + /// Reference of the root event in the same project. Supplied by the producer. + public string? RootReferenceId { get; set; } + + /// Up to 32 uniquely named numeric observations. Missing values are not zero. + [MaxLength(32)] + public List? Measurements { get; set; } + + /// Up to 32 categorical labels, indexed as exact strings without type inference. + [MaxLength(32), SkipRecursion] + public Dictionary? Dimensions { get; set; } + /// /// Optional data entries that contain additional information about this event. /// @@ -102,6 +120,9 @@ void IJsonOnDeserialized.OnDeserialized() Data ??= []; foreach (var kvp in ExtensionData) { + if (EventTelemetryReader.TryRead(this, kvp.Key, kvp.Value)) + continue; + object? value = JsonElementConverter.Convert(kvp.Value); EventDataNormalizer.Set(Data, kvp.Key, value); } @@ -112,7 +133,10 @@ void IJsonOnDeserialized.OnDeserialized() protected bool Equals(Event other) { - return String.Equals(Type, other.Type) && String.Equals(Source, other.Source) && Tags.CollectionEquals(other.Tags) && String.Equals(Message, other.Message) && String.Equals(Geo, other.Geo) && Value == other.Value && Equals(Data, other.Data); + return String.Equals(Type, other.Type) && String.Equals(Source, other.Source) && Tags.CollectionEquals(other.Tags) && String.Equals(Message, other.Message) && String.Equals(Geo, other.Geo) && Value == other.Value && Equals(Data, other.Data) + && Outcome == other.Outcome && ParentReferenceId == other.ParentReferenceId && RootReferenceId == other.RootReferenceId + && (Measurements ?? []).OrderBy(m => m?.Name, StringComparer.Ordinal).SequenceEqual((other.Measurements ?? []).OrderBy(m => m?.Name, StringComparer.Ordinal)) + && (Dimensions ?? []).OrderBy(d => d.Key, StringComparer.Ordinal).SequenceEqual((other.Dimensions ?? []).OrderBy(d => d.Key, StringComparer.Ordinal)); } public override bool Equals(object? obj) @@ -138,6 +162,11 @@ public override int GetHashCode() hashCode = (hashCode * 397) ^ (Geo?.GetHashCode() ?? 0); hashCode = (hashCode * 397) ^ Value.GetHashCode(); hashCode = (hashCode * 397) ^ (Data?.GetCollectionHashCode(_exclusions) ?? 0); + hashCode = (hashCode * 397) ^ HashCode.Combine(Outcome, ParentReferenceId, RootReferenceId); + foreach (var measurement in (Measurements ?? []).OrderBy(m => m?.Name, StringComparer.Ordinal)) + hashCode = (hashCode * 397) ^ (measurement?.GetHashCode() ?? 0); + foreach (var dimension in (Dimensions ?? []).OrderBy(d => d.Key, StringComparer.Ordinal)) + hashCode = (hashCode * 397) ^ dimension.GetHashCode(); return hashCode; } } diff --git a/src/Exceptionless.Core/Models/EventChart.cs b/src/Exceptionless.Core/Models/EventChart.cs new file mode 100644 index 0000000000..e18fb8df90 --- /dev/null +++ b/src/Exceptionless.Core/Models/EventChart.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exceptionless.Core.Models; + +/// Configuration of a saved view's single event chart. Null retains the default event-count chart. +public sealed record EventChart : IValidatableObject +{ + [RegularExpression(EventMeasurement.NamePattern)] + public string? Measurement { get; set; } + + [StringLength(32, MinimumLength = 1)] + public string? Unit { get; set; } + + [Required, RegularExpression("^(avg|min|max|sum|p50|p95|p99|count)$")] + public string Aggregation { get; set; } = "avg"; + + [RegularExpression("^(source|stack|outcome|dimensions\\.[a-zA-Z][a-zA-Z0-9_.-]{0,99})$")] + public string? GroupBy { get; set; } + + [Required, RegularExpression("^(buckets|events)$")] + public string Mode { get; set; } = "buckets"; + + [Required, RegularExpression("^(line|bar)$")] + public string Display { get; set; } = "line"; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Measurement is null && (Aggregation != "count" || Mode == "events")) + yield return new ValidationResult("Select a measurement, or use bucketed event counts.", [nameof(Measurement)]); + if ((Measurement is null) != (Unit is null)) + yield return new ValidationResult("A measurement and its unit must be selected together.", [nameof(Unit)]); + } +} diff --git a/src/Exceptionless.Core/Models/EventMeasurement.cs b/src/Exceptionless.Core/Models/EventMeasurement.cs new file mode 100644 index 0000000000..5386c75770 --- /dev/null +++ b/src/Exceptionless.Core/Models/EventMeasurement.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exceptionless.Core.Models; + +/// A numeric observation on one event. Units describe values, not their storage type. +public sealed record EventMeasurement : IValidatableObject +{ + public const string NamePattern = "^[a-zA-Z][a-zA-Z0-9_.-]{0,99}$"; + + [Required, RegularExpression(NamePattern)] + public string Name { get; set; } = null!; + + [Required] + public double? Value { get; set; } + + /// Case-sensitive unit, for example s, ms, By, or 1 for a dimensionless value. + [Required, StringLength(32, MinimumLength = 1)] + public string Unit { get; set; } = null!; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Value is { } value && !Double.IsFinite(value)) + yield return new ValidationResult("Measurement values must be finite.", [nameof(Value)]); + } +} diff --git a/src/Exceptionless.Core/Models/PersistentEvent.cs b/src/Exceptionless.Core/Models/PersistentEvent.cs index 968a4acd37..4a348c1f1b 100644 --- a/src/Exceptionless.Core/Models/PersistentEvent.cs +++ b/src/Exceptionless.Core/Models/PersistentEvent.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using Exceptionless.Core.Validation; using Exceptionless.Core.Attributes; using Exceptionless.Core.Extensions; using Foundatio.Repositories.Models; @@ -71,6 +72,9 @@ public IEnumerable Validate(ValidationContext validationContex yield return new ValidationResult("ReferenceId must contain between 8 and 100 alphanumeric or '-' characters.", [nameof(ReferenceId)]); } + foreach (var error in EventTelemetryValidation.ValidateRelationshipsAndDimensions(this)) + yield return error; + // NOTE: We need to write a migration to cleanup all old events of 50 or more tags so there never is an error while saving. //if (ev.Tags.Count > 50) // yield return new ValidationResult("Tags can't include more than 50 tags.", nameof(Tags)); diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 14527619c6..a657cc026b 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -54,6 +54,8 @@ public partial record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates /// Whether the dashboard chart is shown for this view. Null means use the default. public bool? ShowChart { get; set; } + public EventChart? Chart { get; set; } + /// Stable identifier used to synchronize predefined saved views across organizations. [MaxLength(150)] public string? PredefinedKey { get; set; } diff --git a/src/Exceptionless.Core/Pipeline/007_ValidateEventTelemetryAction.cs b/src/Exceptionless.Core/Pipeline/007_ValidateEventTelemetryAction.cs new file mode 100644 index 0000000000..9c8afd1290 --- /dev/null +++ b/src/Exceptionless.Core/Pipeline/007_ValidateEventTelemetryAction.cs @@ -0,0 +1,24 @@ +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Validation; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Pipeline; + +/// Rejects invalid observations individually before stack assignment and batch persistence. +[Priority(7)] +public sealed class ValidateEventTelemetryAction(AppOptions options, ILoggerFactory loggerFactory) : EventPipelineActionBase(options, loggerFactory) +{ + public override Task ProcessAsync(EventContext ctx) + { + if (ctx.Event is { Outcome: null, ParentReferenceId: null, RootReferenceId: null, Measurements: null, Dimensions: null }) + return Task.CompletedTask; + + var errors = EventTelemetryValidation.GetErrors(ctx.Event); + if (errors.Count > 0) + { + var error = new MiniValidatorException(errors); + ctx.SetError(error.Message, error); + } + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs b/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs index 327c507c6b..67e30ce198 100644 --- a/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventParser/Default/JsonEventParserPlugin.cs @@ -1,5 +1,7 @@ using System.Text.Json; using Exceptionless.Core.Extensions; +using System.Text.Json.Serialization.Metadata; +using Exceptionless.Core.Serialization; using Exceptionless.Core.Models; using Exceptionless.Core.Pipeline; using Microsoft.Extensions.Logging; @@ -17,6 +19,8 @@ public JsonEventParserPlugin(AppOptions options, JsonSerializerOptions jsonOptio // non-nullable properties. We must not reject structurally valid events; the pipeline // handles missing/null values gracefully downstream. _jsonOptions = new JsonSerializerOptions(jsonOptions) { RespectNullableAnnotations = false }; + _jsonOptions.TypeInfoResolver = (_jsonOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver()) + .WithAddedModifier(EventTelemetryReader.CaptureTelemetryProperties); } public List? ParseEvents(string input, int apiVersion, string? userAgent) diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs index 9c7b77b4cd..0e6be0aaae 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs @@ -46,6 +46,7 @@ protected override void ConfigureQueryBuilder(ElasticQueryBuilder builder) var stacksRepository = _serviceProvider.GetRequiredService(); var cacheClient = _serviceProvider.GetRequiredService(); base.ConfigureQueryBuilder(builder); + builder.Register(new EventChartQueryBuilder()); builder.RegisterBefore(new EventStackFilterQueryBuilder(stacksRepository, cacheClient, _configuration.LoggerFactory)); } @@ -61,6 +62,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor t.Match("*-s").Mapping(m => m.Keyword(s => s.IgnoreAbove(1024))))) .Properties(p => p .SetupDefaults() + .AddTelemetry() .Keyword(e => e.OrganizationId) .FieldAlias(Alias.OrganizationId, a => a.Path(f => f.OrganizationId)) .Keyword(e => e.ProjectId) @@ -154,6 +156,7 @@ protected override void ConfigureQueryParser(ElasticQueryParserConfiguration con EventIndexExtensions.DataPath(Event.KnownDataKeys.UserInfo, u => u.Name) ]) .AddQueryVisitor(new EventFieldsQueryVisitor()) + .UseNested() .UseFieldMap(new Dictionary { { Alias.BrowserVersion, EventIndexExtensions.DataDictionaryPath(Event.KnownDataKeys.RequestInfo, r => r.Data, RequestInfo.KnownDataKeys.BrowserVersion) }, { Alias.BrowserMajorVersion, EventIndexExtensions.DataDictionaryPath(Event.KnownDataKeys.RequestInfo, r => r.Data, RequestInfo.KnownDataKeys.BrowserMajorVersion) }, @@ -164,6 +167,12 @@ protected override void ConfigureQueryParser(ElasticQueryParserConfiguration con { Alias.OperatingSystemVersion, EventIndexExtensions.DataDictionaryPath(Event.KnownDataKeys.RequestInfo, r => r.Data, RequestInfo.KnownDataKeys.OSVersion) }, { Alias.OperatingSystemMajorVersion, EventIndexExtensions.DataDictionaryPath(Event.KnownDataKeys.RequestInfo, r => r.Data, RequestInfo.KnownDataKeys.OSMajorVersion) } }); + + var fieldResolver = config.FieldResolver; + config.UseFieldResolver((field, context) => MeasurementField.TryParse(field, out _, out _) + ? Task.FromResult("measurements.value") + : fieldResolver!(field, context)); + config.UseNestedFilter((path, original, resolved, context) => path == "measurements" ? MeasurementField.Filter(original) : null); } public ElasticsearchOptions Options => _configuration.Options; diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventTelemetryMapping.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventTelemetryMapping.cs new file mode 100644 index 0000000000..753e9719fd --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventTelemetryMapping.cs @@ -0,0 +1,17 @@ +using Elastic.Clients.Elasticsearch.Mapping; +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Repositories.Configuration; + +public static class EventTelemetryMapping +{ + public static PropertiesDescriptor AddTelemetry(this PropertiesDescriptor properties) => properties + .Keyword(e => e.Outcome) + .Keyword(e => e.ParentReferenceId) + .Keyword(e => e.RootReferenceId) + .Flattened(e => e.Dimensions, p => p.DepthLimit(1)) + .Nested(e => e.Measurements, n => n.Dynamic(DynamicMapping.False).Properties(p => p + .Keyword("name") + .Keyword("unit") + .DoubleNumber("value"))); +} diff --git a/src/Exceptionless.Core/Repositories/Queries/EventChartQuery.cs b/src/Exceptionless.Core/Repositories/Queries/EventChartQuery.cs new file mode 100644 index 0000000000..d4328e0f04 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Queries/EventChartQuery.cs @@ -0,0 +1,120 @@ +using Elastic.Clients.Elasticsearch.Aggregations; +using Elastic.Clients.Elasticsearch.QueryDsl; +using Exceptionless.Core.Models; +using Foundatio.Repositories; +using Foundatio.Repositories.Elasticsearch.Queries.Builders; +using Foundatio.Repositories.Options; + +namespace Exceptionless.Core.Repositories.Queries; + +public static class EventChartQueryExtensions +{ + internal const string ChartKey = "@EventChart"; + internal const string CatalogKey = "@EventMeasurementCatalog"; + public const int MaxSeries = 20; + public const int MaxPoints = 1000; + + public static T EventChart(this T query, EventChart chart, DateTime start, DateTime end) where T : IRepositoryQuery + => query.BuildOption(ChartKey, new ChartQuery(chart, GetInterval(start, end))); + + public static T MeasurementCatalog(this T query) where T : IRepositoryQuery => query.BuildOption(CatalogKey, true); + + // UTC fixed intervals keep a strict upper bound on buckets, even for very long retention periods. + public static long GetInterval(DateTime start, DateTime end) => Math.Max(1000, (long)Math.Ceiling((end - start).TotalMilliseconds / 200 / 1000) * 1000); + + internal sealed record ChartQuery(EventChart Chart, long IntervalMilliseconds); +} + +public sealed class EventChartQueryBuilder : IElasticQueryBuilder +{ + public Task BuildAsync(QueryBuilderContext ctx) where T : class, new() + { + if (ctx.Source.SafeGetOption(EventChartQueryExtensions.CatalogKey)) + { + ctx.Search.Aggregations(new Dictionary + { + ["measurements"] = new() + { + Nested = new NestedAggregation { Path = "measurements" }, + Aggregations = new Dictionary + { + ["names"] = new() + { + Terms = new TermsAggregation { Field = "measurements.name", Size = 100 }, + Aggregations = new Dictionary + { + ["units"] = new() { Terms = new TermsAggregation { Field = "measurements.unit", Size = 20 } } + } + } + } + } + }); + } + + var query = ctx.Source.SafeGetOption(EventChartQueryExtensions.ChartKey); + if (query is null) + return Task.CompletedTask; + + var chart = query.Chart; + Query? measurementFilter = null; + if (chart.Measurement is not null) + { + measurementFilter = new TermQuery { Field = "measurements.name", Value = chart.Measurement }; + measurementFilter &= new TermQuery { Field = "measurements.unit", Value = chart.Unit! }; + ctx.Filter &= new NestedQuery { Path = "measurements", Query = measurementFilter, IgnoreUnmapped = true }; + } + + if (chart.Mode == "events") + return Task.CompletedTask; + + var date = new Aggregation + { + DateHistogram = new DateHistogramAggregation + { + Field = "date", + FixedInterval = $"{query.IntervalMilliseconds}ms", + MinDocCount = 0 + } + }; + + if (measurementFilter is not null) + { + var metric = chart.Aggregation switch + { + "min" => new Aggregation { Min = new MinAggregation { Field = "measurements.value" } }, + "max" => new Aggregation { Max = new MaxAggregation { Field = "measurements.value" } }, + "sum" => new Aggregation { Sum = new SumAggregation { Field = "measurements.value" } }, + "count" => new Aggregation { ValueCount = new ValueCountAggregation { Field = "measurements.value" } }, + "p50" or "p95" or "p99" => new Aggregation { Percentiles = new PercentilesAggregation { Field = "measurements.value", Percents = [Double.Parse(chart.Aggregation[1..], System.Globalization.CultureInfo.InvariantCulture)] } }, + _ => new Aggregation { Avg = new AverageAggregation { Field = "measurements.value" } } + }; + date.Aggregations = new Dictionary + { + ["measurements"] = new() + { + Nested = new NestedAggregation { Path = "measurements" }, + Aggregations = new Dictionary + { + ["selected"] = new() + { + Filter = measurementFilter, + Aggregations = new Dictionary { ["value"] = metric } + } + } + } + }; + } + + var aggregations = new Dictionary { ["dates"] = date }; + if (chart.GroupBy is not null) + { + string field = chart.GroupBy switch { "source" => "source.keyword", "stack" => "stack_id", _ => chart.GroupBy }; + aggregations = new Dictionary + { + ["series"] = new() { Terms = new TermsAggregation { Field = field, Size = EventChartQueryExtensions.MaxSeries }, Aggregations = aggregations } + }; + } + ctx.Search.Aggregations(aggregations); + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Core/Repositories/Queries/MeasurementField.cs b/src/Exceptionless.Core/Repositories/Queries/MeasurementField.cs new file mode 100644 index 0000000000..6612198cec --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Queries/MeasurementField.cs @@ -0,0 +1,31 @@ +using System.Text.RegularExpressions; +using Elastic.Clients.Elasticsearch.QueryDsl; +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Repositories.Queries; + +/// Resolves measurement.duration@ms to a value and its nested name/unit scope. +public static class MeasurementField +{ + public static bool TryParse(string field, out string name, out string unit) + { + name = unit = String.Empty; + const string prefix = "measurement."; + if (!field.StartsWith(prefix, StringComparison.Ordinal)) + return false; + int separator = field.IndexOf('@'); + if (separator <= prefix.Length || separator == field.Length - 1) + return false; + name = field[prefix.Length..separator]; + unit = field[(separator + 1)..]; + return unit.Length <= 32 && Regex.IsMatch(name, EventMeasurement.NamePattern); + } + + public static Query? Filter(string originalField) + { + if (!TryParse(originalField, out string name, out string unit)) + return null; + Query filter = new TermQuery { Field = "measurements.name", Value = name }; + return filter & new TermQuery { Field = "measurements.unit", Value = unit }; + } +} diff --git a/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs b/src/Exceptionless.Core/Repositories/Queries/Validation/PersistentEventQueryValidator.cs index 5cd11109f5..9ee3b945fc 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.Repositories.Queries; using Foundatio.Parsers.LuceneQueries; using Foundatio.Parsers.LuceneQueries.Visitors; using Microsoft.Extensions.Logging; @@ -36,6 +37,9 @@ public sealed class PersistentEventQueryValidator : AppQueryValidator }; private static readonly HashSet _allowedAggregationFields = new(StringComparer.OrdinalIgnoreCase) { + "outcome", + "parent_reference_id", + "root_reference_id", "date", "source", "tags", @@ -117,7 +121,9 @@ protected override QueryProcessResult ApplyAggregationRules(QueryValidationResul return new QueryProcessResult { Message = "Aggregation count exceeded" }; // Only allow fields that are numeric or have high commonality. - if (!result.ReferencedFields.All(_allowedAggregationFields.Contains)) + if (!result.ReferencedFields.All(field => _allowedAggregationFields.Contains(field) + || MeasurementField.TryParse(field, out _, out _) + || field.StartsWith("dimensions.", StringComparison.Ordinal))) return new QueryProcessResult { Message = "One or more aggregation fields are not allowed" }; // Distinct queries are expensive. diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs index 95e4ce00da..8f3b512696 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs @@ -19,6 +19,7 @@ public static bool Apply(SavedView destination, SavedView source, string key, st changed |= SetColumnsIfChanged(destination, source.Columns); changed |= SetIfChanged(destination, source.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(destination, source.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); + changed |= SetIfChanged(destination, source.Chart is null ? null : source.Chart with { }, static (view, value) => view.Chart = value, static view => view.Chart); changed |= SetIfChanged(destination, source.Version, static (view, value) => view.Version = value, static view => view.Version); changed |= SetIfChanged(destination, PredefinedSavedViewContentHasher.GetContentHash(destination), static (view, value) => view.PredefinedContentHash = value, static view => view.PredefinedContentHash); diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs index cd5b111718..da3605563a 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs @@ -21,7 +21,7 @@ public static string GetContentHash(SavedView savedView) savedView.FilterDefinitions, savedView.Columns, savedView.ShowStats, - savedView.ShowChart); + savedView.ShowChart, savedView.Chart); } public static string GetDefinitionsContentHash(IEnumerable definitions) @@ -43,7 +43,7 @@ public static string GetDefinitionsContentHash(IEnumerable? columns, bool? showStats, - bool? showChart) + bool? showChart, + EventChart? chart) { var content = new { @@ -75,7 +76,13 @@ private static string GetContentHash( showChart }; - return SerializeAndHash(content); + // Keep the exact legacy hash when no chart configuration exists. + if (chart is null) + return SerializeAndHash(content); + + var configured = JsonSerializer.SerializeToNode(content)!.AsObject(); + configured["chart"] = JsonSerializer.SerializeToNode(chart); + return configured.ToJsonString().ToSHA256(); } private static string SerializeAndHash(T content) diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs index 6ff27831d1..556ee64958 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs @@ -143,6 +143,12 @@ private static bool ApplyDefinition(SavedView existing, PredefinedSavedViewDefin changed = true; } + if (existing.Chart != definition.Chart) + { + existing.Chart = definition.Chart is null ? null : definition.Chart with { }; + changed = true; + } + if (existing.ShowChart != definition.ShowChart) { existing.ShowChart = definition.ShowChart; @@ -204,6 +210,7 @@ internal static SavedView CreateSavedView( Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, + Chart = definition.Chart is null ? null : definition.Chart with { }, Version = 1 }; @@ -271,4 +278,7 @@ public sealed record PredefinedSavedViewDefinition [JsonPropertyName("showChart")] public bool? ShowChart { get; init; } + + [JsonPropertyName("chart")] + public EventChart? Chart { get; init; } } diff --git a/src/Exceptionless.Core/Serialization/EventTelemetryReader.cs b/src/Exceptionless.Core/Serialization/EventTelemetryReader.cs new file mode 100644 index 0000000000..5c31e4e26e --- /dev/null +++ b/src/Exceptionless.Core/Serialization/EventTelemetryReader.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Serialization; + +/// Preserves older clients' unstructured root properties when they do not use the new native shapes. +internal static partial class EventTelemetryReader +{ + private static readonly JsonSerializerOptions _options = new() { PropertyNameCaseInsensitive = true }; + private static readonly HashSet _fields = new(StringComparer.OrdinalIgnoreCase) + { + "outcome", "parent_reference_id", "root_reference_id", "measurements", "dimensions" + }; + + // Only the inbound parser uses this modifier. Native models retain their typed API and storage contracts. + public static void CaptureTelemetryProperties(JsonTypeInfo typeInfo) + { + if (!typeof(Event).IsAssignableFrom(typeInfo.Type)) + return; + + for (int i = typeInfo.Properties.Count - 1; i >= 0; i--) + if (_fields.Contains(typeInfo.Properties[i].Name)) + typeInfo.Properties.RemoveAt(i); + } + + public static bool TryRead(Event ev, string name, JsonElement value) + { + try + { + switch (name.Replace("_", "").ToLowerInvariant()) + { + case "outcome" when value.ValueKind == JsonValueKind.String: + ev.Outcome = value.GetString(); + return true; + case "parentreferenceid" when value.ValueKind == JsonValueKind.String && IsReference(value.GetString()): + ev.ParentReferenceId = value.GetString(); + return true; + case "rootreferenceid" when value.ValueKind == JsonValueKind.String && IsReference(value.GetString()): + ev.RootReferenceId = value.GetString(); + return true; + case "measurements" when value.ValueKind == JsonValueKind.Array && value.EnumerateArray().All(IsMeasurement): + ev.Measurements = value.Deserialize>(_options); + return true; + case "dimensions" when value.ValueKind == JsonValueKind.Object: + ev.Dimensions = value.Deserialize>(_options); + return true; + } + } + catch (JsonException) + { + // This property was previously extension data; preserve its original value. + } + + return false; + } + + public static bool IsReference(string? value) => value is not null && ReferencePattern().IsMatch(value); + + private static bool IsMeasurement(JsonElement element) => element.ValueKind == JsonValueKind.Null || + (element.ValueKind == JsonValueKind.Object && element.EnumerateObject().Any(property => + property.Name.Equals("name", StringComparison.OrdinalIgnoreCase) || + property.Name.Equals("value", StringComparison.OrdinalIgnoreCase) || + property.Name.Equals("unit", StringComparison.OrdinalIgnoreCase))); + + [GeneratedRegex("^[a-zA-Z0-9-]{8,100}$")] + private static partial Regex ReferencePattern(); +} diff --git a/src/Exceptionless.Core/Validation/EventTelemetryValidation.cs b/src/Exceptionless.Core/Validation/EventTelemetryValidation.cs new file mode 100644 index 0000000000..31e3da82e9 --- /dev/null +++ b/src/Exceptionless.Core/Validation/EventTelemetryValidation.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Serialization; + +namespace Exceptionless.Core.Validation; + +public static class EventTelemetryValidation +{ + public static IEnumerable ValidateRelationshipsAndDimensions(Event ev) + { + if (ev.ParentReferenceId is not null && (!EventTelemetryReader.IsReference(ev.ParentReferenceId) || ev.ParentReferenceId == ev.ReferenceId)) + yield return new ValidationResult("ParentReferenceId must be a valid reference to a different event.", [nameof(ev.ParentReferenceId)]); + if (ev.RootReferenceId is not null && !EventTelemetryReader.IsReference(ev.RootReferenceId)) + yield return new ValidationResult("RootReferenceId must be a valid event reference.", [nameof(ev.RootReferenceId)]); + if (ev.Measurements is not null && (ev.Measurements.Any(m => m is null) || ev.Measurements.Select(m => m?.Name).Distinct(StringComparer.Ordinal).Count() != ev.Measurements.Count)) + yield return new ValidationResult("Measurements must have unique names and cannot contain null entries.", [nameof(ev.Measurements)]); + if (ev.Dimensions is not null && ev.Dimensions.Any(d => !Regex.IsMatch(d.Key, EventMeasurement.NamePattern) || d.Value is null || d.Value.Length > 256)) + yield return new ValidationResult("Dimension names must be valid measurement names and values must be strings of at most 256 characters.", [nameof(ev.Dimensions)]); + } + + public static IDictionary GetErrors(Event ev) + { + var results = ValidateRelationshipsAndDimensions(ev).ToList(); + ValidateProperty(ev, nameof(ev.Outcome), ev.Outcome, results); + ValidateProperty(ev, nameof(ev.Measurements), ev.Measurements, results); + ValidateProperty(ev, nameof(ev.Dimensions), ev.Dimensions, results); + foreach (var measurement in ev.Measurements ?? []) + if (measurement is not null) + Validator.TryValidateObject(measurement, new ValidationContext(measurement), results, validateAllProperties: true); + + return results.SelectMany(result => result.MemberNames.DefaultIfEmpty("Measurements").Select(member => (member, result.ErrorMessage))) + .GroupBy(result => result.member) + .ToDictionary(group => group.Key, group => group.Select(result => result.ErrorMessage ?? "Invalid telemetry.").ToArray()); + } + + private static void ValidateProperty(Event ev, string name, object? value, List results) + => Validator.TryValidateProperty(value, new ValidationContext(ev) { MemberName = name }, results); +} diff --git a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs index 05ad674605..8027a8b881 100644 --- a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -25,6 +25,25 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder .AddEndpointFilter() .WithTags("Event"); + group.MapPost("organizations/{organizationId:objectid}/events/chart", async (string organizationId, EventChartRequest request, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new GetEventChart(organizationId, request, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("Chart event measurements or counts"); + + group.MapGet("organizations/{organizationId:objectid}/events/measurements", async (string organizationId, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? time = null, string? offset = null) + => (await mediator.InvokeAsync>(new GetEventMeasurements(organizationId, filter, time, offset, httpContext))).ToHttpResult(resultMapper)) + .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .WithSummary("List measurement names and units in the selected events"); + // Count group.MapGet("events/count", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string? filter = null, string? aggregations = null, string? time = null, string? offset = null, string? mode = null) => (await mediator.InvokeAsync>(new GetEventCount(filter, aggregations, time, offset, mode, httpContext))).ToHttpResult(resultMapper)) diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index a034c63691..31c2c1ec1f 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -687,6 +687,127 @@ public async Task> Handle(DeleteEvents message) return results; } + public async Task> Handle(GetEventChart message) + { + var organization = await GetOrganizationAsync(message.OrganizationId, message.Context); + if (organization is null) + return Result.NotFound("Organization not found."); + if (organization.IsSuspended) + return PlanLimitResult("Unable to view events for the suspended organization."); + + var request = message.Request; + var validation = await validator.ValidateQueryAsync(request.Filter); + if (!validation.IsValid) + return Result.BadRequest(validation.Message ?? "Invalid filter."); + + var systemFilter = new AppFilter(organization) { UsesPremiumFeatures = true }; + if (ApiFilterPolicy.IsPremiumFeatureQueryBlocked(systemFilter)) + return PlanLimitResult(ApiFilterPolicy.PremiumSearchUpgradeMessage); + + var time = TimeRangeParser.GetTimeInfo(request.Time, request.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + RepositoryQueryDescriptor query = q => q + .SortExpression("-date") + .AppFilter(systemFilter) + .FilterExpression(request.Filter) + .EnforceEventStackFilter() + .DateRange(time.Range.UtcStart, time.Range.UtcEnd, time.Field) + .Index(time.Range.UtcStart, time.Range.UtcEnd) + .EventChart(request.Chart, time.Range.UtcStart, time.Range.UtcEnd); + + if (request.Chart.Mode == "events") + { + var events = await eventRepository.FindAsync(query, o => o.PageLimit(EventChartQueryExtensions.MaxPoints)); + var groups = events.Documents.GroupBy(ev => GetSeriesName(ev, request.Chart.GroupBy)).ToList(); + return new EventChartResult + { + Total = events.Total, + Truncated = events.HasMore || events.Total > events.Documents.Count || groups.Count > EventChartQueryExtensions.MaxSeries, + Series = groups.Take(EventChartQueryExtensions.MaxSeries).Select(group => new EventChartSeries(group.Key, + group.OrderBy(ev => ev.Date).Select(ev => new EventChartPoint(ev.Date.UtcDateTime, + ev.Measurements?.FirstOrDefault(m => m.Name == request.Chart.Measurement && m.Unit == request.Chart.Unit)?.Value, 1, ev.Id)).ToList())).ToList() + }; + } + + var result = await eventRepository.CountAsync(query); + var response = new EventChartResult + { + Total = result.Total, + IntervalMilliseconds = EventChartQueryExtensions.GetInterval(time.Range.UtcStart, time.Range.UtcEnd) + }; + if (request.Chart.GroupBy is null) + response.Series.Add(ReadSeries("All events", result.Aggregations, request.Chart)); + else + { + var buckets = result.Aggregations.Terms("series")?.Buckets ?? []; + response.Truncated = buckets.Sum(b => b.Total ?? 0) < result.Total; + foreach (var bucket in buckets) + response.Series.Add(ReadSeries(bucket.Key, bucket.Aggregations, request.Chart)); + } + return response; + } + + public async Task> Handle(GetEventMeasurements message) + { + var organization = await GetOrganizationAsync(message.OrganizationId, message.Context); + if (organization is null) + return Result.NotFound("Organization not found."); + if (organization.IsSuspended) + return PlanLimitResult("Unable to view events for the suspended organization."); + var validation = await validator.ValidateQueryAsync(message.Filter); + if (!validation.IsValid) + return Result.BadRequest(validation.Message ?? "Invalid filter."); + + var systemFilter = new AppFilter(organization) { UsesPremiumFeatures = true }; + if (ApiFilterPolicy.IsPremiumFeatureQueryBlocked(systemFilter)) + return PlanLimitResult(ApiFilterPolicy.PremiumSearchUpgradeMessage); + + var time = TimeRangeParser.GetTimeInfo(message.Time, message.Offset, timeProvider, _allowedDateFields, DefaultDateField, organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider)); + var result = await eventRepository.CountAsync(q => q.AppFilter(systemFilter).FilterExpression(message.Filter).EnforceEventStackFilter() + .DateRange(time.Range.UtcStart, time.Range.UtcEnd, time.Field).Index(time.Range.UtcStart, time.Range.UtcEnd).MeasurementCatalog()); + var measurements = GetBucket(result.Aggregations, "measurements"); + var names = measurements?.Aggregations.Terms("names")?.Buckets ?? []; + var values = new List(); + bool truncated = names.Sum(n => n.Total ?? 0) < (measurements?.Total ?? 0); + foreach (var name in names) + { + var units = name.Aggregations.Terms("units")?.Buckets ?? []; + truncated |= units.Sum(u => u.Total ?? 0) < (name.Total ?? 0); + values.AddRange(units.Select(u => new EventMeasurementDescriptor(name.Key, u.Key))); + } + return new EventMeasurementCatalog(values.OrderBy(m => m.Name, StringComparer.Ordinal).ThenBy(m => m.Unit, StringComparer.Ordinal).ToList(), truncated); + } + + private static string GetSeriesName(PersistentEvent ev, string? groupBy) => groupBy switch + { + null => "All events", + "source" => ev.Source ?? "(missing)", + "stack" => ev.StackId, + "outcome" => ev.Outcome ?? "(missing)", + _ => ev.Dimensions?.GetValueOrDefault(groupBy["dimensions.".Length..]) ?? "(missing)" + }; + + private static SingleBucketAggregate? GetBucket(IReadOnlyDictionary aggregations, string name) + => aggregations.GetValueOrDefault(name) as SingleBucketAggregate; + + private static EventChartSeries ReadSeries(string name, IReadOnlyDictionary aggregations, EventChart chart) + { + var points = new List(); + foreach (var bucket in aggregations.DateHistogram("dates")?.Buckets ?? []) + { + var selected = GetBucket(bucket.Aggregations, "measurements") is { } nested ? GetBucket(nested.Aggregations, "selected") : null; + double? value = chart.Measurement is null ? bucket.Total ?? 0 : null; + if (selected is { Total: > 0 } && selected.Aggregations.TryGetValue("value", out var metric)) + value = metric switch + { + ValueAggregate numeric => numeric.Value, + PercentilesAggregate percentiles => percentiles.Items.FirstOrDefault()?.Value, + _ => null + }; + points.Add(new EventChartPoint(bucket.Date, value, bucket.Total ?? 0)); + } + return new EventChartSeries(name, points); + } + #region Private Helpers private async Task> CountInternalAsync(AppFilter sf, TimeInfo ti, HttpContext httpContext, string? filter = null, string? aggregations = null, string? mode = null) diff --git a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index 45baae424c..35006d0a16 100644 --- a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -281,22 +281,23 @@ public async Task>> Ha } var savedViews = message.Definitions.Select(definition => new SavedView - { - OrganizationId = PredefinedSavedViewsDataSeed.SystemOrganizationId, - CreatedByUserId = GetCurrentUserId(), - PredefinedKey = definition.Key, - Name = definition.Name, - Slug = definition.Slug, - ViewType = definition.ViewType, - Filter = definition.Filter, - Time = definition.Time, - Sort = definition.Sort, - FilterDefinitions = definition.FilterDefinitions is { } filterDefinitions ? JsonSerializer.Serialize(filterDefinitions) : null, - Columns = CopyColumns(definition.Columns), - ShowStats = definition.ShowStats, - ShowChart = definition.ShowChart, - Version = 1 - }) + { + OrganizationId = PredefinedSavedViewsDataSeed.SystemOrganizationId, + CreatedByUserId = GetCurrentUserId(), + PredefinedKey = definition.Key, + Name = definition.Name, + Slug = definition.Slug, + ViewType = definition.ViewType, + Filter = definition.Filter, + Time = definition.Time, + Sort = definition.Sort, + FilterDefinitions = definition.FilterDefinitions is { } filterDefinitions ? JsonSerializer.Serialize(filterDefinitions) : null, + Columns = CopyColumns(definition.Columns), + ShowStats = definition.ShowStats, + ShowChart = definition.ShowChart, + Chart = definition.Chart is null ? null : definition.Chart with { }, + Version = 1 + }) .ToList(); foreach (var savedView in savedViews) @@ -364,6 +365,10 @@ public async Task> Handle(UpdateSavedViewMessage message) var changedNames = message.Changes.GetChangedPropertyNames(); message.Changes.Patch(original); + // Delta's cross-model copy skips null references. An explicit null chart restores the default. + if (changedNames.Contains(nameof(UpdateSavedView.Chart)) && message.Changes.TryGetPropertyValue(nameof(UpdateSavedView.Chart), out var chart)) + original.Chart = (EventChart?)chart; + if (changedNames.Contains(nameof(UpdateSavedView.Slug))) original.Slug = ToSlug(original.Slug); @@ -818,6 +823,7 @@ private SavedView CreatePredefinedSavedView(string organizationId, PredefinedSav Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, + Chart = definition.Chart is null ? null : definition.Chart with { }, Version = 1 }; @@ -838,6 +844,7 @@ private static bool ApplyPredefinedSavedView(SavedView savedView, PredefinedSave changed |= SetColumnsIfChanged(savedView, definition.Columns); changed |= SetIfChanged(savedView, definition.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(savedView, definition.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); + changed |= SetIfChanged(savedView, definition.Chart is null ? null : definition.Chart with { }, static (view, value) => view.Chart = value, static view => view.Chart); changed |= SetIfChanged(savedView, 1, static (view, value) => view.Version = value, static view => view.Version); changed |= SetIfChanged(savedView, PredefinedSavedViewContentHasher.GetContentHash(savedView), static (view, value) => view.PredefinedContentHash = value, static view => view.PredefinedContentHash); @@ -943,7 +950,8 @@ private static PredefinedSavedViewDefinition ToPredefinedSavedView(SavedView sav FilterDefinitions = ParseFilterDefinitions(savedView.FilterDefinitions), Columns = savedView.Columns, ShowStats = savedView.ShowStats, - ShowChart = savedView.ShowChart + ShowChart = savedView.ShowChart, + Chart = savedView.Chart }; } diff --git a/src/Exceptionless.Web/Api/Messages/EventMessages.cs b/src/Exceptionless.Web/Api/Messages/EventMessages.cs index 6c1ac3bb95..eb50a0babf 100644 --- a/src/Exceptionless.Web/Api/Messages/EventMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/EventMessages.cs @@ -18,6 +18,10 @@ public record GetEventsByStack(string StackId, string? Filter, string? Sort, str public record GetEventsByReferenceId(string ReferenceId, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, string? Include, HttpContext Context); public record GetEventsByReferenceIdAndProject(string ReferenceId, string ProjectId, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, string? Include, HttpContext Context); +// Measurements +public record GetEventChart(string OrganizationId, EventChartRequest Request, HttpContext Context); +public record GetEventMeasurements(string OrganizationId, string? Filter, string? Time, string? Offset, HttpContext Context); + // Sessions public record GetEventsBySessionId(string SessionId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, string? Include, HttpContext Context); public record GetEventsBySessionIdAndProject(string SessionId, string ProjectId, string? Filter, string? Sort, string? Time, string? Offset, string? Mode, int? Page, int Limit, string? Before, string? After, string? Include, HttpContext Context); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts index fbf7faa1d7..7ddc6f3e6b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts @@ -1,4 +1,5 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; +import type { EventChartRequest, EventChartResult, EventMeasurementCatalog } from '$generated/api'; import type { CountResult, WorkInProgressResult } from '$shared/models'; import { accessToken } from '$features/auth/index.svelte'; @@ -363,6 +364,47 @@ export function deleteEvent(request: DeleteEventsRequest) { })); } +export function getEventChartQuery(request: { body: EventChartRequest | undefined; enabled?: boolean; organizationId: string | undefined }) { + return createQuery(() => { + const organizationId = request.organizationId; + const body = request.body; + return { + enabled: !!accessToken.current && !!organizationId && !!body && (request.enabled ?? true), + queryFn: async ({ signal }) => { + const response = await useFetchClient().postJSON(`organizations/${organizationId}/events/chart`, body, { + signal + }); + return response.data!; + }, + queryKey: [...queryKeys.organizations(organizationId), 'chart', body], + staleTime: ORGANIZATION_EVENT_QUERY_STALE_TIME_MS + }; + }); +} + +export function getEventMeasurementsQuery(request: { enabled: boolean; filter?: null | string; organizationId: string | undefined; time?: null | string }) { + return createQuery(() => { + const organizationId = request.organizationId; + const params = { + filter: request.filter, + offset: DEFAULT_OFFSET, + time: request.time + }; + return { + enabled: !!accessToken.current && !!organizationId && request.enabled, + queryFn: async ({ signal }) => { + const response = await useFetchClient().getJSON(`organizations/${organizationId}/events/measurements`, { + params, + signal + }); + return response.data!; + }, + queryKey: [...queryKeys.organizations(organizationId), 'measurements', params], + staleTime: ORGANIZATION_EVENT_QUERY_STALE_TIME_MS + }; + }); +} + // Cacheable reads intentionally finish after their observer unmounts so navigation can reuse the result instead of aborting and restarting the request. export function getEventQuery(request: GetEventRequest) { return createQuery(() => ({ @@ -723,7 +765,7 @@ function isOrganizationEventDashboardQueryKey(queryKey: readonly unknown[]): boo return ( queryKey[0] === queryKeys.type[0] && queryKey[1] === 'organizations' && - (queryKey[3] === 'count' || queryKey[3] === 'events' || queryKey[3] === 'sessions') + (queryKey[3] === 'count' || queryKey[3] === 'events' || queryKey[3] === 'sessions' || queryKey[3] === 'chart' || queryKey[3] === 'measurements') ); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte new file mode 100644 index 0000000000..f3c083679c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte @@ -0,0 +1,266 @@ + + + + + {#snippet child({ props })}{/snippet} + + + + Configure chart + Chart a measurement in these events. Save the view to keep this configuration. + +
{ + event.preventDefault(); + event.stopPropagation(); + void form.handleSubmit(); + }} + > + + {#each textFields as item (item.key)} + + {#snippet children(field)} + + {item.label} + field.handleChange(event.currentTarget.value)} + onblur={field.handleBlur} + aria-invalid={ariaInvalid(field)} + aria-describedby={`chart-${item.key}-error`} + /> + + + {/snippet} + + {/each} + {#each selectFields as item (item.key)} + + {#snippet children(field)} + + {item.label} + + {item.options.find((option) => option.value === field.state.value)?.label} + {#each item.options as option (option.value)}{option.label}{/each} + + + {/snippet} + + {/each} + Units are exact: ms and s are separate series. Individual events use their recorded values. To count all events, clear Measurement and Unit + and select Count with Time buckets. + {#if catalog.error}{/if} + {#if catalog.data?.truncated}

+ Only the most common measurements are suggested. You can enter another name and unit. +

{/if} +
+ + + + +
+
+
+{#each [...new Set(catalog.data?.measurements.map((m) => m.name) ?? [])] as name (name)}{/each} +{#each [...new Set(catalog.data?.measurements.map((m) => m.unit) ?? [])] as unit (unit)}{/each} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte.test.ts new file mode 100644 index 0000000000..8437297553 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-chart-settings.svelte.test.ts @@ -0,0 +1,44 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import EventChartSettings from './event-chart-settings.svelte'; + +vi.mock('$features/organizations/context.svelte', () => ({ organization: { current: 'organization-a' } })); +vi.mock('../api.svelte', () => ({ + getEventMeasurementsQuery: () => ({ data: { measurements: [{ name: 'duration', unit: 'ms' }], truncated: false }, error: null }) +})); + +describe('event chart settings', () => { + it('validates a measurement unit and applies a typed chart configuration', async () => { + const onApply = vi.fn(); + render(EventChartSettings, { chart: null, onApply }); + await fireEvent.click(screen.getByRole('button', { name: 'Configure chart' })); + await fireEvent.input(screen.getByLabelText('Measurement'), { target: { value: 'duration' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + await screen.findByText('Select a measurement and its unit together.'); + expect(onApply).not.toHaveBeenCalled(); + await fireEvent.input(screen.getByLabelText('Unit'), { target: { value: 'ms' } }); + await fireEvent.input(screen.getByLabelText('Split by'), { target: { value: 'dimensions.branch' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + + await waitFor(() => + expect(onApply).toHaveBeenCalledWith({ + aggregation: 'avg', + display: 'line', + group_by: 'dimensions.branch', + measurement: 'duration', + mode: 'buckets', + unit: 'ms' + }) + ); + }); + + it('explicitly restores the default chart', async () => { + const onApply = vi.fn(); + render(EventChartSettings, { chart: { aggregation: 'avg', display: 'line', measurement: 'duration', mode: 'buckets', unit: 'ms' }, onApply }); + await fireEvent.click(screen.getByRole('button', { name: 'Configure chart' })); + await fireEvent.click(screen.getByRole('button', { name: 'Default chart' })); + + expect(onApply).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.stories.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.stories.ts new file mode 100644 index 0000000000..279e0c5ac5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.stories.ts @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import EventMeasurementChart from './event-measurement-chart.svelte'; + +const meta = { + args: { + chart: { aggregation: 'avg', display: 'line', group_by: 'source', measurement: 'duration', mode: 'buckets', unit: 'ms' }, + isLoading: false, + onSelect: () => {}, + result: { + interval_milliseconds: 86400000, + series: [ + { + name: 'Account import', + points: [ + { count: 1, date: '2026-10-01T12:00:00Z', value: 120 }, + { count: 1, date: '2026-10-02T12:00:00Z', value: 100 }, + { count: 0, date: '2026-10-03T12:00:00Z', value: null }, + { count: 1, date: '2026-10-04T12:00:00Z', value: 90 }, + { count: 1, date: '2026-10-05T12:00:00Z', value: 95 } + ] + }, + { + name: 'Transaction import', + points: [ + { count: 1, date: '2026-10-01T12:00:00Z', value: 45 }, + { count: 1, date: '2026-10-02T12:00:00Z', value: 35 }, + { count: 1, date: '2026-10-03T12:00:00Z', value: 0 }, + { count: 1, date: '2026-10-04T12:00:00Z', value: 25 }, + { count: 1, date: '2026-10-05T12:00:00Z', value: 20 } + ] + } + ], + total: 8, + truncated: false + } + }, + component: EventMeasurementChart, + title: 'Components/Events/Measurement chart' +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Line: Story = {}; +export const Bars: Story = { args: { chart: { ...meta.args.chart, display: 'bar' } } }; +export const Empty: Story = { args: { result: { series: [], total: 0, truncated: false } } }; +export const Limited: Story = { args: { result: { ...meta.args.result, truncated: true } } }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte new file mode 100644 index 0000000000..2562cb0355 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte @@ -0,0 +1,101 @@ + + + +

+ {chart.measurement ?? 'Events'} · {chart.mode === 'events' ? 'Individual events' : chart.aggregation} + {unit === '1' ? '' : `(${unit})`} +

+ {#if isLoading} + + {:else if !data.some((point) => point.value != null)} +

No matching observations in this time range.

+ {:else} + + {#if chart.display === 'bar'} + selectAt(details.data.date, details.series.key)}> + {#snippet tooltip()}{@render chartTooltip()}{/snippet} + + {:else} + selectAt(details.data.x, details.series.key)}> + {#snippet marks({ context })} + {#each context.series.visibleSeries as item (item.key)} + + + {/each} + {/snippet} + {#snippet tooltip()}{@render chartTooltip()}{/snippet} + + {/if} + +

Select a point to inspect its events. Missing measurements appear as gaps.

+
+ View observations +
+ {#each series as item (item.key)} +

{item.label}

+ {#each item.data as point, index (`${point.date.getTime()}-${index}`)} + + {/each} + {/each} +
+
+ {/if} + {#if result?.truncated}

+ Showing a limited set of observations or series. Narrow the filter or time range for a complete view. Events without the split field are excluded + from bucketed series. +

{/if} +
+ +{#snippet chartTooltip()} + formatDateLabel(value instanceof Date ? value : new Date(value as number | string))}> + {#snippet formatter({ name, value })}{name}{formatMeasurement(typeof value === 'number' ? value : null, unit)}{/snippet} + +{/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte.test.ts new file mode 100644 index 0000000000..d8abd0e0bb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-measurement-chart.svelte.test.ts @@ -0,0 +1,56 @@ +import type { EventChart, EventChartResult } from '$generated/api'; + +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import EventMeasurementChart from './event-measurement-chart.svelte'; + +const chart: EventChart = { aggregation: 'avg', display: 'line', measurement: 'duration', mode: 'events', unit: 'ms' }; + +describe('EventMeasurementChart', () => { + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + public disconnect() {} + public observe() {} + public unobserve() {} + } + ); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('keeps coincident events separately accessible and preserves zeros and missing values', async () => { + const onSelect = vi.fn(); + const result: EventChartResult = { + series: [ + { + name: 'Import', + points: [ + { count: 1, date: '2026-10-01T12:00:00Z', event_id: 'zero', value: 0 }, + { count: 1, date: '2026-10-01T12:00:00Z', event_id: 'ten', value: 10 }, + { count: 0, date: '2026-10-01T12:01:00Z', value: null } + ] + } + ], + total: 2, + truncated: true + }; + + render(EventMeasurementChart, { chart, isLoading: false, onSelect, result }); + await fireEvent.click(screen.getByText('View observations')); + await fireEvent.click(screen.getByRole('button', { name: /10 ms/ })); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ event_id: 'ten', value: 10 }), 'Import'); + expect(screen.getByText('0 ms').closest('button')).toBeTruthy(); + expect(screen.getByRole('button', { name: /—/ }).hasAttribute('disabled')).toBe(true); + expect(screen.getByRole('status').textContent).toContain('limited set'); + }); + + it('explains an empty measurement selection without rendering a false zero', () => { + render(EventMeasurementChart, { chart, isLoading: false, onSelect: vi.fn(), result: { series: [], total: 0, truncated: false } }); + expect(screen.getByText('No matching observations in this time range.')).toBeTruthy(); + expect(screen.queryByText('0 ms')).toBeNull(); + }); +}); 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 b51cc07c65..df37732445 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 @@ -7,6 +7,7 @@ import { Button } from '$comp/ui/button'; import * as Table from '$comp/ui/table'; import * as EventsFacetedFilter from '$features/events/components/filters'; + import { formatMeasurement } from '$features/events/event-chart'; import { getErrorType, getLocation, @@ -69,10 +70,55 @@ let requestUrl = $derived(getRequestInfoUrl(event)); let requestUrlPath = $derived(getRequestInfoPath(event)); let version = $derived(event.data?.['@version']); + + function relatedEventsHref(filter: string): string { + return `${resolve('/(app)/event')}?filter=${encodeURIComponent(`project:${event.project_id} AND (${filter})`)}&time=all`; + } + function relationshipHref(referenceId: string): string { + return `${resolve('/(app)/event/by-ref/[referenceId]', { + referenceId + })}?project=${encodeURIComponent(event.project_id)}`; + } + {#if event.outcome} + Outcome{event.outcome} + {/if} + {#if event.parent_reference_id} + Parent event{event.parent_reference_id} + {/if} + {#if event.root_reference_id} + Root event{event.root_reference_id} · View related events + {/if} + {#if event.reference_id && (event.measurements?.length || event.outcome || event.parent_reference_id || event.root_reference_id)} + ChildrenView child events + {/if} + {#each event.measurements ?? [] as measurement (measurement.name)} + {measurement.name}{formatMeasurement(measurement.value, measurement.unit)} + {/each} + {#each Object.entries(event.dimensions ?? {}) as [name, value] (name)} + {name}{value} + {/each} {#if isSessionStart} Duration diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.test.ts new file mode 100644 index 0000000000..cbea28da6c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.test.ts @@ -0,0 +1,57 @@ +import type { EventChart } from '$generated/api'; + +import { describe, expect, it } from 'vitest'; + +import { chartSeries, chartSignature, eventChartSchema, formatMeasurement, measurementUnit, parseChartParameter } from './event-chart'; + +const chart: EventChart = { aggregation: 'avg', display: 'line', measurement: 'duration', mode: 'buckets', unit: 'ms' }; + +describe('event charts', () => { + it('preserves missing values, real zero values, and coincident observations', () => { + const series = chartSeries({ + series: [ + { + name: 'Import', + points: [ + { count: 1, date: '2026-10-01T12:00:00Z', event_id: 'first', value: 0 }, + { count: 1, date: '2026-10-01T12:00:00Z', event_id: 'second', value: 12 }, + { count: 0, date: '2026-10-01T12:05:00Z', value: null } + ] + } + ], + total: 3, + truncated: false + }); + expect(series[0]!.data.map((point) => point.value)).toEqual([0, 12, null]); + expect(series[0]!.data.map((point) => point.event_id)).toEqual(['first', 'second', undefined]); + expect(formatMeasurement(0, 'ms')).toBe('0 ms'); + expect(formatMeasurement(null, 'ms')).toBe('—'); + }); + + it('distinguishes an explicit default chart from an absent or invalid URL override', () => { + expect(parseChartParameter(JSON.stringify(chart))).toEqual(chart); + expect(parseChartParameter('null')).toBeNull(); + expect(parseChartParameter(null)).toBeUndefined(); + expect(parseChartParameter('{invalid')).toBeUndefined(); + expect(parseChartParameter(JSON.stringify({ ...chart, unit: null }))).toBeUndefined(); + }); + + it('requires a unit for measurements and allows generic event counts without a measurement', () => { + expect(eventChartSchema.safeParse({ ...chart, unit: null }).success).toBe(false); + expect(eventChartSchema.safeParse({ ...chart, aggregation: 'count', measurement: null, unit: null }).success).toBe(true); + expect(eventChartSchema.safeParse({ ...chart, aggregation: 'count', measurement: null, mode: 'events', unit: null }).success).toBe(false); + }); + + it('formats counts independently of the measured unit', () => { + expect(measurementUnit({ ...chart, aggregation: 'count' })).toBe('1'); + expect(measurementUnit({ ...chart, aggregation: 'count', mode: 'events' })).toBe('ms'); + }); + + it('compares chart configurations without depending on property order or absent optional fields', () => { + expect(chartSignature(chart)).toBe( + chartSignature({ aggregation: 'avg', display: 'line', group_by: null, measurement: 'duration', mode: 'buckets', unit: 'ms' }) + ); + expect(chartSignature(chart)).not.toBe(chartSignature({ ...chart, unit: 's' })); + expect(chartSignature(null)).toBe(chartSignature(undefined)); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.ts new file mode 100644 index 0000000000..aa8906cca0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/event-chart.ts @@ -0,0 +1,56 @@ +import type { EventChart, EventChartPoint, EventChartResult } from '$generated/api'; + +import { EventChartSchema } from '$generated/schemas'; + +export const eventChartSchema = EventChartSchema.superRefine((chart, context) => { + if (!chart.measurement && (chart.aggregation !== 'count' || chart.mode === 'events')) { + context.addIssue({ code: 'custom', message: 'Select a measurement, or use bucketed event counts.', path: ['measurement'] }); + } + if (!!chart.measurement !== !!chart.unit) { + context.addIssue({ code: 'custom', message: 'Select a measurement and its unit together.', path: ['unit'] }); + } +}); + +export type MeasurementPoint = Omit & { date: Date }; + +export function chartSeries(result: EventChartResult | undefined) { + return (result?.series ?? []).map((series, index) => ({ + color: `var(--chart-${(index % 5) + 1})`, + data: series.points.map((point) => ({ ...point, date: new Date(point.date) })), + key: `series${index}`, + label: series.name, + value: 'value' + })); +} + +export function chartSignature(chart: EventChart | null | undefined): string { + return JSON.stringify(chart ? [chart.measurement ?? null, chart.unit ?? null, chart.aggregation, chart.group_by ?? null, chart.mode, chart.display] : null); +} + +export function formatMeasurement(value: null | number | undefined, unit: string): string { + if (value == null || !Number.isFinite(value)) { + return '—'; + } + const number = new Intl.NumberFormat(undefined, { maximumSignificantDigits: 6 }).format(value); + return unit === '1' ? number : `${number} ${unit}`; +} + +export function measurementUnit(chart: EventChart): string { + return chart.mode !== 'events' && chart.aggregation === 'count' ? '1' : (chart.unit ?? '1'); +} + +export function parseChartParameter(value: null | string | undefined): EventChart | null | undefined { + if (!value) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(value); + if (parsed === null) { + return null; + } + const result = eventChartSchema.safeParse(parsed); + return result.success ? result.data : undefined; + } catch { + return undefined; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 883d2c3063..484dffbc8f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -1,4 +1,5 @@